Commit Graph
936 Commits
Author SHA1 Message Date
Jason P 125c4514b0 Allow Spacebar to advance frames (#11771) 2026-09-09 01:58:10 +00:00
Jason P 95f96439c6 Hide Navigation Bar when shutting down EInk (#11775)
* Hide Navigation Bar on EInk Shutdown

* Revert "Hide Navigation Bar on EInk Shutdown"

This reverts commit 744812555b.

* Hide Navigation Bar on EInk Shutdown

* It's Hide, not Drop
2026-09-09 01:53:17 +00:00
Ben Meadors 784014e8a7 fix(ci): unbreak the ESP32 static analysis gate after the cppcheck 2.20 jump (#11777)
Every PR targeting develop has been red since 2026-09-07 on the seven ESP32
check jobs, while the nRF52, RP2040 and STM32 jobs pass on identical source.

gh-action-firmware#61 moved the ESP32 container images onto the pioarduino
core. Its esp32 platform ships its own tool-cppcheck 2.20.1 and reinstalls it
over anything the repo pins, so ESP32 now analyses with cppcheck 2.20 while
every other platform still resolves platformio/tool-cppcheck 1.21100.230717,
i.e. 2.11. 2.20 parses far more of this tree than 2.11 ever managed, so checks
that were always enabled fired for the first time: 388 defects, ESP32 only.

#11776 cleared the two unknownMacro errors. Of the 386 left, two are worth
acting on and are fixed rather than suppressed:

  * SerialModule dereferenced a null Position in NMEA/CALTOPO mode. `decoded`
    stays NULL when pb_decode_from_bytes() fails, but printWPL() was called
    with *decoded regardless, so a malformed position payload on our portnum
    crashed the node. Emit the waypoint only on a successful decode.
  * InkHUD's 12-hour clock passed a signed 12 to a %u conversion.

The rest are style and performance suggestions - functionStatic and the
const-correctness family account for 351 of them. Suppress those check ids so
the gate means the same thing on every platform again, scoping the one-off
ones to their file so a new occurrence elsewhere still fails. Burning them
down is worth doing deliberately, not under a CI outage.

Verified in the CI container images: all seven previously failing ESP32
environments pass, and rak4631, tracker-t1000-e and t-echo-plus still pass
under cppcheck 2.11.
2026-09-08 23:52:54 +00:00
AustinandClaude Opus 5 9ac0c2c75d fix(checks): silence cppcheck functionStatic on the no-screen Screen stub (#11778)
pioarduino's cppcheck 2.20 reports functionStatic for all 20 methods of the
no-op graphics::Screen defined under !HAS_SCREEN: none of them touch a member,
so it offers to make them static. The advice is wrong here - the stub exists
only to mirror the real Screen's instance API so call sites like
screen->setFrames(...) compile on screenless boards, so the methods have to
stay non-static member functions.

The header is included by 85 translation units, so this fired 1700 times on
the two screenless esp32 boards in the check matrix (heltec-ht62-esp32c3-sx1262
and tlora-c6) - the entire src/graphics low-severity count for those boards.
bin/check-all.sh passes --fail-on-defect=low, so it was failing those jobs.

Wrap the stub in an inline cppcheck-suppress-begin/end block, matching the
inline-suppression style already used elsewhere in the tree.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 18:25:57 -04:00
Jason P 0221fc8044 Address TFT color overlaps in BaseUI (#11735) 2026-09-04 19:16:35 +00:00
Thomas Göttgens 14eaa5587d Honor mute when waking the screen for a received message (#11688)
* fix(ui): honor mute when waking the screen for a received message

TextMessageModule fired powerFSM.trigger(EVENT_RECEIVED_MSG) for every text
packet, gated only by shouldWakeOnReceivedMessage(), which checks external
notification, device role and battery level but never the mute flags. A muted
channel therefore suppressed the banner and still lit the screen.

MessageRenderer::handleNewMessage() only computed mute for MessageType::BROADCAST,
so a DM from a muted node produced a banner and a wake.

Add isMutedForPacket() in Channels: a DM addressed to us reads the sender's
NodeInfoLite mute bit, every other packet reads the mute bit of the channel it
arrived on. This is the predicate ExternalNotificationModule already applied to
the buzzer, vibra and LED outputs, hoisted so all three call sites share it.

Bell and alert messages still break through mute on both paths, unchanged.

No protobuf or config change: ChannelSettings.module_settings.is_muted and the
NodeInfoLite mute bit already exist and are already settable from the device menu
and via AdminMessage.toggle_muted_node.

Closes #11674

* fix(ui): let an alert break through mute on the screen wake path

In COLOR display mode TextMessageModule skips handleNewMessage(), so
powerFSM.trigger(EVENT_RECEIVED_MSG) is the only wake an alert gets. Gating it
on mute alone dropped that wake for a bell on a muted channel.

Add MeshService::isAlertPayload(): an ASCII BEL in the payload while at least one
alert_bell_* output is enabled. The wake gate is now "not muted, or an alert".
MessageRenderer uses the same predicate instead of its own inline bell scan,
which also lifts that scan's arbitrary 100 byte cap.

Rename three test cases. Their names carried exactly 35 characters after the
test_ prefix, which matches the Lob API key format and tripped trufflehog in the
trunk check gate.
2026-09-01 11:52:40 +00:00
Thomas Göttgens b823c8d7fe Let a module hold the screen (#11631)
* Let a module hold the screen

Screen::setModalModule() marks one module as owning the screen, honoured at the three places that would otherwise take it away: the carousel advance in runOnce(), the new-message banner in handleNewMessage(), and Cmd::STOP_ALERT_FRAME, which any caller can currently fire to cancel any alert frame regardless of who started it. Only the owning pointer can release it, so a module with a modal state no longer has to patch Screen.cpp to keep an alert from vanishing when a chat message arrives. The default is nullptr and no in-tree caller sets it, so every existing build behaves exactly as before.

* Address review: clear pauseBanner even while a module holds the screen

START_ALERT_FRAME sets NotificationRenderer::pauseBanner and STOP_ALERT_FRAME is
the only thing that clears it, so swallowing the whole command left banners
suppressed for good once a module took the screen. Only the setFrames() teardown
is now gated on the modal owner.

* Take the modal owner as a pointer to const

Screen never dereferences it; the pointer is only stored and compared, so const
is what the parameter and the member both mean. Fixes the cppcheck
constParameterPointer defect on clearModalModule().

* Add isShowingModuleFrame() so a module can claim keys on its own frame

Input observers registered by modules run before Screen's, so a module that handles UP/DOWN has to know whether its own frame is the one being looked at, or it takes the key away from the frame that is. moduleFrames is already index-aligned with the frame list for drawModuleFrame(), so the check is a lookup against the current frame.

* Address review: match drawModuleFrame's frame selection, trim the comment

Mid-transition drawModuleFrame() renders transitionFrameTarget, so comparing only currentFrame reported false while the module's frame was actually on screen and its input observer would have ignored keys. The header comment is back inside the two-line limit.
2026-09-01 08:50:39 +00:00
HarukiToreda 3683566f62 Don't show new message banner on message screen (#11671)
* message banner

* Update MessageRenderer.cpp

* Fix message banner suppression race on Portduino
2026-09-01 00:45:42 +00:00
IxitxachitlandBen Meadors 7239fe886a fix(BaseUI): let a module frame with no menu fall through the SELECT dispatch (#11659)
#11209 added a module-frame branch to the SELECT chain that claims the press
for any non-null moduleFrames entry, but its body acts only on the
environmental telemetry frame. Every other module frame lands there and the
press dies: the branches below it - waypoint among them - are unreachable.

#11358 already patched one casualty by excluding the nullptr padding, which
restored the node list. Real module frames stayed swallowed, so the waypoint
menu #10920 appended to the end of the chain has never opened on BaseUI.

Enter the branch only when a module frame actually has a menu, so anything
without one falls through to the frames matched after it.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-29 11:31:55 -05:00
Manuelandcoderabbitai[bot] 36c89fa3a7 feat: Support Seeed Wio Tracker L2 (#10909)
* initial commit

* enable power save

* implement mesh LED

* add ADS1115+AW35615 for wio tracker L2

* add ES8311, GT911, AW35615, LP5814 to I2C scanner

* update commit references

* move variant.cpp to extras

* update hw_model

* update lovyanGFX

* point to device-ui commit

* trunk fmt

* fix IO expander (have to take from SensorLib for now as long as AudioThread has the limitation to only support SensorLib and the previous IO expander clashes with duplicate names in arduino-audio-driver)

* workaround duplicate defined symbol

* remove SensorLib; add lightweight Pca9555 class and use unified USE_PCA95X5; add wake button detection

* keep TP_INT disabled(OUTPUT) as we use wake button for wakeup

* PA off by default, enabled when playing sound; add some delay because typical class-D amps (NS4150 family) spec 20–50ms for the output stage to reach full swing after power-on

* refactored AW35615 into new external library

* local revert of PR10571 as this PR completely breaks the alert sound

* fix detection of ADS1115

* update device-ui commit reference

* add synchronisation to IO expander and call toggleDisplay() on wake button press

* add battery curve, fix io expander sync

* add SPILock, simplify macro usage

* update device-ui

* fix wakeup from sleep

* revert because of #11604

* use new AUDIO_AMP_SETTLE_MS

* remove test logs

* enable BaseUI

* use touch screen

* refactor wakekey thread

* fix wake button toggle screen on/off

* fix battery percentage and plugIn state

* Update src/graphics/TFTDisplay.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* consider to return I2C errors to make coderabbi happy

* fix warnings

* use Throttle for millis comparison

* fix endTransmission in write

* make the rabbit happy

* spli targets -tft / non-tft

* fix compile

* revert forced use of Throttle

* remove MeshLED

* add HW_MODEL

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-28 23:30:27 +00:00
Jason P 467dc44cfa Update applyLoraRegion to enable TX on set (#11643)
* Update applyLoraRegion to enable TX on set

* Don't enable TX if in HamMode. User must set callsign first

* Don't use isHam, use owner.is_licensed
2026-08-28 17:20:02 +00:00
Ben Meadors 7e9525ad83 feat(baseui): default US to LongTurbo on first region selection (#11637)
Selecting US in the BaseUI region chooser now installs LongTurbo instead of
LongFast, but only for out-of-box setup: the outgoing region must be UNSET, so
a later switch to US leaves whatever preset the node is running alone.

Scoped to the menu on purpose. The US entry in regions[] keeps LongFast as its
default preset, so preset repair, admin/phone writes and every other route onto
US are unchanged. A build pinning USERPREFS_LORACONFIG_MODEM_PRESET, a preset
already moved off the install default, or use_preset=false all outrank it.

The decision is lifted into menuHandler::presetForRegionSelection() so it is
reachable without a Screen, following toggleNodeMuted().
2026-08-28 11:55:20 +00:00
Ben Meadors 122ec0e9f4 Revert "feat(baseui): default US to LongTurbo on first region selection"
This reverts commit dbba2b3f6c.
2026-08-27 11:43:20 -05:00
Ben Meadors dbba2b3f6c feat(baseui): default US to LongTurbo on first region selection
Selecting US in the BaseUI region chooser now installs LongTurbo instead of
LongFast, but only for out-of-box setup: the outgoing region must be UNSET, so
a later switch to US leaves whatever preset the node is running alone.

Scoped to the menu on purpose. The US entry in regions[] keeps LongFast as its
default preset, so preset repair, admin/phone writes and every other route onto
US are unchanged. A build pinning USERPREFS_LORACONFIG_MODEM_PRESET, a preset
already moved off the install default, or use_preset=false all outrank it.

The decision is lifted into menuHandler::presetForRegionSelection() so it is
reachable without a Screen, following toggleNodeMuted().
2026-08-27 11:23:02 -05:00
Ixitxachitl ad2d27ab5b feat(emotes): add the 📍 pushpin emote (#11618)
Adds U+1F4CD as a 16x16 emote, so waypoint text carrying the pushpin renders a
glyph instead of falling back to the replacement box.
2026-08-26 20:21:50 +00:00
cd6ac90f7e Add waypoint & geofence support with notifications for BaseUI and InkHUD (#10920)
* Implement GeofenceModule for waypoint crossing notifications and integrate with existing modules

* Waypoint Applet Initial Support on InkHUD

* undo tile change

* Update screen when Waypoint shows or dissapears

* Merge branch 'develop' into waypoint-geofence

* Geofence on InkHUD

* Update MapTile.h

* Update WaypointStore.cpp

* Notifications

* remove GF from waypoint screen

* Prevent Focus from closing the notifiaction banner

* Trunk fix

* cleanup

* undo merge conflix mistake

* Waypoint screen on BaseUI

* Focus preserve fix

* UI bugs

* Allow Inkhud to remove waypoint

* Respect Locked Waypoints

* Trunk fix

* Update WaypointStore.cpp

* Use 8-digit hex formatting for waypoint IDs.

0x%x was inconsistent with the repo's own convention (0x%08x for 32-bit IDs, used elsewhere in this file). Fixed here and in two other spots I found with the same issue (WaypointModule.cpp, GeofenceModule.cpp).

* Update ExternalNotificationModule.cpp

* Reject invalid surrogate codepoints in waypoint icon rendering

* Update WaypointModule.cpp

* Update WaypointStore.cpp

* Update WaypointStore.cpp

* Update WaypointStore.cpp

* trunk fix

* fix warnings

* power.h rename to Power.h

* Update Power.h

* Fix executable bit on bin/lint-ifdef-complexity.sh

Lost during a prior merge from develop (Windows checkout doesn't
preserve file mode), causing "execve failed: Permission denied" in
the Trunk Check Runner CI job. develop has this file at 100755;
restoring that here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Update README.md

* Clean up waypoint and geofence integration

* Minimize waypoint and geofence implementation

* removed unnecessary gating

* Geofence alert

* trunk fix

* Update test_main.cpp

* Update WaypointStore.cpp

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 16:17:28 +00:00
Thomas Göttgens 7b0004806a fix(graphics): drive GPIO backlights from the stored brightness level (#11588)
* fix(graphics): drive GPIO backlights from the stored brightness level

Screen::handleSetOn restored PIN_EINK_EN only when screen_brightness was
exactly 1. The field is 0..255 and defaults to 153, so the frontlight stayed
off after a screen timeout until the next reboot.

InputBroker read screen_brightness as "currently lit" for the touch backlight,
so a stored level made touch-to-light a no-op. The HAPTIC_FEEDBACK_PIN block
then reassigned touchConfig.onPress and onRelease, dropping those handlers on
any variant defining both.

MINI_EPAPER_S3 names its panel power rail PIN_EINK_EN. It was switched off with
the screen and never restored.

graphics::Backlight gains a GPIO backend covering PIN_EINK_EN and
PCA_PIN_EINK_EN, so Screen, MenuHandler and InputBroker call backlightOn,
backlightOff, backlightToggle and backlightIsLit instead of touching pins.
backlightIsLit reports the driven state, separate from the configured level.

Power-up state is declared per variant with GPIO_BACKLIGHT_DEFAULT_ON rather
than hardcoded in the e-ink driver. The backend stores only 0 or 255, so any
other stored level falls back to the variant default and no board changes its
existing behaviour. MINI_EPAPER_S3 is excluded and keeps its rail powered.

Touch handlers are merged so backlight and haptic feedback compose.

Verified on ThinkNode M1: lit at boot, off on timeout, lit on wake, and an
explicit off surviving both wake and reboot.

* chore(thinknode_m1): correct the LED pin comments

P0.13 drives the blue indicator, not a green one. P1.06 is a second drive for
the same red LED as LED_POWER, which is why it stays disabled.

* fix(graphics): clamp GPIO backlight levels at the setter

backlightSet stored whatever level it was given, so a caller passing an
intermediate value left backlightGet and the persisted config holding a level
the rail cannot drive. Clamp to off or on in the setter, which keeps the
invariant at the single write point instead of only at init.
2026-08-25 08:49:37 +00:00
Ixitxachitl d8a95a76b4 fix(TFTDisplay): give the CO5300 its post-sleep-out settle time (#11587)
LovyanGFX's Panel_CO5300 init table issues Sleep Out and Display On
back-to-back with zero delay. The controller needs up to 120 ms after
Sleep Out before it accepts Display On; when it is slow to wake, Display
On is swallowed and the panel stays dark until the next re-init (screen
wake), while the firmware runs normally. Override the init table with
the datasheet delays. Fix belongs in LovyanGFX ultimately; this carries
it until the pin updates.
2026-08-24 19:52:17 +00:00
Ben MeadorsandQuency-D bfd1e1a231 Add Heltec RC32, RC52 and RCC6 boards, and LC760CA GNSS support (#11572)
* refactor(graphics): select Arduino_GFX panels with a capability flag

TFTDisplay tested `defined(HACKADAY_COMMUNICATOR)` in a dozen places to mean
"this panel is driven by Arduino_GFX rather than LovyanGFX". Every new
Arduino_GFX board had to be appended to all of them.

Move the decision into the variant as USE_ARDUINO_GFX so the display code
stops naming individual boards. No behaviour change: the Hackaday Communicator
is still the only board that sets it.

* feat(boards): add Heltec RC32, RC52 and RCC6

Three boards around the same 128x220 NV3001B panel: RC32 (ESP32-S3), RCC6
(ESP32-C6) and RC52 (nRF52840). They differ only in how the panel bus is
wired, so they share one branch in TFTDisplay behind TFT_NV3001B.

RC32 and RC52 also carry a rotary encoder on a TCA6408 I2C expander. That
lands as its own input source rather than as board conditionals inside
i2cButton, which is the M5Stack UnitC6L button driver and stays untouched.

On RC52 and RCC6 the panel is an add-on module, so probe it before reporting
a screen. The probe reuses the bit-banged SPI helper that already backs the
T114 ST7789 check.

Arduino_GFX is pinned to the upstream commit that added the NV3001B driver;
it has not shipped in a tagged release yet.

Co-Authored-By: Quency-D <55523105+Quency-D@users.noreply.github.com>

* feat(gps): detect and configure the LC760CA GNSS module

The LC760CA is another Unicore part, so it joins the $PDTINFO probe family
and reuses the CM121 message-rate setup. It answers with CC1161W.

GNSS_MODEL_LC760CA goes immediately before GNSS_MODEL_GENERIC_NMEA: the
sentinel has to stay last because isValidGnssModel() uses it as the exclusive
upper bound on values the probe cache may hold. Placing the new model after
it would leave LC760CA permanently uncacheable.

Co-Authored-By: Quency-D <55523105+Quency-D@users.noreply.github.com>

* fix(graphics): re-init the NV3001B after the panel rail comes back

DISPLAYOFF de-asserts VTFT_CTRL, which cuts power to the panel, so the
controller loses MADCTL, COLMOD and gamma. displayOn() only sends sleep-out
and cannot restore them, leaving the panel dark or in the wrong format after
wake. Re-run begin() once the rail has settled, and repaint in full since the
re-init leaves display RAM undefined.

Also stop the TCA6408 rotary polling from two threads at once. Registering as
an InputPollable meant InputBroker's pollSoon task could call pollOnce() while
runOnce() was mid-transfer on the main thread, with nothing serialising Wire
or the decoder state. Drop InputPollable and have the interrupt wake the
thread instead, the way ButtonThread does, so the bus and the decode stay on
one thread.

* fix(graphics): skip the NV3001B wake when re-init fails

begin() reports whether the bus came up. Ignoring it meant a failed re-init
still lit the backlight and drove a full-screen repaint at a panel that was
never initialised.

* chore(boards): ship the Heltec RC boards at release level

release is the normal level for a variant; the matrix generator still builds
each of these in this PR because they add a new platformio.ini.

---------

Co-authored-by: Quency-D <55523105+Quency-D@users.noreply.github.com>
2026-08-23 11:00:37 +00:00
0b906b4d15 T-Watch Ultra support (#8171)
* feat: T-Watch Ultra support

* fix init touch controller

* add framebuffer

* update to device-ui

* trunk fmt

* update amoled driver reference

* PMU cosmetics

* power off lora

* fix NodeDB defaults

* trySetRTC when fixedPosition

* haptic touch (only BaseUI)

* init lora RF switch

* update LovyanGFX 1.2.19

* earlyInitVariant() adaptations acc. #9438

* update device-ui / touch handling

* Set NFC_CS disabled on boot

* Get t-watch-ultra working better on BaseUI

* Fix compilation

* Fix flash reads on t-watch-ultra

* Get baseui drawing to the screen correctly again on t-watch and add touch IRQ handling

* Add PMU IRQ handling

* Add IMU support

* Change define to avoid collision

* BaseUI changes to support t-watch-s3 rounded screen (#10786)

* BaseUI changes to support t-watch-s3 rounded screen

* Extend margin work to CannedMessages

* Finish merge

* Get audio working on watch-ultra

* trunk fmt

* added custom_meshtastic boilerplate

* T-Echo-Plus: disable BHI260AP while assumingly not implemented

* Drop the duplicate origBold declaration from the merge

* Inset incoming message bubbles on rounded screens

* Fix RTTTL tempo, WiFi screen margins, PMU guard and a duplicate define

* fix compile errror (the 2nd time)

* fix SDcard

* fix/workaround CO5300 pixel flush to SPI

* trunk fmt

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-20 12:28:57 +00:00
Clive BlackledgeandClaude Opus 5 389559bddb fix(NodeDB): re-derive my_node_num when ensurePkiKeys() mints the identity keypair (#11426)
* fix(pki): re-derive NodeNum when setting a region mints the identity key

A node's mesh address is derived from its identity key:

    my_node_num == crc32Buffer(config.security.public_key.bytes, 32)

NodeDB::createNewIdentity() is what establishes that, and NodeDB::
generateCryptoKeyPair() is the only thing that called it.

CryptoEngine::ensurePkiKeys() generates or re-derives the keypair and writes
security.public_key, security.private_key and user.public_key - but never
re-derives my_node_num. Boot-time keygen is suppressed while the LoRa region is
UNSET (generateCryptoKeyPair()'s regionBlocksKeygen guard), so on a fresh device
my_node_num is still the MAC-derived value from pickNewNodeNum(). The user then
sets the region - the stock onboarding flow - ensurePkiKeys() mints a key, and
the invariant is broken.

The node then signs its broadcasts (Router.cpp signs when !pki_encrypted &&
(owner.is_licensed || isBroadcast(p->to))). Every receiver runs
verifyFirstContactNodeInfo, fails crc32Buffer(user.public_key) != p->from, and
drops the NodeInfo. The node's identity beacons are invisible to the mesh.

Nothing reboots to repair it: AdminModule sets requiresReboot = false for LoRa
changes ("All LoRa radio changes apply live via configChanged observer") and
MenuHandler ends at service->reloadConfig(changes).

Four call sites reached ensurePkiKeys():

  1. AdminModule set_config LORA, region first set   (phone app - the common path)
  2. MenuHandler applyLoraRegion                     (on-device region picker)
  3. InkHUD MenuApplet applyLoRaRegion               (schedules a reboot, so it
                                                      self-healed at next boot)
  4. portduino wasm wasm_set_region

The reference implementation was already in the tree: the *licensed* branch of
call site 1, thirteen lines below the broken unlicensed one, calls
nodeDB->generateCryptoKeyPair() (which reaches createNewIdentity()) and widens
the persisted mask with SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE.

Rather than repeat that at four call sites, the key-mint is routed through one
chokepoint that owns both halves of the identity: NodeDB::ensurePkiIdentity()
calls crypto->ensurePkiKeys() and then createNewIdentity(). It lives in NodeDB
because createNewIdentity() operates on the devicestate/node-DB globals, which
CryptoEngine deliberately does not touch - ensurePkiKeys() takes the security
config and user by reference precisely so it stays free of that dependency, and
it is unit-tested against a standalone CryptoEngine.

ensurePkiIdentity() returns true only when my_node_num actually moved
(createNewIdentity() early-returns when the key is unchanged, so a repeat region
change does not disturb the self entry or force a needless flash write). Callers
use that to widen their save mask; my_node_num lives in devicestate and the self
row moves in the node DB, so both segments must be persisted or the fix would
revert at the next boot. SEGMENT_CONFIG, which carries the key itself, is
already unconditional on all four paths.

The InkHUD reboot is left as-is. It is now redundant for this invariant, but it
covers the rest of that menu's behaviour and a redundant reboot is not a bug.

Adds test_handleSetConfig_persistsUnlicensedFirstRegionIdentity, the unlicensed
twin of the existing licensed test, asserting both the segment mask and
my_node_num == crc32(public_key).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(NodeDB): trim identity-recovery comments and guard the WASM nodeDB deref

Two review asks, no behaviour change on any built target.

Copilot flagged the unguarded nodeDB deref in the WASM region setter; it is the
only ensurePkiIdentity() call site that did not check the pointer first.

The rest is comment length. AGENTS.md:83 caps code comments at two lines, and the
identity-recovery comments across the four call sites plus the NodeDB.h doc block
ran to four and six lines. The rationale they carried is in the commit messages
and the PR body, which is where AGENTS.md says it belongs.

The PR's own fix in AdminModule.cpp is deliberately untouched.

* fix(NodeDB): keep the identity move authoritative when the self record cannot be created

createNewIdentity() removes the old node entry and assigns myNodeInfo.my_node_num
before it tries to create the row for the new number. If getOrCreateMeshNode()
came back null it returned false, so the first-region callers left
SEGMENT_DEVICESTATE and SEGMENT_NODEDATABASE out of the save mask.

The number had already moved in RAM at that point, and the freshly minted key
goes to flash under SEGMENT_CONFIG regardless. The next boot therefore reloads
the old number alongside the new key, which is exactly the
crc32(public_key) != my_node_num break this path exists to prevent, reached
through the error branch instead of the happy one.

Rolling the number back is not an option either, since the key has already been
replaced by the time this runs. So the move is now reported as the fact it is and
the missing self record is logged separately; getOrCreateMeshNode() will recreate
that row on the next contact. Reachable when the self record is absent and the
table is full of protected nodes.

Reported by CodeRabbit on #11426.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:23:02 +00:00
Thomas Göttgens bb6a81f1e9 Pass framebuffer rotation through DisplayDriverConfig (#11534)
* tftSetup: pass framebuffer rotation via DisplayDriverConfig

Replaces the MESHTASTIC_FB_ROTATION environment variable with
DisplayDriverConfig::rotation(), which device-ui reads in
FBDriver::create(const DisplayDriverConfig &).

* tftSetup: carry framebuffer rotation in the panel config

Use the DisplayDriverConfig builder with panel_config_t::offset_rotation
instead of a dedicated rotation setter. Width and height fall back to the
device-ui defaults when the yaml does not set them.

* tftSetup: pass the framebuffer panel config unfiltered

Take Display.Width, Display.Height and Display.OffsetRotate straight from
the portduino config, like the CUSTOM_TFT branch does.
2026-08-19 15:50:49 +00:00
Ben Meadors e1ea653a45 fix(graphics): crash and leak fixes across display drivers (#11455)
* fix(graphics): crash and leak fixes across display drivers

- TFTDisplay (portduino): _touch_instance was an uninitialized member,
  and the touch-config block only assigns it for xpt2046/stmpe610/
  ft5x06 while the guard accepts any configured module. A gt911 entry
  in config.yaml (supported by the color-UI path) reached
  _touch_instance->config() through an indeterminate pointer. Initialize
  to nullptr and guard the config block.

- Screen: the destructor freed normalFrames but leaked the owned
  dispdev (driver + framebuffer) and ui objects. Screen is genuinely
  destroyed on the portduino reboot path (screen = nullptr in
  Power.cpp).

- EInkDisplay2: GxEPD2_BW's constructor takes the low-level driver by
  value and stores a copy, so the 'new EINK_DISPLAY_MODEL' at nine
  sites was orphaned the moment connect() returned. Pass temporaries,
  as GxEPD2Multi already does.

- EInkParallelDisplay: the async full-refresh task cleared
  asyncFullRunning before nulling asyncTaskHandle, so the destructor
  could observe running==false with a stale handle and vTaskDelete a
  freed TCB. Null the handle first (same ordering fix the
  eink/Drivers/EInkParallel.cpp sibling already carries).

- Panel_sdl: initFrameBuffer only null-checked the first of its three
  allocations and returned true regardless, leaving the line array
  full of null+offset garbage on failure; later redraws would write
  through those. Check all three, release partial allocations, and
  return false.

* fix(graphics): propagate Panel_sdl framebuffer allocation failure from init()

Per review: initFrameBuffer() can now fail cleanly, so init() must not
register the monitor and report success when it does.
2026-08-18 12:10:03 +00:00
c308d0aca4 feat: Support Elecrow ThinkNode M9 (#10908)
* thinknode-m9 variant

* move lora to SPI1 device

* enable SDcard

* use HSPI

* BaseUI tft -> HSPI

* buzzer, webdav lib

* fix build issues

* M9 default to MUI, no BT, short ringtone

* add keyboard long-press config

* update variant

* add ThingNode-M9 GPS string

* GPS 115200 baud

* Basic BaseUI support

* Fixup power detection

* Compass and KB fixes for M9

* add timed Lock::lock()

* add SD card

* point device-ui to thinknode m9 draft branch

* trunk fmt

* fix FusionCompass

* Fix t-deck-tft linker arg list overflow in CI

* SDcard/lora fix: SPI1 must not be declared twice in arduino 3.x -> reuse SPI1 defined in FSCommon.cpp

* update battery parameters

* reinit SD card when updating; fix PSRAM size

* update lib versions

* fix wakeup on key press (KB_INT)

* fix default nag_timeout for TFT/MUI devices with buzzer

* increase PSRAM and SD freq

* trunk fmt

* update lovyanGFX 1.2.26

* update device-ui commit reference

* fix screen definition

* remove DONE; maybe a keyword or other used identifier

* fixed CI error nag_timeout

* fix prepareSleep initialization

* trunk fmt

* reduce SD SPI frequency

* update device-ui

* fix SDcard issue

* stage

* fix device-ui commit reference

* fix device-ui commit

* update device-ui commit (fixed keyboard lag)

* fix QMI8658

* trunk fmt

* update .ini meta information, align SD freq

* fix device-ui reference to target (ready to merge)

* device-ui for all other targets

* make the rabbit happy

* trunk fmt

* fixed lock screen

* fix compile error

* SPI lock timeout

* apply device-ui fix

* revert bad RadioLib commit hash in platformio.ini

Co-authored-by: mverch67 <71137295+mverch67@users.noreply.github.com>

* fix wrong commit hash change

* fix fix commit fix

* I love changing random numbers in random files

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-08-17 17:28:33 +00:00
Ben Meadors fb6a212b44 fix(graphics): make on-screen keyboard lifecycle safe and RAII-managed (#11460)
- VirtualKeyboard::handleLongPress VK_ESC invoked the onTextEntered
  member std::function directly, but that callback path reaches
  OnScreenKeyboardModule::stop(), which destroys the keyboard - and
  with it the std::function whose invocation is still on the stack.
  handlePress and submitText already deliberately copy-and-clear before
  invoking for exactly this reason (CannedMessageModule documents the
  same hazard); do the same here.

- OnScreenKeyboardModule's keyboard becomes unique_ptr, replacing the
  delete-in-destructor / delete-then-new-in-start / delete-in-stop
  bookkeeping that runs on every keyboard open/close. The
  NotificationRenderer legacy hook receives a non-owning raw pointer,
  as before.
2026-08-14 10:05:16 +00:00
Ben MeadorsandClaude Opus 5 230da77642 fix(time): convert the millis() rollover sites #11291's CI guard cannot see (#11483)
* MeshPacketQueue: fix millis() rollover in the late-packet drop test

replaceLowerPriorityPacket() read `backPacket->tx_after < now`, with `now`
taken from millis() on the line above. tx_after is an absolute deadline, so
that comparison inverts while the deadline sits on the far side of the 32-bit
wrap: a queued late packet reads as not-yet-due for the rest of the wrap
window, or every late packet reads as droppable at once. The same statement
ordered two deadlines against each other with `backPacket->tx_after >
p->tx_after`, which has the same problem.

#11291 swept every site where millis() sits next to the comparison operator,
and its CI guard matches that shape. Stashing the clock in a local first is
the same bug written so the guard cannot see it.

Both tests now subtract before comparing: the due test through
Throttle::deadlinePassedAt(), and the ordering through the elapsed-since-now
form already used in AdminModule's oldest-slot scan. The snapshot comes from
Time::getMillis() so the deadlines and the test read one clock, per the
convention deadlinePassedAt() documents.

The `dt` the log line reports is now derived from the same elapsed value
rather than recomputed. Behaviour is otherwise unchanged, save the boundary:
deadlinePassedAt() is inclusive, so a deadline landing exactly on `now` reads
as due rather than one millisecond early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* RadioLibInterface: don't widen a uint32_t deadline delta into a 64-bit long

TRANSMIT_DELAY_COMPLETED tested whether the front packet was still waiting
with

    long delay_remaining = txp->tx_after ? txp->tx_after - millis() : 0;
    if (delay_remaining > 0) ...

The subtraction is uint32_t. Where long is 32-bit - every embedded target -
an already-due deadline lands negative and the packet transmits, which is why
this has never been visible on device. Where long is 64-bit (portduino, and
the native test build) the same value zero-extends to ~4.29e9, reads as
positive, and the packet is rescheduled 49.7 days out. It stays parked until
some later notifyLater() with overwrite happens to reset the timer.

That is not an edge case. notifyLater() schedules through
setIntervalFromNow(), so the thread wakes at or after the deadline; being a
millisecond past due is the ordinary path through this branch.

Ask Throttle instead. deadlinePassedAt() is the unsigned half-range test, so
there is no signed conversion to get wrong at any width, and the remaining
delay handed to notifyLater() is computed from the same snapshot. On 32-bit
the behaviour is identical, including at the boundary: a deadline equal to
now transmitted before and still does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ExpressLRSFiveWay: convert the two remaining raw window checks to Throttle

runOnce() dismissed the alert frame with `now > alertingSinceMs + 2000` and
chose its poll rate with `now < keyDownStart + 20000`, both against a millis()
snapshot in a local. Same rollover inversion as any other naive compare, and
invisible to the millis-deadline-check guard because millis() is not adjacent
to the operator. update() in the same file was already on Throttle.

hasElapsed()/isWithinTimespanMs() with the stored event give the full ~49.7
day range and need no snapshot. Sentinels are unchanged in meaning:
`alerting` is the armed flag for alertingSinceMs and is tested first, and
keyDownStart == 0 reads as "recent" for the first 20s of uptime exactly as
`now < 0 + 20000` did - a poll rate either way.

The arm sites move to Time::getMillis() so the writes land on the clock
Throttle reads, which also puts them within reach of Time::setTestMillis().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* GPSUpdateScheduling: record whether a search is running, don't infer it

elapsedSearchMs() answered "am I searching?" by ordering two raw millis()
stamps: searchStartedMs > searchEndedMs. Whichever stamp lands on the far
side of the 32-bit wrap reads as the larger one, so the answer inverts once
per wrap cycle, in both directions:

  - a search that started before the wrap and ended after it keeps reading as
    "searching". elapsedSearchMs() then grows without bound and
    searchedTooLong() aborts a search that is not running.
  - a search that started after the wrap, following one that ended before it,
    reads as "idle". elapsedSearchMs() returns 0, so an unproductive search is
    never aborted and the receiver stays powered until it locks.

Both self-heal at the next informSearching(), which bounds the damage to one
GPS cycle - but the ordering test cannot be made wrap-correct, because the
two stamps carry no information about which wrap they belong to.

It does not need to be. Whether a search is in progress is a fact the three
inform*() calls already have in hand; the ordering was only ever standing in
for it. Add the flag and set it there. elapsedSearchMs() keeps its unsigned
subtraction, which was always the correct part.

The file's clock reads move to Time::getMillis() so the suite can drive them
across the wrap. Behaviour-preserving in production - Time::getMillis() is
millis() unless a test injects a clock.

test_gps_update_scheduling/ gains seven cases: the idle/searching/ended
states, elapsed exactness across the wrap, both inversion directions above,
and reset(). The two wrap cases fail on the old predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* MessageStore: date boot-relative messages in uptime seconds

A message received before the wall clock is trustworthy is stamped
boot-relative and healed by upgradeBootRelativeTimestamps() once the RTC
arrives. Both the stamp and the "same boot?" test were millis() / 1000, which
wraps every 49.7 days: a stamp taken before the wrap reads as newer than
`bootNow` afterwards, so `m.timestamp <= bootNow` declines to heal it and the
message shows "???" until it ages out. MessageRenderer's own copy of the test
falls the same way and prints invalidTime.

Neither produces a wrong time - the guard is what fails safe - but
Time::getUptimeSecs() landed in #11291 for exactly this, and does not wrap for
136 years. Both sites take it, which makes the comparison exact rather than
merely fail-safe.

While here, the autosave tick had its own hand-rolled deadline helper -
`reachedMs(now, target)` as `(int32_t)(now - target) >= 0`. Wrap-correct, but
a competing idiom for what Throttle::isWithinTimespanMs() already answers, and
the signed cast is the form #11291 replaced everywhere else. Deleted; the
stamps read Time::getMillis() so the whole path is on one clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* WebServer: drop the hand-rolled millis() wrap branch

getAdaptiveInterval() special-cased the wrap by hand:

    if (currentTime >= lastActivityTime)
        timeSinceActivity = currentTime - lastActivityTime;
    else
        timeSinceActivity = (UINT32_MAX - lastActivityTime) + currentTime + 1;

Those two expressions are the same number - unsigned subtraction already
computes the difference modulo 2^32 - so this is not a bug, just eight lines
reimplementing what Throttle does. It also reads like a site that has thought
about the wrap and settled it, which makes it a bad example to copy.

Two isWithinTimespanMs() calls against the stored activity stamp, matching
ethApiServer's shape for the same adaptive-interval decision. The stamps move
to Time::getMillis() so the writes and the reads share a clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* MeshPacketQueue: only order elapsed times once both deadlines have passed

The late-packet eviction I rewrote compared how long ago each deadline passed:

    backElapsed < (uint32_t)(now - p->tx_after)

That is only an ordering when both deadlines are in the past. An incoming
packet whose tx_after is still in the future subtracts to a near-2^32 elapsed,
which reads as the most overdue packet in the queue rather than the least - so
a full queue would drop the overdue packet it was about to transmit in favour
of one that is not ready yet. The comparison it replaced,
`backPacket->tx_after > p->tx_after`, got this right away from the wrap; I
lost it in the conversion.

Classify before ordering: p->tx_after must be unset, or passed, before its
elapsed time means anything. Two expired deadlines still order by which is
further overdue, which is what the branch is for.

Caught by CodeRabbit on #11483.

test/test_meshpacket_queue/ pins the branch: the future-dated arrival that
started this, both directions of the both-expired ordering, the undelayed
arrival, and all of it again with the deadlines and `now` on opposite sides of
the wrap. maxLen is 1 so the suite reaches the branch without dragging in
CompareMeshPacketFunc and a NodeDB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ExpressLRSFiveWay: treat "no key pressed yet" as no activity

keyDownStart is 0 until the first press of a boot, and the fast-poll window
read that as a press at time zero: 100ms polling for the first 20s of uptime
with no activity at all, re-triggering once per millis() wrap. The arithmetic
this replaced (`now < keyDownStart + 20000`) did the same, so it is not a
regression - but the sentinel is exactly what the conventions say to test
before the elapsed comparison, and "has there been recent key activity" has an
honest answer here.

250ms is the documented floor for not missing presses, so an idle node simply
starts there and moves to 100ms on the first press.

Also trims the wrap-cases comment in test_gps_update_scheduling to the
two-line house limit.

Both from CodeRabbit review on #11483.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:15:21 -04:00
Jason P c144fa5484 Fix the IFDEF guard causing OLED_TINY failures (#11484) 2026-08-13 13:14:42 -04:00
Jason P 10217172f0 Fixed GPS icon alignment for all OLEDs (#11463) 2026-08-12 18:51:07 -05:00
Jason P f06f808391 Add Transmit Enabled menu to LoRa frame (#11442)
* Add Transmit Enabled menu to LoRa frame

* Fix outer variable shadows
2026-08-12 21:59:26 +00:00
fdb644e0b7 Fix millis() rollover in deadline, interval, and timestamp handling (#11291)
* Add native test coverage for the UptimeClock monotonic seam

src/UptimeClock.{h,cpp} shipped without a dedicated test suite. Port the six
tests from the monotonic-time branch (test/test_time), retargeted to the
renamed header.

The wrap test crosses 0xFFFFFFFF via advanceTestMillis() rather than a second
setTestMillis(): setTestMillis() sets clockSourceChanged, which makes
getMillis64() rebase its accumulator and swallow the wrap.

* NextHopRouter: fix 49.7-day millis() rollover in retransmission timing

Resolves the "FIXME, handle 51 day rolloever here!!!" in
NextHopRouter::doRetransmissions() by switching the retransmission-due
comparison from plain unsigned <= to a signed-difference cast.

The previous p.nextTxMsec <= now comparison silently breaks across the
~49.7 day millis() wraparound: pending retransmissions either stall
for the remainder of the wrap window, or all fire simultaneously at
the rollover boundary. Long-running router/infrastructure nodes do hit
this in practice.

The replacement (int32_t)(p.nextTxMsec - now) <= 0 is the standard
Arduino/embedded idiom for rollover-safe deadline checks and behaves
identically to the original for any non-wrap timing.

* Address Copilot review: use unsigned half-range for rollover-safe retransmit check

Review feedback from @Copilot on PR #10227: casting a uint32_t
subtraction to int32_t is implementation-defined in C++ when the
unsigned value exceeds INT32_MAX (even though it works on typical
two's-complement targets).

Switch to the fully well-defined unsigned half-range form:
  nextTxMsec is in the past-or-equal iff (now - nextTxMsec) has not
  wrapped past 2^31 ms. Future offsets < 2^31 ms wrap into the top
  half and read as 'not yet'.

Same semantics as the signed-cast version on every two's-complement
platform we care about, but portable to any conforming C++ impl.

* Use monotonic time for airtime windows

* Document monotonic airtime windows

* Fix test_packet_signing sentinel that #10227's rollover fix inverts

test_C3_invalid_repeated_packet_cannot_ack_or_change_retry_state parked a
pending packet at nextTxMsec = UINT32_MAX to mean "never retransmit", then
asserted that a rejected repeated packet leaves the retry state untouched.

NextHopRouter::doRetransmissions() now tests whether a retransmit is due with
an unsigned half-range compare, (uint32_t)(now - nextTxMsec) < 0x80000000u,
so that retransmission timing survives the ~49.7 day millis() wrap. Under it
now - 0xFFFFFFFF == now + 1, a small positive delta, so UINT32_MAX reads as
~1ms in the past: the retransmit fires and rewrites nextTxMsec, and the test
failed with "Expected 4294967295 Was 6247".

Use a representable future time instead. Production is unaffected either way -
nextTxMsec is only ever written as millis() + d, and UINT32_MAX came from the
test harness alone - so the sentinel is what needs to go, not the comparison.
Special-casing UINT32_MAX in the retransmit path would keep a value that reads
as "expired" under any wrap-correct compare.

The value is held in a local because millis() advances across
runPipelineIngress(), so recomputing it at the assertion would compare against
a different number.

Reported upstream on meshtastic/firmware#10227, whose branch predates this test.

* Make Throttle time-injectable and add hasElapsed()

Throttle backs ~94 call sites, which makes it the highest-leverage place in
the tree to put the clock seam: reading Time::getMillis() instead of millis()
in its three call sites turns all of them into time-injectable code at once,
without touching any of them. The 32-bit millis() wrap is not otherwise
reachable from a native test.

The read is behaviour-preserving - Time::getMillis() returns millis() unless a
test injects a clock - and the full native suite passes with it live.

Also add hasElapsed(), the complement of isWithinTimespanMs(), because 51 of
the 94 call sites are spelled !isWithinTimespanMs and read poorly. Its
boundary is inclusive (>=) since isWithinTimespanMs uses <; both are
documented. It deliberately does not treat lastExecutionMs == 0 as "never
run": call sites pair that test with the interval check themselves, and
absorbing a sentinel into the one helper every module depends on is exactly
the value-overloading hazard being removed elsewhere.

Migrating the existing !isWithinTimespanMs sites is cosmetic and deliberately
left out of this commit.

test/test_throttle/ covers window semantics, both boundaries, the complement
identity, execute()'s first-run and throttled paths, and - the point of the
exercise - a window opened before the wrap closing correctly after it,
including at the 24h interval that is the longest in the tree.

* Stop disarmed deadline sentinels reaching the comparison

Two deadline variables encoded "inactive" as a magic value that only reads as
"never" because the comparison against it is a naive millis() compare. Under
any rollover-correct comparison both invert to "expired ~49 days ago", so they
have to be untangled before those comparisons can be fixed.

Power::reboot() set rebootAtMsec = -1 on platforms with no reboot
implementation, intending "never fire". Every reader already treats 0 as the
disarm value - powerCommandsCheck() tests `if (rebootAtMsec && ...)`, and
AdminModule writes 0 to cancel - so -1 was both wrong and unnecessary. Use 0.
Left as UINT32_MAX it would reboot-loop the moment the comparison is corrected.

ExternalNotificationModule's nag window compared against nagCycleCutoff, which
holds UINT32_MAX once stopped and 1 at boot. isNagging is the real armed flag,
so test it first and short-circuit: a disarmed cutoff can no longer reach the
arithmetic, while an idle module still takes the same sleep path that the
boot-time value of 1 was relying on.

Note this fixes the sentinel only. The comparison itself is still a naive
`nagCycleCutoff < millis()` and remains on the list to convert.

* Fix millis() rollover in every deadline and interval comparison

Roughly 20 sites compared against millis() directly - `millis() > deadline`,
`deadline < millis()`, `last + interval < millis()`. All of them break for
about 24 days after the 32-bit millis() wrap: depending on which side of the
wrap each value sits, the action either stalls for weeks or fires immediately
and repeatedly. The longest affected interval is the 12 hour NTP renewal, a
~50x margin against the wrap, so none of these needed the range - only the
correct comparison.

Add Throttle::deadlinePassed(deadlineMs) for sites that store an absolute
deadline they cannot re-express as "interval since an event". It uses the same
unsigned half-range test as NextHopRouter::doRetransmissions() rather than
introducing a competing signed-cast idiom, and unlike the signed cast it is
defined for every input. Sites that do store an event use the existing
isWithinTimespanMs / hasElapsed. Nothing gained new state.

Because both helpers read Time::getMillis(), every converted site is now
reachable from a native test that drives the clock across the wrap; the
comparison itself is covered directly in test/test_throttle/.

Sentinel handling is the reason this could not be a mechanical rewrite. The
disarm convention is not uniform: 0 means "inactive" for rebootAtMsec,
shutdownAtMsec, alertBannerUntil, fixHoldEnds, suppressUntilMs and
touchResumeBlockUntilMs; 0 means "due now" for ntp_renew, which is forced to 0
at link-up; UINT32_MAX means "inactive" for nagCycleCutoff; and
alertBannerUntil == 0 in isOverlayBannerShowing() means "show indefinitely".
Every inactive marker is arithmetically far in the past, so a correct
comparison fires on it - each site tests its sentinel before the arithmetic,
and keeps the meaning it had.

Two sites carried a second bug found on the way:

BME680Sensor tested (stateUpdateCounter * STATE_SAVE_PERIOD) < millis(). With
a 6 hour period and a uint16_t counter that product overflows uint32_t after
about 198 saves, independently of the millis() wrap. It now measures the
interval since the last save.

EInkDynamicDisplay had `if (previousRunMs > millis()) return;` as a millis()
overflow guard, which skipped rate limiting entirely for the whole post-wrap
period - the bug it meant to prevent. Every check below it already goes
through Throttle, so the guard is removed rather than fixed.

MotionSensor's calibration countdown is converted to a signed delta rather
than deadlinePassed, because it needs the remaining magnitude and not a
boolean; that matches the already-correct check in the same file.

* Remove getMillis64() and use Throttle for the NodeInfo reply window

getMillis64() had exactly one caller and no callers in tests. It also carried
obligations that made it the wrong shape for this firmware: a wrap accumulator
in mutable statics, which is not ISR-safe, and which must be polled at least
once every ~49.7 days or it silently misses a wrap and returns a time ~49 days
short.

Its one caller only wanted to know whether a 12 hour suppression window had
elapsed - which Throttle answers correctly across the wrap without any
accumulator. NodeInfoModule now stores Time::getMillis() in lastNodeInfoSeen
and tests the window with Throttle::isWithinTimespanMs, so the map holds
milliseconds rather than seconds derived from a 64-bit read.

USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS is user-overridable and now feeds a
multiply by 1000, so a static_assert rejects any value too large to express in
milliseconds instead of letting it wrap.

clockSourceChanged goes too. It existed solely to rebase getMillis64()'s
accumulator when a test swapped clock sources, and it made the wrap untestable
through the injection API: setTestMillis() set the flag, so a wrap crossed by
two setTestMillis() calls was swallowed. With the accumulator gone the flag has
nothing to rebase, and the injection API is a plain settable clock.

The three getMillis64 tests are dropped as they no longer describe anything.
One test replaces them, pinning that advanceTestMillis() wraps past
0xFFFFFFFF rather than saturating, since the Throttle wrap tests rely on it.

Also fix eviction in pruneLastNodeInfoCache(): it picked the entry with the
smallest stored stamp, which is the wrong victim once some stamps sit on the
far side of the wrap. It now evicts the largest elapsed time.

* Add CI guard and docs rule against naive millis() comparisons

Fixing the existing sites does not stop the next one being added. The
millis-deadline-check job rejects millis() placed directly next to a comparison
operator, in either order, anywhere in src/. It lives in test_native.yml
alongside suite-count-check, which sets the precedent for a repo-hygiene guard
that CI enforces and bin/run-tests.sh does not.

The correct idioms all subtract before comparing, so none of them match the
pattern. Line comments are stripped first, so documentation is free to name the
broken form - as the guard's own comment and the coding conventions both do.

Writing the check before finishing the sweep turned out to be worth it: it
found roughly 14 sites that a by-hand audit of deadline variables had missed,
including two extra nagCycleCutoff compares, both boot-screen timeouts, and a
6 hour sensor save interval that was also overflowing a uint32_t multiply.

.github/millis-deadline-allowlist.txt covers the cases that are genuinely not
deadline tests. Both current entries are uptime thresholds - "has the device
been up N ms" - with no stored deadline and no event to measure from: a 30s
button holdoff against phantom shutdown from floating pins, and a 10s window
for the OEM boot logo. Each re-crosses its threshold once per wrap, which is
harmless for boot-holdoff logic and not worth new state to avoid. Entries are
keyed on file plus exact source text, without line numbers, so an edit above an
entry does not silently invalidate it.

Locally the guard reports 19 matches before the sweep and 2 after, both
allowlisted.

The Throttle bullet in the coding conventions is rewritten from "prefer
Throttle for rate limiting" to "never compare against millis() directly", lists
all four helpers with when to use which, names the CI guard, and documents the
sentinel hazard with the rebootAtMsec = -1 case that would have become a reboot
loop. Mirrored into AGENTS.md; CLAUDE.md gets a pointer row.

* Trim rollover comments to what the code needs

The comments added with the millis() rollover fixes carried too much of the
investigation that produced them: how many sites were found, which document
recorded them, what the old code used to do. That belongs in the commit history,
not in the source, and some of it was already stale - Power::reboot() still
described the check it disarms as "a naive millis() > deadline" when that
comparison had been fixed in the same series.

What stays is the non-obvious part at each site: which sentinel value the
variable overloads and what it means there, since that differs between call
sites and is what a correct comparison gets wrong. 0 means "not scheduled" for
rebootAtMsec, "renew now" for ntp_renew, and "show indefinitely" in
isOverlayBannerShowing().

Exposition is kept where it earns its place: the Throttle helpers, the uptime
clock's note on why there is no 64-bit variant, and the tests. The Throttle
docs lose only the site count and the "longest interval in the firmware"
statistic, both of which would age badly; the range trade-off between the two
forms is what a caller actually needs.

Comments only - no code changed, verified by diff.

* possible fixes

* Address review feedback on the rollover fixes

- BME680Sensor: checkpoint lastStateSaveMs after a successful write instead of
  at the interval test. The first save (IAQ accuracy >= 2) left it at 0, timing
  the next save from boot, and stamping before the write deferred the retry a
  full period when the write failed. Reads Time::getMillis(), the same clock
  Throttle compares against.

- Throttle: add deadlinePassedAt(now, deadline) for loops that snapshot the
  clock once and test many deadlines; deadlinePassed() now delegates to it.
  NextHopRouter::doRetransmissions() uses it, replacing the inline half-range
  compare adopted from #10227 (nightjoker7) - same arithmetic, credited at the
  call site - and takes its snapshot from Time::getMillis() so setNextTx()
  deadlines and the due test cannot diverge under an injected test clock.

- test_native.yml: set -euo pipefail in the millis-deadline guard, matching the
  sibling suite-count job. Without -e a partially failed scan could report "no
  violations" from truncated output.

- test_packet_signing: build the not-due deadline from Time::getMillis() rather
  than millis(), so the test and the router read one clock.

- test_throttle: cover deadlinePassedAt(), and correct a wrapped-value comment
  (0xFFFFFF00 + 400 is 0x00000090, not 0x00000094).

Two review comments were declined: the AirTime mutex (every airTime-> caller
runs in the single cooperative loop, WebServerThread included) and the
MotionSensor 0-sentinel countdown (the calibration frame is only installed
while a window is open).

clod helped out here

* Correct the described failure window of a naive millis() compare

The comments and agent docs said a bare `millis() > deadline` "breaks for ~24
days after the wrap". That figure belongs to the fix, not the bug: it is the
half-range limit of deadlinePassed(), which reads deadlines more than 2^31 ms
ahead as already passed, and the range over which a UINT32_MAX sentinel reads
as passed.

The naive compare's actual failure is an inversion lasting only while the
deadline sits on the far side of the wrap, so it is bounded by the interval:
the action fires immediately and loses its wait, or blocks for about the wait
it should have performed - days for the nRF52 flash-corruption backoff,
one skipped cycle for a seconds-long retransmit timer.

Comments and docs only; the ~24.8 day statements that correctly describe
deadlinePassed()'s own range are left as they were.

clod helped out here

* Restore a monotonic uptime clock and consolidate the wrap counters

Time::getMillisMonotonic() is the getMillis64() shape - a 32-bit wrap
counter carried across reads - promoted to the shared timebase, with
Time::getUptimeSecs() as the derived whole-seconds view. This deliberately
reverses the earlier removal of getMillis64(), and the distinction matters:
removal was right for a lazily-read accumulator with one rare caller, where
a 49.7-day gap between reads silently swallowed a wrap. Here every read is
the poll and AirTime::runOnce() guarantees one per second; the missed-wrap
contract is pinned by a test rather than left as a footnote.

Three private wrap counters collapse into it:

- AirTime::syncNow() takes its seconds from Time::getUptimeSecs() and drops
  its lastSyncMsec checkpoint; window rotation is unchanged.
- DeviceTelemetryModule loses refreshUptime()/uptimeWrapCount/uptimeLastMs;
  uptime_seconds comes from Time::getUptimeSecs(), which also removes the
  0.296s-per-wrap truncation of (0xFFFFFFFF / 1000) * wraps. Its two
  interval checks move to Throttle::hasElapsed().
- HostMetricsModule's copies of those members were never read (its uptime
  comes from /proc/uptime) - deleted.

Not ISR-safe (unguarded mutable carry): ISRs keep using getMillis(), which
stays a pure read. Audited: no interrupt-context file reads getTime(),
getValidTime(), or the new accessors.

test/native-suite-count 44 -> 45: the bump for test_uptime_clock was lost
in a branch history rewrite, leaving every later value off by one -
run-tests.sh reports AMBER and CI's suite-count-check fails on the current
push until this correction.

* Anchor the wall clock in monotonic milliseconds

getTime() computed elapsed-since-time-set as a 32-bit millis() delta, so a
node that took time once and stayed up past 49.7 days reported a wall clock
one full cycle in the past - and last_heard, rx_time, message and position
stamps all inherited it. The anchor is now the 64-bit monotonic count
(timeStartMsec -> timeStartMs64) and the elapsed term is computed in 64-bit,
so the wall clock is exact at any uptime.

All six anchor writers follow: the five hardware-RTC read branches and
perhapsSetRTC(), which keeps a truncated 32-bit copy of the same instant for
its Throttle-checked rate-limit stamps. The test seams anchor the same way.

Two native regression tests drive getTime() across the wrap through the
Time seam - one anchored before the wrap and read after it, one anchored
after a counted wrap - with the test epoch derived from BUILD_EPOCH so the
plausibility window cannot rot as the build date advances.

* Stamp the rx_time placeholder in monotonic uptime seconds

computeRxTimeStamp() stamped Time::getMillis() when the clock was untrusted,
and reconcilePendingRxTimes() back-calculated with a 32-bit millis() delta -
correct within one wrap, but a placeholder older than 49.7 days aliased to a
small elapsed value and reconciled to a plausible-but-wrong recent epoch:
the exact failure has_rx_time exists to prevent, reachable by an ordinary
unattended router whose phone connects two months in.

The placeholder is now Time::getUptimeSecs(). Both stamps come off the
monotonic counter, so the elapsed term is exact at any age and the aliasing
window is gone outright rather than widened. If elapsed somehow exceeds the
epoch itself, the packet stays un-dated (absent, never wrong) instead of
clamping to a pre-1970 value. Defence in depth: a placeholder that leaks
needs ~50 years of uptime to cross MIN_PLAUSIBLE_EPOCH, where milliseconds
took 18.3 days.

The stream-API reconciliation tests keep their scenarios with the placeholder
unit switched, and ScopedTimeFixture resets the monotonic carry so uptime
seconds are deterministic per case.

* Date nodes heard before the clock arrives, without polluting last_heard

A node first heard while the wall clock was untrusted got no last_heard at
all, and nothing backfilled it once time arrived - the phone showed "Last
heard: unknown" for a node it had just announced. The arrival instant now
waits in a RAM-only sidecar (NodeNum -> uptime seconds, 32 slots,
reuse-oldest - the RouteHealth shape) and is converted to a real epoch on
the clock-becoming-trusted transition, beside the existing rx_time
reconciliation. last_heard itself never holds anything but a real epoch or
0: it persists to flash and the warm tier, where an uptime-relative value
would be meaningless after reboot.

The sidecar's write sites are updateFrom()'s no-trusted-clock path (the
rx_time placeholder already carries the arrival instant, so this is a store,
not a second clock read) and addFromContact's anti-eviction stamps, which
previously wrote a bare getTime() - boot-relative seconds on a clockless
node, the exact value lastHeardIsWallClock() exists to catch. Eviction
ranking honours the stamps: heard-this-boot outranks every stored epoch,
ordered among themselves, so a stamped contact is not the first victim.

PhoneAPI re-reads last_heard at nodeinfo send time: a record prefetched
before the clock became trusted can carry 0 while the store has since been
backfilled, and re-reading at the pop makes handshake ordering (time-set vs
node-list download) irrelevant. Backfill never moves last_heard backwards
and skips the pathological elapsed-exceeds-epoch case. A node evicted to
the warm tier before time arrives is still absorbed with last_heard 0 -
same as before, bounded to the untrusted window.

* Update the agent docs for the monotonic timebase

The conventions bullet asserted there is deliberately no 64-bit millis; the
monotonic uptime clock restored for timestamps changes that contract. State
the split explicitly: Throttle for deadlines and intervals (no carry state),
Time::getMillisMonotonic()/getUptimeSecs() for timestamps, polled by
construction and not ISR-safe.

* Publish the monotonic wrap carry from a single writer

getMillisMonotonic() was a read-modify-write on two unguarded statics, and it
is reached off the main loop: the nRF52 Bluefruit task via
onFromRadioAuthorize() -> PhoneAPI::getFromRadio -> getValidTime(), and the
portduino civetweb workers via the same path. Two readers interleaving inside
the wrap window could each increment the carry, putting every uptime and
wall-clock reading 2^32 ms ahead for the rest of the boot - a permanent ~49.7
day jump in rx_time, last_heard and ClientNotification.time.

Readers no longer write. serviceMonotonic() publishes a snapshot behind a
seqlock and is the only writer; a reader adds its own unsigned elapsed time to
that snapshot, which is exact across the wrap, so it never inspects the
boundary and cannot miscount it. The main loop publishes every iteration, so
the once-per-49.7-days obligation now has the whole window of margin instead of
resting on an instruction-wide race.

AirTime was the guaranteed poller and is now a pure reader, so the two airtime
wrap tests step the clock the way loop() does. The test clock itself is atomic
so a suite can drive it from one thread while others read.

* Re-arm the GPS ephemeris hold when none is in force

The rollover sweep guarded the hold re-arm with `fixHoldEnds != 0 &&`, which
reads like the sentinel rule but inverts this site. The comparison it replaced,
`(fixHoldEnds + GPS_THREAD_INTERVAL) < millis()`, was always true when nothing
was armed - that was the point, since 0 means "not holding" and so is a reason
to arm. With the guard, a publish that cleared the hold without sleeping (the
`shouldPublish && !tooLong && !holdExpired` path, which does not call down())
left hasValidLocation set and prev_fixQual non-zero, so no disjunct held:
nothing re-armed, nothing published, and the receiver stayed powered at the
200ms poll until searchedTooLong() fired.

State the question positively instead. fixHoldInForce() is the only place the
sentinel is interpreted, and both of runOnce()'s decisions derive from it - the
asymmetry is now visible rather than implied, since arming does not require a
prior hold but expiring does. Its `!= 0` test is not redundant with the
arithmetic: deadlinePassed() is an unsigned half-range test, so past 2^31 ms of
uptime the sentinel reads as a deadline ~24.9 days in the future.

Kept beside its caller rather than in a header; the native test build compiles
GPS.cpp, so the suite declares the prototypes.

Also converts the getACK() wait to isWithinTimespanMs(start, interval): it has
both the start instant and the interval in hand, which gives the full 49.7-day
range instead of 24.8 days ahead, and takes its anchor from Time::getMillis()
so the wait is injectable.

* Date the NodeInfo reply window in uptime seconds

The 12h reply-suppression stamp regressed from wrap-immune 64-bit seconds to
raw 32-bit milliseconds, and pruneLastNodeInfoCache() evicts only by node count
and DB membership - never by age. A stable mesh under the node cap therefore
keeps every stamp indefinitely, and once uptime passes 49.7 days an old one
aliases back into the window: `now - stamp` computes as ~0 and a legitimate
NodeInfo request goes unanswered for up to 12h. It self-heals and repeats once
per wrap cycle.

Store Time::getUptimeSecs() instead, which does not wrap for 136 years, and
drop the millisecond conversion the previous shape needed. Entries past the
window are now evicted too: they can only ever decide "don't suppress".

N8-N11 cover the window from both sides, and N10 pins the regression - it needs
a full 2^32 ms of uptime to elapse, not merely a crossing of the boundary,
because that is when a millisecond stamp reads as "answered this instant".

tearDown() now restores the injected clock and C14's region and TX bucket. A
failing assertion aborts the test body, so restoring at the end of it leaked
that state into every later case.

* Update the agent docs for the single-writer clock and sentinel direction

Two rules the preceding three commits changed.

The monotonic clock is no longer maintained by whoever happens to read it:
serviceMonotonic() is the only writer, readers are pure, and calling it from
anywhere but the main loop reintroduces the double-count.

The sentinel guidance gained the half it was missing. It named UINT32_MAX as a
sentinel while prescribing an idiom that only covers 0, and it assumed the
sentinel always means "suppress" - at the GPS fix-hold site it meant "fire",
which is how that regression passed review looking like the rule.

* Name the fix-hold expiry predicate and arm it from the injected clock

holdJustExpired() gives the second reading of the fixHoldEnds sentinel a
name beside the first, so both are pinned by test/test_gps_fix_hold/ and
neither can be respelled at the call site. The old inline form could not
be tested: written as a literal, its guard folds at compile time and the
assertion asserts nothing.

The arm site used bare millis() while the evaluation reads the Throttle
clock; same value in production, but it kept that write out of reach of
Time::setTestMillis(). Remap a deadline that lands on 0, which would
otherwise read as no hold at all.

* Share the extend formula between the clock's reader and writer

getMillisMonotonic() and serviceMonotonic() carried byte-identical wrap
arithmetic. A one-sided edit to either would drift the published carry
from what readers report, so keep one copy.

* Trim the NodeInfo dedup comment to the house limit

* todo note for potential future imrpovments

* fix some simple deadlines

* Trim the hold-expiry test comment to the house limit

* Fix non-blocking uptime publication and pre-clock recency edges (#29)

* fix(time): avoid blocking monotonic readers

* test(time): make paused-publisher check deterministic

* fix(time): address review portability gaps

* Init the eviction sentinel to the newest possible recency

EvictionRecency{} is {0, false}, which evictionRecencyOlder() ranks as older than
every candidate: without the oldestIndex/oldestBoringIndex guards nothing would
ever be selected and a full node DB would stop evicting entirely.

Init to the genuine maximum instead, so the sentinel is correct on its own. The
index guards stay: two independent reasons the scan is right beats one.

* Keep the deadline-guard check name branch protection matches

The guard was widened to cover Time::getMillis() and unqualified getMillis(),
and renamed to suit. Upstream branch protection matches required checks by name,
so a rename means the old name never reports and merges block on a check that
will never arrive.

Widen the guard, keep the name; the descriptive text carries the broader scope.

* Correct native-suite-count to 47 after the develop merge

Upstream #11293 added test_nmea_wpl and took develop's count to 43; this branch
had independently reached 46. Merging develop resolved the counter textually,
keeping 46, while the directory set became the union of both sides at 47.

The suite-count CI gate fails on the mismatch, and it gates the native test jobs,
so the tests themselves were being skipped.

* test(uptime): make the wrap fall where the comment says it does

The concurrent-reader case started at 0xFFFFF000, leaving 0x1000 to the wrap, so
the 0x800 advance annotated "cross the wrap" fell short and the wrap actually
happened during the following 60s advance.

Start at 0xFFFFF800 instead, so the first advance lands exactly on the wrap while
the readers are running and the second is the ordinary time after it - the shape
both comments already described. Total elapsed is unchanged, so the closing
assertion still holds.

* Respond to human comments

* Did I ever tell you about the time I went to Shelbyville? I wore an onion on my belt, which was the style at the time.

* Convert the I2S nag deadline develop dragged in

The HAS_I2S_SPEAKER_NRF52 RTTTL block arrived from develop with a raw
nagCycleCutoff >= millis(), which the deadline guard rejects. Use the same
Throttle::deadlinePassed() form as the two sibling paths in this function.

* Arm the LittleFS format guard with a flag, not a zero timestamp

preFSBegin() runs in the first millisecond of boot, so millis() can legitimately
return 0 there. Both readers of last_format_ms treated 0 as "nothing formatted
this boot", which would skip the repeat-corruption escalation and let a dead
flash reformat-loop instead of reporting FLASH_CORRUPTION_UNRECOVERABLE.

* Note the single-thread contract on AirTime

* Note the AirTime locking TODO, and tighten the thread note

The two constant getters are not constrained, and getSilentMinutes() reads the
buckets without rotating them, so "the accessors mutate" was not accurate.

* trunk: ignore trufflehog false positives on millis-wrap test constants

test_throttle and test_uptime_clock pin dense clusters of hex boundary
constants (0xFFFFFF00u and neighbors) to exercise 32-bit millis()
rollover. trufflehog's Lob detector stitches nearby hex literals into
one candidate string, and the result happens to match a Lob API key
shape - not a secret, just test fixtures.

Same pattern already used for the gitleaks/nodedb-fixture false
positive in this file.

---------

Co-authored-by: nightjoker7 <mattdeering7@gmail.com>
Co-authored-by: Clive Blackledge <clive@ansible.org>
Co-authored-by: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-12 16:49:17 -05:00
Jonathan BennettandClaude Fable 5 5baad2e2a8 logging: compile out LOG_TRACE by default, demote chatty DEBUG lines, drop redundant logs (#11391)
* logging: gate LOG_TRACE behind MESHTASTIC_TRACE_LOGGING, drop redundant reclock logs

LOG_TRACE now compiles out by default so trace-level diagnostics cost no
flash; enable with -DMESHTASTIC_TRACE_LOGGING. Portduino keeps it on for
the traceFilename packet-trace feature.

Remove the 66 caller-side I2C reclock/restore log lines in the telemetry
sensors: ReClockI2C::setClock/restoreClock already log both frequencies
internally (now at trace level, since they fire every sensor read).

Also unify near-duplicate literals (colon/case/punctuation variants) so
linker string dedup applies, and drop an information-free bare 'done'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: demote chatty per-packet/per-poll DEBUG lines to trace level

With LOG_TRACE compiled out by default, per-iteration chatter (packet
bookkeeping, sensor poll values, e-ink refresh reasons, GPS pin states,
UI runState traces) now costs no flash on device builds while remaining
one -DMESHTASTIC_TRACE_LOGGING away. 108 lines demoted, 4 information-
free lines removed; failure paths, drop reasons, and one-time init logs
all stay at debug level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: address CodeRabbit review on trace-gate PR

- GPS: pass serial-derived buffers as %s args, never as format strings
  (untrusted bytes could contain % directives)
- 0x%08x for packet id / NodeNum per convention (Router, CannedMessage,
  NeighborInfo); unsigned casts for size_t args; %u for uint32_t delta
- EInk: async full-refresh begin/complete back to DEBUG (rare state
  transitions); per-frame SKIPPED lines stay trace

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: gate trace on the flag's value, not its presence

-DMESHTASTIC_TRACE_LOGGING=0 previously *enabled* trace logging because
the gate tested definedness. The flag now defaults per-platform
(portduino 1, else 0) and both backends test the value, so =0 disables,
=1 or a bare -D enables. Also cast tx_after-millis() to uint32_t for %u
(millis() is unsigned long on native).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: clang-format rewrap after specifier widening

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* Even fewer bytes!

* logging: keep compile-gated debug lines at debug level; fix native-suite-count

Lines already inside default-off #ifdef blocks (GPS_DEBUG,
DEBUG_LOOP_TIMING) cost no flash and should stay visible at debug level
when their gate is enabled, rather than also requiring
MESHTASTIC_TRACE_LOGGING.

test/native-suite-count lags the two test_event_channel_* suites added
by #11045 (develop's Native Suite Count check has the same mismatch);
bump 46 -> 47.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* gps: route GPS_DEBUG diagnostics through a LOG_DEBUG_GPS() macro (#11414)

Replaces 27 log-only #ifdef GPS_DEBUG blocks across GPS.cpp,
PositionModule, MeshService, and GPSStatus.h with a single-line
LOG_DEBUG_GPS() call (src/gps/GPSLog.h, modeled on LOG_MIGRATION:
value-gated, ((void)0) when off). Blocks containing declarations,
control flow, hexDump, or nested conditionals keep an explicit
'#if GPS_DEBUG' guard. RTC.cpp's per-reading raw time dumps and
per-candidate rejection chatter fold under the same gate; quality
transitions and boot-time seeding stay at debug.

Also fixes the '// define GPS_DEBUG' missing-# typo in two variant
headers and updates all seven commented examples to the value form
('#define GPS_DEBUG 1') required by the value-based gate.


Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

Co-authored-by: Claude <noreply@anthropic.com>

* gps: declare RTC gmtime result as pointer to const (cppcheck)

With the setTime debug dump gated behind GPS_DEBUG, all remaining uses
of t are reads; cppcheck (constVariablePointer) now flags it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-12 00:05:51 +00:00
Quency-D fb17f6ddbe Add LoRa FEM LNA toggle menu support (#11341) 2026-08-11 12:05:30 +00:00
Jonathan BennettandClaude b8dee13d0b fix(BaseUI): never render banner font tags ([S]/[M]/[L]) as literal text (#11392)
The nRF52 BLE pairing banner sends "Bluetooth\nPIN\n[M]<pin>" where [M] is
a medium-font marker for the PIN line. The renderer only honored font tags
for lines covered by the parsed-line cache, so any path that draws a banner
line from the raw message - a stored-without-reparse banner, or a draw
racing the parse from the BLE task - showed a literal "[M]" ahead of the
pairing PIN.

Extract the per-line text/font decision into resolveBannerLine(): parsed
(tag-stripped) line when the cache covers it, otherwise strip a leading
font tag from the raw line on the fly and honor the font it names. Picker
content (e.g. node names) is deliberately exempt - it can be user data and
must never be tag-interpreted.

Also measure line widths on the text actually rendered (they were measured
on the raw, un-stripped string, skewing box width and centering), and check
p[1] for NUL before reading p[2] in the tag probe, which could read one
byte past the end of a line that ends in '['.

Add a native test suite (test_banner_font_tags) covering tag parsing, the
BLE pairing message, the cache-miss fallback, and the picker exemption.


Claude-Session: https://claude.ai/code/session_01CFMdkE6d8fY28Ax3hFVDMU

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 00:06:00 +00:00
Jonathan BennettandClaude 512154ff97 feat(BaseUI): show 'GPS Time Only' when GNSS has time but no position fix (#11361)
The position frame's drawGpsCoordinates() only distinguished 'No GPS
present' / 'No GPS Lock' / coordinates, so a GNSS that had decoded
valid time but no fix displayed identically to a cold chip.

- GPSStatus: add per-acquisition hasTime flag (5th ctor param,
  accessor, matches() term, updateStatus() copy)
- GPS::runOnce(): publish immediately on the gotTime rising edge so
  the flag reaches observers on the time-only path, which previously
  never published; done directly rather than via the end-of-loop
  block so fixHoldEnds is preserved and hold/power behavior is
  unchanged. Safe without a location: PositionModule ignores invalid
  positions. gotTime is already cleared on each GPS_ACTIVE entry, so
  the state is not sticky across acquisitions.
- UIRenderer::drawGpsCoordinates(): the 'No GPS Lock' line becomes
  'GPS Time Only' when time is valid.

The drawGps() header renderer is intentionally untouched (its
branches need separate de-clobbering work).


Claude-Session: https://claude.ai/code/session_01CcrasD4QsatunDreANDgCx

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 02:39:40 +00:00
2e958821f9 fix(BaseUI): suppress new-message pop-up while a menu is on screen (#11373)
Menus share the single global banner slot with notification pop-ups
(Screen::showOverlayBanner overwrites message, options, and callback
unconditionally), so a "New Message" banner arriving mid-menu destroyed
the open menu and stole its input.

Add NotificationRenderer::isMenuShowing() — true when the active overlay
is interactive (a menu with options, or any picker/keyboard/pairing-PIN
type) rather than a plain text banner — and skip the new-message banner
in handleNewMessage() while such an overlay is up. Screen wake and
hasUnreadMessage behavior are unchanged, and a new message can still
replace an earlier plain banner.


Claude-Session: https://claude.ai/code/session_01PSAouemtAihV5P87AgCadu

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason P <applewiz@mac.com>
2026-08-10 02:30:37 +00:00
cd716fe384 logging: audit log strings for terseness, reclaiming ~6.8 KB of string data (#11374)
* logging: strip redundant punctuation, level prefixes, and 'successfully' from log strings

The logger already appends a newline and prints the level tag, so
trailing '.', '!', '...', literal \n, and 'Error:'/'Warning:' prefixes
inside format strings are wasted flash bytes. Same for 'successfully'
(the affirmative form already implies it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: tighten verbose log strings in modules, radio, platform, and system code

Rewrite wordy log messages to terser equivalents - drop filler words
(articles, 'attempting', 'due to', 'please'), use 'Can't X'/'X failed'
phrasing, and abbreviate where the codebase already does (config, init,
msg, BT). Format specifiers and argument lists are unchanged; distinctive
greppable tokens are preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: tighten verbose log strings in telemetry sensors and GPS

Same terseness pass: drop filler, 'Can't X'/'X failed' phrasing, common
abbreviations (temp, msg). Specifiers, arguments, and sensor-name
prefixes unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: tighten verbose log strings in mesh core

Same terseness pass over NodeDB, Router, MeshService, PhoneAPI,
RadioInterface, NextHopRouter, and PacketHistory: 'X failed'/'Can't X'
phrasing, imperative verbs, dropped filler. Specifiers and arguments
unchanged; duplicate literals kept identical to preserve linker string
dedup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: 'Unable to/Could not/Cannot' -> "Can't" in log strings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: clang-format rewrap after string shortening

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* logging: restore boot-logo trailing newline and progress-dot strings

The terseness pass over-trimmed: the Meshtastic ASCII boot logo kept its
blank line via a trailing \n, and three bare "." progress ticks were
reduced to empty strings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBiZc9sfPrH1MZ2L3Fxgt1

* Update src/mesh/wifi/WiFiAPClient.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* logging: address review feedback on the terseness audit

- Node/packet IDs use the repo's 0x%08x convention in NextHopRouter,
  NodeDB, AdminModule and CannedMessageModule. The sibling log in each
  if/else pair is converted too, so a pair isn't split across two formats.
  next_hop stays 0x%x - it's the last-byte relay hint, not a NodeNum.
- RTC: the read-path and set-path "not found" warnings were byte-identical,
  so the linker deduped them and the log couldn't say which one fired.
  Split into "RTC read:" / "RTC set:". (The four sites live in mutually
  exclusive #ifdef branches, so the RTC family was never ambiguous.)
- SCD4X getAmbientPressure()/setAmbientPressure() logged "altitude", and
  SCD30 getASC() logged "Can't send command" for a read. Both now name the
  operation they actually perform.
- LOG_ERROR already carries the level: ". Error: %u" -> ", rc=%u" (matching
  the existing rc=%d house style) and "Error executing X()" -> "X() failed".
- Typos and wording: "OTA partiton.  (Reason" -> "OTA partition (reason",
  "CST3530 not response ~" -> "CST3530 no response", "Packet received with
  to: of 0" -> "to=0", HostMetrics "Error decoding" -> "Can't decode", and
  the dangling ": " on the NextHopRouter retransmission line.

Printf specifier sequences are byte-identical on all 37 touched lines apart
from the 6 deliberate %x/%u -> %08x node-ID widenings, all on uint32_t args.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-10 01:02:19 +00:00
Tom de6b23190a Test suite rebuild (#11322)
* docs(nodedb): make the native node cap unambiguous

The native node cap was stated in four places that disagreed, and the disagreement
already caused a wrong diagnosis: a saturated 200-node database looked arithmetically
impossible because the cap had been read as 248, computed from a header that does not
apply on this platform. The real value is 198.

On portduino MAX_NUM_NODES is not a compile-time constant at all - the variant defines
it as `portduino_config.MaxNodes`, resolved at runtime, default 200 and settable per
host with `General: MaxNodes`. variant.h is reached before mesh-pb-constants.h, so that
header's ARCH_PORTDUINO branch never fires and its plausible-looking 250 is dead code.

- #error-guard the dead branch rather than leave a wrong number where people grep. The
  guard found a real defect: seven translation units reach mesh-pb-constants.h without
  configuration.h (SerialConsole.cpp, StreamAPI.cpp, PacketAPI.cpp, ServerAPI.cpp,
  PiWebServer.cpp, ServiceEnvelope.cpp, MeshtasticOTA.cpp, and test/TestUtil.cpp), so
  each was compiling with a different MAX_NUM_NODES - and therefore a different
  PACKETHISTORY_MAX - than the rest of the build. Each now includes configuration.h
  first. It cannot be included from mesh-pb-constants.h itself: that reaches
  SerialConsole.h through DebugConfiguration.h and closes a cycle.
- Name the bare 250 in getMaxNodesAllocatedSize() NODEDB_MIGRATION_LOAD_CEILING. It is a
  decode allowance for files written by larger-cap firmware, not a cap, and it read like
  one.
- Fix docs/node_info_stores.md, which named the wrong source and a "10-250" range that
  is wrong for native, and the copilot-instructions tunables line that said "portduino
  250".

* test(harness): give each suite its own scratch HOME and report leftovers

Native suites shared one directory. Every suite that constructs a NodeDB loads and
saves ~/.portduino/default/prefs/ - nodes.proto, config.proto, channels.proto,
module.proto, device.proto, warm.dat, transmit_history.dat - and nothing cleared it,
so state leaked suite -> suite within a run and run -> every run after it. A test run
could also rewrite a real meshtasticd node database on the same machine.

Per-run isolation does not fix this: the leak is generated inside a single run, so the
boundary has to be per suite.

bin/pio-test-isolate.sh runs each suite in its own scratch $HOME, registered as
test_testing_command for env:native and env:coverage so a bare `pio test` and CI get the
same boundary, not just bin/run-tests.sh. It runs the binary unchanged and exits with its
exit code, so PlatformIO's pass/fail is untouched. Overriding HOME here rather than
around `pio` also sidesteps the blocker that a bare HOME= breaks pio's own
~/.platformio/penv/bin/pio lookup.

Leftovers are reported as a second axis, PASS/FAIL x CLEAN/DIRTY, because an unintended
write has no matching assertion by definition - nobody writes TEST_ASSERT for a save they
do not know is happening. The harness asserts it from outside, so it applies to every
suite without the author opting in.

- Only the *set of changed paths* is asserted, never contents. Hashes answer the boolean
  "did this change?" and nothing more; content baselines over protobuf bytes would churn
  on every NodeInfoLite field added, which is how snapshot suites become noise.
- Deliberate writes are declared in test/state-manifest.tsv - one central file, suite /
  flags / mandatory reason. run-tests.sh prints the opt-out count on every run.
- Granularity follows the state flag, so the two ship together: per-test by default
  (TestUtil redefines RUN_TEST to checkpoint after each test, naming the exact test that
  dirtied things), suite boundary for state=per-suite, where carrying state across test
  cases is the declared behaviour.
- A declared write that does NOT happen is reported as MISSING, not folded into DIRTY. It
  catches silently broken persistence; a warning for now, since some are conditional.
- Graded AMBER, not RED. With isolation in place DIRTY means "undeclared", not
  "dangerous", and a check that lands red on day one gets switched off.

Guard the guard, both halves: state_assert_empty() refuses to run a suite against a
sandbox that is not empty (otherwise the after-diff measures against the wrong baseline
and reports CLEAN while meaning nothing), and bin/test-state-check.sh drives the real
wrapper with fixtures asserting CLEAN / CLEAN / DIRTY / MISSING plus both directions of
the empty assertion. A checker that silently matches everything would otherwise pass
forever.

--write-manifest proposes entries for a human to paste and justify; it never applies
them, and neither does CI.

* test(harness): stop reporting Unity's exit code as a signal

A native suite ends in exit(UNITY_END()), and UNITY_END() returns the failure count.
PlatformIO's native runner reads that non-zero exit code as a POSIX signal number, so
four failures print "Program received signal SIGILL", five print "SIGTRAP", and the suite
is classified [ERRORED] rather than [FAILED].

There is no crash. The signal name tracks the failure count and nothing else - it moved
SIGILL -> SIGTRAP when a diagnostic probe added a fifth failure - and it cost hours of
hunting a memory bug that did not exist, on an env (native) that carries no sanitizer at
all. It also explains the phantom extra test case in the totals: the runner adds a
synthetic entry for the signal it thinks it saw.

run-tests.sh now says so inline whenever a signal line appears, and the three
agent-facing docs say it too.

* test(admin): isolate NodeDB and globals per test

setUp() did `if (!nodeDB) nodeDB = new NodeDB();` and never deleted it, so 83 of the 85
tests shared one never-reset database and never restored config, owner, devicestate or
channelFile. The fixture that does restore them was opt-in and armed by exactly two
tests. The setUp comment claiming the rest "set their own config/region state and are
unaffected" was not true - the admin handlers under test write all four globals.

Route every test through the fixture instead: setUp saves the globals and installs a
fresh NodeDB, tearDown restores and deletes it. The two tests that armed it themselves no
longer need to.

All 85 pass, so nothing was silently relying on the shared state. It costs about 7% of
the suite's runtime (a NodeDB construction is a loadFromDisk plus, with a region set, key
generation) - worth paying to write the phase 3 tests against a clean fixture rather than
83 tests' residue.

Also cap the per-test attribution in the run summary at five entries; the full list stays
in the suite's sandbox.

* test(fs): cover the bounded file-manifest walk

getFiles() runs on every phone sync via STATE_SEND_FILEMANIFEST, and nothing asserted any
of its bounding behaviour. It does execute unasserted from test_stream_api's handshakes,
but the cap, the depth limit, the wasLimited paths, overlong-path rejection and capacity
release were all unguarded.

Eight tests, all describing what the code does today: today's code is already correct
here, since #10778 landed the by-reference collectFiles(), the 64-entry cap, the strlcpy
bounds and the swap-idiom release. They pass on arrival, which is the point - this is the
baseline a later change has to leave alone.

Two things they do not cover, and cannot:

- Moving reserve() outside the __cpp_exceptions guard. Exceptions are on natively, so the
  #else branch is not compiled. The suite's job there is to prove that change alters
  nothing observable.
- The file.name() null guard. No in-tree backend returns null; the guard is defensive.

The manifest-release test pins the swap idiom rather than calling
PhoneAPI's releaseFilesManifest(), which is file-local. It asserts capacity() == 0, not
just size() == 0 - a size-only check passes on clear(), which is the bug #7924 shipped.

Suite count 43 -> 44, recounted against the directories rather than copied.

* test(admin): assert node-DB metadata saves skip the radio reload

set_favorite_node, set_ignored_node and toggle_muted_node each persist a NodeInfoLite bit
and nothing else. MeshService::reloadConfig() gates its region re-derivation and
configChanged notification on saveWhat & (SEGMENT_CONFIG | SEGMENT_CHANNELS), so a
SEGMENT_NODEDATABASE-only save already skips the live radio reconfigure.

Pure characterization - all three pass on develop. Worth pinning because that reconfigure
is the path implicated in the WisMesh Tag favourite-node crash, and develop asserts
nothing about it: widening the saveWhat mask or reordering the check would currently go
unnoticed.

Ported from the config-save series along with ConfigChangedCounter (an Observer<void *>
counting configChanged notifications, the only externally visible signal that the reload
branch was taken) and TEST_NODE_NUM. They join the existing suite, so no suite-count
change.

* refactor(menu): extract the mute toggle into a named function

The node menu's mute action was inline in a banner-callback lambda, and that lambda only
ever runs via screen->showOverlayBanner() - which is why nothing in MenuHandler.cpp was
reachable from a test. Lift the `selected == Mute` branch into
menuHandler::toggleNodeMuted(uint32_t) and call it from the lambda.

Behaviour-neutral by construction: same statements, same order, same bare saveToDisk().
The null check moves into the function, so the call site no longer needs its own lookup.
Verified by the native build and suite; the byte-identical-image check on a
headroom-constrained nRF52 board was not run locally - CI's firmware-size comment covers
it.

Three tests come with it, all describing today's behaviour:

- the bit flips both ways and no configChanged fires (develop never calls reloadConfig on
  this path);
- an unknown node is a no-op rather than a write;
- and the segment mask. Flipping one NodeInfoLite bit currently rewrites all five
  segments via bare saveToDisk(). That is asserted deliberately, with the comment naming
  it as characterization of a known defect: a pending fix narrows it to
  SEGMENT_NODEDATABASE, and when it lands this assertion is expected to change, which
  makes the improvement visible in the diff instead of silent.

saveToDisk() is not virtual, so the mask is observed through its effect - remove the five
prefs files, toggle, and see which reappear.

* docs(test): make every suite count a pointer to the canonical one

test/native-suite-count is the registered total and is machine-checked against test/test_*
on every full run and by the suite-count-check CI job. Every other statement of the count
is a copy that drifts: copilot-instructions said 12, AGENTS.md said 19, and the real
number is 44.

Replace both literals with a pointer to the file, say explicitly that no document should
state the count as a literal, and reframe the two suite listings as descriptions rather
than inventories - they carry per-suite information the count does not, so they stay, but
nothing should infer completeness from their length. Register the new FS suite in both.

* test(harness): randomise suite order, reproducibly

Landed last, deliberately. Randomising an order-dependent suite set does not find bugs so
much as convert a silent pass into intermittent red, and the first instinct is to revert
the randomisation rather than fix the coupling. Phases 1-2 removed the coupling; this
keeps it removed.

Both runners previously hid order dependence behind a fixed order that happened to differ
between them, and neither order was chosen: CI's area rules put admin first, PlatformIO's
local discovery is reverse alphabetical and put it last. CI was green by accident.

- bin/run-tests.sh --shuffle / --seed <n>. The seed defaults to HEAD's short SHA: one
  order per commit, so a red is replayable and attributable to the diff instead of flaky,
  while the project keeps exploring orders. Printed at the start and carried into the
  RESULT line, so a verdict is replayable from that line alone; the full order is printed
  on failure, because for an order-dependent failure the order is the diagnostic.
- The shuffle is a Fisher-Yates over a MINSTD generator rather than awk's rand(), whose
  sequence differs between gawk and mawk. A seed that does not reproduce the same order on
  another machine is not a seed.
- Shuffling needs one `pio test -f <suite>` invocation per suite - PlatformIO orders by
  its own os.walk() over test/ and filters only select - which measures at about 4.7s per
  suite of extra startup.
- CI shuffles its area order, seeded from GITHUB_SHA and printed with the command to
  replay it locally. Intra-area order stays PlatformIO's; controlling it there would mean
  per-suite invocations, which is a cost worth deciding separately.

Also records the 16 measured entries in test/state-manifest.tsv, each with its reason,
taken from a full run's --write-manifest output rather than guessed.

* test(default): cover the region-throttle interval overload

getConfiguredOrDefaultMsScaled(configured, default, nodes, TrafficType) is the overload
every telemetry and position module actually calls, and nothing referenced TrafficType
anywhere under test/. All four of its behaviours were unguarded: the no-region guard, the
throttle <= 1 short-circuit, the multiply, and the 64-bit overflow clamp.

The throttles are real, not hypothetical - EU_866 carries PROFILE_LITE, which sets both
positionThrottle and telemetryThrottle to 10, so a change here moves broadcast spacing in
that region by an order of magnitude.

Each test pins numOnlineNodes at the congestion threshold and uses ROUTER, which never
congestion-scales, so the coefficient is 1 and the throttle is the only variable. The
overflow case needs a base above INT32_MAX/10, hence three days rather than one.

* ci(test): keep pull-request suite order fixed, seed the rest

Shuffling the area order on every run - including pull_request - would turn a
contributor's PR red for an ordering they did not choose, which is how a randomisation
gets reverted instead of the coupling being fixed. That is the exact dynamic the ordering
work was sequenced last to avoid, and the previous commit walked straight into it.

- pull_request keeps the fixed declared area order.
- push and schedule shuffle, seeded from the commit SHA: deterministic per commit,
  printed, attributable, and never blocking someone else's PR.
- A suite_order_seed input on workflow_call and workflow_dispatch overrides both, so a
  specific failing order can be replayed anywhere, including on a PR.

The run log prints which mode it took, the resulting order, and the local command to
replay it.

* ci(test): satisfy CKV_GHA_7 and yamllint on the seed input

The seed is reachable through workflow_call, which callers can pass programmatically. The
workflow_dispatch copy tripped checkov's "workflow_dispatch inputs MUST be empty" rule,
and suppressing it was not worth it: replaying a specific order is a local operation, and
the run log already prints the exact bin/run-tests.sh command to do it.

* style(menu): apply the node-ID format convention

RadioInterface.cpp documents the rule: 0x%08x in logs, !%08x in user-facing
display. MenuHandler held every remaining exception - seven logs printing bare
%08X, and two display labels doing the same.

Repo-wide there are now no bare %08X node IDs left in log calls.

* ci(test): pass workflow inputs through env, not shell interpolation

suite_order_seed and github.event_name were spliced into the run: script as
${{ }} text, so a value carrying shell metacharacters would execute as code on
the runner rather than being read as data. semgrep (run-shell-injection) and
zizmor (template-injection) both flag it.

Both now arrive as environment variables and are read as "$VAR".

* refactor(test): share the seeded shuffle between the harness and CI

bin/run-tests.sh and test_native.yml each carried a byte-identical copy of the
MINSTD Fisher-Yates awk. The workflow prints "replay locally: ./bin/run-tests.sh
--shuffle --seed $seed" after a shuffled CI run, and that instruction is only
true while the two agree - drift would be announced by a replay quietly
reproducing a different order than the one that failed.

Extract shuffle_suites() to bin/lib/shuffle.sh and source it from both.
Permutations verified identical across seeds before and after the move.

* fix(test): correct the shared-state MISSING check and summary join

Three defects in the new harness:

state_classify() matched declarations two different ways - state_path_declared()
for "undeclared", a hand-rolled regex for "missing". Interpolating an entry into
an ERE also let a metacharacter in a manifest name match a file that is not the
declared one. Both directions now go through the one helper.

`paste -sd'; '` does not join with "; ": with -s, paste cycles through a
multi-character delimiter one character per join, so paths rendered as
"a;b c;d e". Replaced with an awk join.

test-state-check.sh ran on after a failed cd instead of stopping (SC2164).

./bin/test-state-check.sh: 6/6 fixtures pass, MISSING included.

* fix(portduino): bound General.MaxNodes

MaxNodes was validated only for <= 0. Any positive value, including a typo'd or
pasted-in one, propagates to MAX_NUM_NODES and scales both the node DB and the
nodes.proto decode ceiling - failing at boot with no obvious cause.

The ceiling is a sanity bound, not a capability limit; raise it if a host
genuinely needs more.

* docs(nodedb): reconcile the capacity tables

The property matrix omitted the ESP32-S3 100-node flash tier that the platform
table above it lists, and neither mentioned that the WASM build overrides
MaxNodes to 80 in wasm_config_apply().

* fix(nodedb): make mesh-pb-constants.h self-sufficient on portduino

The ARCH_PORTDUINO #error assumed it was unreachable in a normal build. It is
not: the vendored device-ui sources include this header without configuration.h,
which broke both native-tft docker builds.

Include configuration.h here instead, ahead of every compile-time default -
variant.h overrides MAX_RX_TOPHONE as well as MAX_NUM_NODES, so placing it lower
in the file just moves the divergence to a redefinition. The #error stays as a
backstop for the case where that include genuinely stops providing the cap.

Verified with the native env's own flags: a TU including only this header now
compiles, normal-order use of both macros compiles, and NodeDB.cpp compiles.

* fix(portduino): raise the MaxNodes ceiling to 16000

Marked artificial: nothing in the node DB fails at 16001. 16000 sits just under
the 16384 (128 x 128) population where HopScalingModule saturates its sampling
denominator and starts dropping nodes, so a host inside the bound still gets
meaningful hop recommendations.

* lint(trunk): advise on node IDs logged as bare %08x

RadioInterface.cpp documents the convention - 0x%08x in logs, !%08x in display -
but nothing enforced it, which is how the MenuHandler cluster drifted. 22 call
sites in PacketHistory, NodeInfoModule and PositionModule are still off it.

A trunk linter rather than a CI grep job, because trunk checks changed files:
new violations get flagged without a 22-site cleanup landing in an unrelated PR.
Modelled on the existing too-many-defined definition.

Scoped to values it can tell are IDs - an ID-shaped argument (->num, .from,
getNodeNum) or message text naming one. A 32-bit hex that is not an ID is out of
scope, so the CRC32 logs in ethOTA.cpp are correctly ignored.

Emits "note", trunk's only non-blocking level: "warning" and "info" both exit
non-zero and would gate CI, which is not what a log-format nit deserves. The
pre-existing sites are line-scoped in the allowlist, so a new bad call in those
same files is still caught.

* lint(trunk): stop exempting the known node-id-format sites

The seeded allowlist made the rule green by declaring the backlog acceptable.
Empty it instead, so the 22 pre-existing sites are reported and get cleaned up
by whoever next edits those files.

Costs nothing to do: the rule emits "note", so these are non-blocking either
way. The allowlist stays for its real purpose - a value the linter misreads as
an ID.

* style: log node and packet IDs as 0x%08x

Clears the 22 sites the node-id-format linter reports, so the rule starts from
zero rather than from a backlog nobody can see - trunk suppresses pre-existing
findings by default, so left alone these would not have surfaced on edit the way
an empty allowlist implies.

Format strings only; no argument or control flow changes. The !%08x
user-facing display forms are deliberately untouched - that is the other half of
the same convention.

* test(harness): build once up front, so suite timings mean something

run-tests.sh fused build and run in a single pio invocation, so whichever suite
PlatformIO's directory walk reached first absorbed the entire src compile and
reported it as its own duration. On a real run that made a 0.03s suite report
13m21s, and hid the build cost from every other number in the summary.

Do what .github/workflows/test_native.yml already does: one --without-testing
build pass, then run with --without-building. Measured on a full 44-suite run -
the build is now a single reported figure and 968 test cases execute in 1.9s,
with no suite above 0.084s.

Build output goes to its own log rather than $LOG: the outcome regexes match
"error:" and "[ERRORED]", so a compiler diagnostic sharing that file would read
as a test failure.

Both red paths now keep the log they quote from. $LOG and the build log are
mktemps the EXIT trap removes, so the three grepped lines were previously all
anyone ever saw - and the cause is usually further up than the first [FAILED].

* test(harness): keep the run log on every red path

bin/pio-test-isolate.sh already keeps a failing or DIRTY suite's sandbox and log
under .pio/test-state/<suite>/. What was missing is the cross-suite view: $LOG is
a mktemp the EXIT trap deletes, so run-tests.sh quoted three grepped lines from a
file that no longer existed by the time anyone looked.

Preserve it as .pio/build/<env>/test-failure.log from both red paths - including
"no success summary found", which said "see log" while preserving nothing, and
which is exactly the case where the build died before any suite ran and so left
no per-suite sandbox either.

Cleared at the start of every run, so a green run cannot leave a red one's log
lying around looking current.

* fix(test): report the real failure count on a shuffled red

A shuffled run is one `pio test` invocation per suite, all appending to the
same log, so the log carries one PlatformIO "N test cases:" summary per suite.
verdict_red() took `tail -1`, which reports whatever the LAST suite did: a
failure in suite 3 printed a "0 failed" summary from suite 44 directly under
"RED - failures detected:".

Sum the summaries instead. A single summary line - every unshuffled run - is
passed through verbatim, so the familiar output is byte-identical.

The patterns are passed to the awk helper as strings rather than /regex/
literals: awk evaluates a regex literal in argument position as `$0 ~ /re/`,
so the callee would receive 0 or 1 and silently sum garbage.

* fix(test): do not emit an empty suite name for an empty shuffle

`printf '%s\n' "$@"` with no arguments still writes one empty line, and both
callers read shuffle_suites through mapfile, so an empty suite list arrived as
a single suite named "". Return before the printf when there is nothing to
shuffle.

* test(harness): state and enforce the Linux host requirement

The native harness is a Linux tool: bash 4+ (mapfile), GNU coreutils and GNU
find (-printf, md5sum, -executable). Most of that predates this branch -
mapfile and both find predicates are already on develop - but none of it was
written down, so the requirement was there to be discovered rather than read.

Refuse to start on a non-Linux uname instead of degrading. On a BSD userland
this would not fail cleanly: it would mis-hash the sandbox and mis-read the
suite list, and still print a verdict. A state check that silently measures
the wrong thing is worse than one that declines to run.

Carrying a per-host fallback was the alternative, and it buys a second code
path that nothing in CI exercises. bin/test-native-docker.sh already exists
for macOS and non-Linux hosts, and the native-macos PlatformIO env is a build
target for meshtasticd, not a test host - the isolation wrapper is registered
for env:native and env:coverage only.

Documented in the script header, test/README.md, and both agent docs.

* fix(test): terminate every suite with exit(UNITY_END())

Two sites across two suites ended on a bare UNITY_END(). That ends the
reporting, not the suite: setup() returns, the runtime goes on calling loop(),
and the process runs forever. PlatformIO does not notice - it reports a suite
from its Unity output, not from process exit - so the suite passes, the run
goes green, and the binary stays resident. Thirteen of them had accumulated on
one dev box, the oldest 19 hours old.

The costs are quiet by construction:

- the per-suite sandbox is deleted underneath a live process, so its
  CLEAN/DIRTY verdict describes what the suite had written when the harness
  stopped looking, not what it left behind;
- .gcda coverage and LeakSanitizer's report both flush from atexit handlers,
  so a suite that never exits contributes no coverage and gets no leak check;
- each survivor pins its own deleted 94 MB binary, which du cannot see.

One of the two is the #else of an architecture guard, which is the easiest one
to get wrong - it looks like there is nothing to clean up. test_mqtt has a
correct exit(UNITY_END()) in its live branch, so a "does this file call exit()
anywhere" check passes the file whole.

test_serial had two more. develop's serial-config validation rework
restructured that suite - the architecture guard is gone and both remaining
branches now exit correctly - so this commit no longer has anything to change
there; bin/lint-unity-exit.sh, added later on this branch, is what keeps it
that way.

test/README.md gets a section on it, since the skeleton showing the right
shape had not stopped this happening.

* test(harness): detect and reap suites that outlive their run

A suite that never exits was invisible: PlatformIO reports a suite from its
Unity output, so the run stayed green while the binary kept running. Two
checks, because they fail differently.

Runtime, in bin/pio-test-isolate.sh: the sandbox $HOME is mktemp-unique per
suite, so any process still holding it is a survivor of that suite. Matching
on the environment rather than a remembered PID identifies one whatever its
parentage - a fork, a grandchild, a process already reparented to init - none
of which a $! comparison catches. Reaped before the after-fingerprint is
taken, so that fingerprint measures a tree nobody is still writing to, and so
a run cannot leave processes accumulating on the host. Recorded as a sixth
summary column and graded AMBER: the tests did pass, but the CLEAN verdict and
the coverage were measured under a false assumption.

Author-time, as bin/lint-unity-exit.sh, wired into trunk at "note" like
node-id-format: every UNITY_END() must be wrapped in exit(). The rule is per
occurrence, and that is the point - a file-level "calls exit() somewhere"
check passes test_serial and test_mqtt, which have a correct one in their live
branch and a bare one in the #else. Running it over the tree turned up
test_mqtt, which the file-level pass had missed.

It allows `int rc = UNITY_END(); ...; exit(rc)`, used by test_packet_signing
to restore globals between the summary and the exit. That is where the rule
gives ground: capturing and never exiting would leak and is not flagged.
Flagging a correct idiom would push someone to "fix" working code.

bin/test-state-check.sh gains a survivor fixture, asserting the wrapper both
reports and reaps - a detector that only reports leaves the host accumulating
processes, which is half the harm. 8/8.

* fix(lint): make the unity-exit scanner statement-aware

The rule judged one physical line at a time, which reports two kinds of correct
code as bare:

    /* a comment that happens to
       mention UNITY_END() */          <- interior lines were never stripped

    exit(
        UNITY_END());                  <- exit( and the macro never met

On a probe of both, two of three findings were wrong. This is a note-level rule
whose whole job is advice, and bin/lint-node-id-format.sh already says why that
matters: a false positive costs more than a miss. One that cries wolf gets
ignored, and the real finding goes with it.

Carry /* ... */ state across lines and accumulate logical statements before
testing, with a 12-line cap so one unclosed call cannot swallow the rest of the
file - the same structure lint-node-id-format.sh uses, so the two custom linters
in bin/ work alike rather than each having its own idea.

Verified both directions: the develop-era sources still produce the same four
findings, the fixed tree produces none, and a probe covering block-comment
interiors, wrapped exit(), line comments, return UNITY_END() and capture-then-
exit reports only the genuinely bare calls - including a complete block comment
followed by real bare code on the same line, which the state machine has to
keep live.

Reported by CodeRabbit on #11322.

* fix(lint): tokenise instead of pattern-matching, and self-test it

Second round of review findings on the same scanner, all confirmed by direct
test before changing anything. Six defects, one root cause: layered regexes
cannot tokenise C++.

False positives (correct code reported):
  - UNITY_END() inside a string literal read as code

False negatives (real leaks missed):
  - a string containing "/*" opened comment state and swallowed later lines
  - greedy .* removed everything between two block comments on one line,
    taking a bare call with it
  - myexit(UNITY_END()) matched the exit() exemption as a substring
  - x == UNITY_END() and total += UNITY_END() matched the assignment exemption

Replaced with a character-level scan carrying comment state, and token-bounded
exemptions: exit must be a whole identifier, and the capture form must be a
plain `=`. Raw string literals are still not modelled - there are none under
test/, and delimiter tracking for a case that does not occur would be untested
code guarding untested code, so it is documented rather than guessed at.

Also drops the `return UNITY_END()` exemption. It only terminates from main(),
there is no main() under test/, and from a helper it just returns a count.

bin/test-lint-unity-exit.sh pins all fifteen cases, every false positive and
false negative found in review among them. The rule has been wrong twice in a
way that looked fine by inspection; it needed a self-test more than it needed
another careful reading.

Two further findings in the same review:

  - bin/run-tests.sh dropped PASSTHRU in shuffled mode, so `--shuffle -vvv`
    built verbosely and then ran quietly. The shuffled loop now forwards
    EXTRA_ARGS, which is PASSTHRU minus the -f pair it supplies per suite.
  - bin/run-tests.sh did not guard `cd "$ROOT_DIR"`.

And one that did not reproduce: the survivor fixture's glob does find the pid
file (verified with the lookup instrumented - the earlier failure was an
artifact of running the script from /tmp, where SCRIPT_DIR cannot resolve).
The assertion was still weak, because an empty pid took the "not running"
branch and passed vacuously. It now fails if the pid was never recorded, and
finds the file by search rather than assuming a directory depth.

Reported by CodeRabbit on #11322.

* fix(lint): report each UNITY_END occurrence at its own location

The self-test only asked "did the linter say anything", so it could not have
caught a wrong line, a wrong column, or a missing second finding. Fixtures now
assert the exact diagnostics as line:col, and the first run of that assertion
found two real problems.

The caret pointed at the wrong occurrence. For `exit(UNITY_END()); UNITY_END();`
the verdict was right but the column was 17 - the wrapped call - because the
scanner stripped terminating forms out of the whole statement and then reported
the first occurrence it had seen. Two bare calls on one line reported once.

Judged per occurrence now, by looking back through whitespace at what wraps it,
so both the count and the caret are right. That also needed a position map from
strip_noncode(): removing a comment or collapsing a literal shifts every later
column, and counting occurrences in the raw line does not recover it either -
TEST_MESSAGE("... UNITY_END() ..."); UNITY_END(); has two occurrences in the raw
text and one in the code.

Four of the expected columns I wrote by hand were also wrong, off by one. The
linter was right in every case; the assertions were not. They are computed from
the fixture text now rather than pasted from output, because a baseline accepted
from the tool it is testing asserts nothing.

17 fixtures, including the two-on-one-line case from review and its mirror.

Reported by CodeRabbit on #11322.
2026-08-06 14:05:07 +00:00
Ixitxachitl 32c3b62ef3 fix(BaseUI): restore node list menu swallowed by module-frame SELECT dispatch (#11358)
* Fix input event handling to correctly identify real module frames in the UI

* Refactor input event handling comments for clarity on module frame validation
2026-08-05 18:44:58 +00:00
HarukiToredaandThomas Göttgens 74b9f6ff02 BaseUI: Select Environmental Data Source (#11209)
* Select Source

* Fixes

* remove per sensor name

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-05 09:55:18 +00:00
HarukiToredaandJason P 39e7aa6a6c Full T-echo card support + Compact UI (#11342)
* T-echo card

* Update NRF52I2SOutput.cpp

* Update NRF52I2SOutput.h

* cleanup

* Update buzz.cpp

* use consistent runtime compact-panel check instead of mixing with compile-time macro

* Update NodeDB.cpp

* Update ExternalNotificationModule.cpp

* switched to Throttle::isWithinTimespanMs

* Update SharedUIDisplay.h

* trunk fix

* last cleanup

* ClockRenderer.cpp for OLED_COMPACT_UI and setup Unit C6L for new UI.

* Fixed regressions in standard OLED and TFT

---------

Co-authored-by: Jason P <applewiz@mac.com>
2026-08-04 14:27:35 +00:00
Thomas Göttgens 98813fe79d Shared e-ink hardware layer: foundation for target-by-target migration off GxEPD2 (#11142)
* Add shared e-ink hardware layer (graphics/eink) alongside legacy drivers

Foundation for a target-by-target migration off the GxEPD2-based
EInkDisplay2/EInkDynamicDisplay/EInkParallelDisplay stack:

- src/graphics/eink/: chipset drivers, panel profiles, backlight helper
  (promoted from the InkHUD driver set, shared by BaseUI and InkHUD)
- src/graphics/BaseUIEInkDisplay: OLEDDisplay adapter driving the new
  layer, with EINK_* compat macros matching EInkDynamicDisplay
- [niche] build helper in platformio.ini; graphics/eink/ excluded from
  arduino_base so unconverted targets are unaffected
- Screen/CannedMessageModule dispatch between the two stacks per env
- InkHUD-specific touch code in TouchScreenImpl1 guarded with
  MESHTASTIC_INCLUDE_INKHUD (no-op today, required once BaseUI variants
  define MESHTASTIC_INCLUDE_NICHE_GRAPHICS without InkHUD)

No variant is converted and no legacy file is removed; every existing
env builds identical firmware.

* Fix clang-format comment alignment in Screen.cpp

* Address review findings in the e-ink driver layer

- Screen.cpp: exclude InkHUD builds from all NicheGraphics BaseUI guards
- BaseUIEInkDisplay: size the OLEDDisplay buffer from its actual indexing
- EInkParallel: defer update() while an async refresh is in flight, honor
  the selected clear mode in the async task, never delete a live task
- ED047TC1: clean up on failed initPanel, fix inverted bbepI2CWrite checks
- UC8175: drop bogus 0x12 soft reset (0x12 is display refresh on UC8175)
- LCMEN2R13EFC1: guard absent reset pin, bound the busy wait
- SSD16XX/SSD1682: build the RAM window from the instance, not statics
- Doc corrections in driver banners and Drivers/README

* SSD16XX/SSD1682: send inclusive Y-end address (height - 1)

* LCMEN213EFC1: adopt the shared wait timeout / fail-through pattern

wait() now bounds the busy poll via Throttle and sets the EInk failed
flag on timeout; sendCommand/sendData fail through like the SSD16XX and
UC8175 drivers. EInk::runOnce clears the flag after the failed cycle.
2026-08-04 12:34:45 +00:00
Jason P 9e60b23334 Targeted build fixes, especially Heltec T1 (#11345) 2026-08-03 14:13:52 +00:00
Jason P c0ddb209b5 Fix up the BiColor boot screen (#11334)
* Fix up the BiColor boot screen

Fits the color below the divide of the two OLED colors for better boot appearance. Calculates out the same math for any other screens.

* Failed builds because of math, means you change the math
2026-08-03 01:03:08 +00:00
AustinandClaude Opus 5 f481585de4 Make GxEPD2_Multi non-copyable to address cppcheck warnings (#11326)
cppcheck reports noCopyConstructor and noOperatorEq against GxEPD2_Multi
on every e-ink environment (over 80 duplicate pairs on a single
heltec-wireless-paper run, one per template instantiation point):

  src/graphics/GxEPD2Multi.h:123: [medium:warning] Class 'GxEPD2_Multi <
  GxEPD2_213_FC1 , GxEPD2_213_E0213A367 >' does not have a copy
  constructor which is recommended since it has dynamic memory/resource
  allocation(s). [noCopyConstructor]

The warning is correct. The constructor news one of two GxEPD2_BW drivers
into a raw pointer member and caches &driver->epd2 in epd2.m_epd2, so the
compiler-generated copy operations would alias that driver: two objects
would drive the same panel, and the second to be destroyed would free a
driver the first still points at.

Nothing copies it - EInkDisplay2 heap-allocates a single instance and
holds a pointer - so declare that intent by deleting the copy operations
rather than adding a suppression.

Also null the unselected driver pointer. Only one of driver0/driver1 is
allocated and the other was left indeterminate; every method branches on
`which` before dereferencing, so this is latent rather than a live bug,
but an indeterminate owning pointer is one refactor away from a wild
dereference.

Behaviour is unchanged: no caller could have copied this type, and the
two added stores only initialize a pointer that is never read.

Verified on heltec-wireless-paper: `./bin/check-all.sh
heltec-wireless-paper` now reports "No defects found" (exit 0), and
`pio run -e heltec-wireless-paper` builds clean. The build matters
separately here because syntaxError is suppressed in suppressions.txt,
so cppcheck alone would stay green on a malformed declaration.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:13:20 +00:00
4b4e82bd72 SenseCAP Indicator: RP2040 peripherals for the main firmware (#6220)
* indicator: RP2040 peripherals for the main firmware

The SenseCAP Indicator RP2040 co-processor serves as a generic
peripheral bridge over a serial protobuf link (interdevice.proto):

- FakeI2C implements TwoWire and tunnels write and read transactions,
  so the standard sensor drivers and the I2C scan work unmodified on
  the bridged second bus (WIRE1)
- FakeUART forwards GPS NMEA to the regular GPS driver
- SD card access with chunked file transfers, paged directory
  listings and card statistics; device-ui loads map tiles and map
  styles from the card behind the RP2040
- link at 2M baud with 4KB chunks, message structs kept off task
  stacks

Log messages carrying their own bracket tag render it like a thread
name. Replaces the earlier IndicatorSensor/COBS approach.

* indicator: address review

Correlate responses with request ids, serialize the shared TX buffer,
reject oversized frames, fix RX buffer overflow and NMEA truncation,
full-length file paths.

* indicator: assign the GPS FakeUART at runtime

Static initialization order across translation units is undefined,
so createGps() assigns and null-checks the bridged serial instead.
Bound the NMEA length defensively.

* indicator: bump device-ui pin to 27e6c0c

* indicator: ping/pong link probe, non-blocking runOnce, FakeI2C locking

The RP2040 sends nothing unsolicited without a GPS module attached, so
wait_ready now probes with the new ping message instead of listening
passively. runOnce skips its pump while a requester holds link_lock,
keeping the main loop from blocking for a full request timeout. FakeI2C
serializes transactions between the UI task and the main loop with an
owner-tracked lock held from beginTransmission to transaction end.

* indicator: link resync, config-honoring GPS, bridged-bus routing, stats validity

Frame resync scans to the next magic instead of flushing the RX buffer,
and the pump handles all buffered frames per pass. The RX drain reads in
bulk and the protobuf encoder gets the correct buffer bound. GPS honors
the gps_mode setting on the Indicator instead of always running. RTC,
I2C keyboard and motion sensor drivers resolve WIRE1 through
ScanI2CTwoWire::fetchI2CBus so bridged buses reach the right transport.
FakeUART implements flush/availableForWrite/const-write from the Stream
contract and fences its cross-core ring buffer. SdCardInfo.stats_valid
is passed through to device-ui, and the remote FS backend gains the
remove operation used for cleanup of failed tile saves.

* indicator: retry lost link round trips, I2CResult UNSPECIFIED

Remote FS operations retry once on a transport timeout. Correlation ids
drop late responses of the first attempt; a retried append whose first
attempt landed is recognized by the offset conflict carrying the
resulting file size. Definitive failures are not retried, missing-tile
probes stay a single round trip. Regenerated bindings add the
I2CResult.Status UNSPECIFIED zero value so an empty result cannot
decode as success.

* indicator: nack responses, rename bridge classes to I2CProxy/UARTProxy

A request the co-processor cannot decode or handle is nacked, so the
requester fails fast instead of burning its timeout. All requests stage
the shared tx_message under link_lock. FakeI2C and FakeUART are renamed
to I2CProxy and UARTProxy after the pattern they implement, with their
instances following suit. Drops dead code (unused NO_NEWS_PAUSE,
unreachable not-running branches, doubled include guards) and the GPS
pin log line that is meaningless on the tunneled port.

* indicator: refuse a co-processor that speaks another protocol version

The ping/pong handshake now carries InterdeviceVersion. A pong reporting
a version other than ours means the RP2040 runs firmware that does not
match this build, so the bridge stays shut down for the session and the
mismatch is logged with both versions. Requests fail fast instead of
being misinterpreted by the other side.

* indicator: regen protos, interdevice protocol version 2

* indicator: per-task I2C contexts, gated handshake, retryable link failures

The bridged I2C bus is shared between the main loop and the UI task, and
TwoWire has no transaction bracket a lock can span: drivers drain the read
buffer with available()/read() long after requestFrom() returned. Each
calling task therefore gets its own staging and read buffers instead of a
lock that could be left held (or that could not protect the read buffer
anyway). The transaction is staged inside the link, under its lock.

No request is sent before the co-processor has completed the version
handshake, and runOnce keeps probing until it does, so a co-processor that
boots slowly or reboots on its watchdog no longer leaves the bridge dead
for the session. Requests in flight are counted, not flagged: two threads
can be in a request and the first one out must not clear the other's state.

File operations are retried on a lost frame and on a co-processor busy with
card maintenance, but not on a refusal (nack) or a definitive failure, and
they release the SPI lock while they wait so a slow link does not starve
the radio.

* indicator: fail safe on a peer mismatch, wait out card maintenance

FileStatus moved to a fresh tag: reusing the tag of the removed success flag
made every failure status decode as success on a peer that predates it.

A card being mounted (busy) is retried rather than reported as an empty
slot, and a co-processor busy with card maintenance is waited out: mounting
takes seconds and the free space scan of a large card walks its whole FAT,
which is not a reason to report a missing tile. The bridged I2C bus releases
the SPI lock as well, so the keyboard scan on the UI task cannot starve the
radio either. Slot claims in the I2C proxy are atomic, NMEA is not sent to a
peer we refuse to talk to, and the handshake is completed by the unsolicited
ping the co-processor sends when it has booted, which also reports a
reboot.

* indicator: regen protos, FileStatus back on the original tags

* indicator: regen protos, ping/pong carry the InterdeviceVersion enum

* indicator: point the protobufs submodule at the merged interdevice protos

* indicator: pin device-ui to the branch with the remote SD support

* indicator: honor the txOnly flag of flush, report dropped GPS writes

flush() through a Stream pointer discarded the receive buffer: the flag is
txOnly, and HardwareSerial::flush() keeps what has been received. write()
reported bytes as written even when the link refused to send them. The link
probe uses Throttle for its rate limit.

* indicator: decide the log tag on the formatted message, hex request ids

The thread tag was suppressed based on the printf template, which disagrees
with the rendered message it is compared against: a format starting with a
conversion could produce two tags, and one without a trailing bracket-space
lost the tag entirely. vprintf now receives the thread name and picks. Also
shifts only the bytes actually buffered after a frame, throttles with
Throttle and logs request ids as hex.

* indicator: SD mount, eject and format commands over the link

* indicator: bound how long a busy card state blocks the UI task

* indicator: a busy co-processor must not block the UI task for ever

The busy retry re-armed its own budget on every busy answer, so a
co-processor that stayed busy kept the caller in the loop with no way out.
Transport retries and the wait for a busy card are now separate budgets that
only count down.

* indicator: start each request from an aligned receive buffer

A byte run lost mid-response (a UART overflow during a 4KB tile chunk, when
the display starves the RX interrupt) misaligns the assembly buffer. The
buffer was never reset, so the poison outlived the request and cascaded into
the following chunks of the same tile: one glitch dropped a whole multi-chunk
tile, while single-chunk tiles resynced in the idle gap and survived. Each
request now flushes the buffer first, bounding a glitch to the one chunk it
hit. Adds resync/decode/timeout counters, logged rarely, to see the rate.

* indicator: enlarge the LVGL heap for low-zoom map tiles

The heap was 3MB and the image cache reserves 1.5MB of it, so a low-zoom map
tile could not find a large enough contiguous block to decode and rendered
white. 5MB of the 8MB PSRAM fixes it with room to spare.

* indicator: advance the device-ui and protobufs pins to the merged commits

Point the protobufs submodule at the merged SD command protos (protobufs
#986) so it matches the checked in interdevice sources, and bump the
device-ui archive to the current indicator branch tip that carries the SD
button and format UI.

* Update device-ui library dependency URL

* remove cutom sdkconfig

* remove duplicated synchronisation (after PR11278 is in place)

* set commit reference to updated RemoteSDService class

* Add board_level configuration for release

* fix cppcheck errors

---------

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
2026-08-01 16:23:30 +00:00
Jason P 1d2fb23a0f Consolidate drawGps related code (#11304)
* Update UIRenderer.cpp

* Update GPS code path
2026-08-01 12:30:50 +00:00
Jason P 3d8ff48fab BaseUI: Clean up dead code (#11306)
* Clean up dead code

* Fix up the WiFi macro to be more directly called
2026-07-31 17:29:21 +00:00
Ben MeadorsandManuel e2460b546e MUI: stop holding the SPI bus across the whole UI cycle (#11278)
tft_task_handler held spiLock for the entire LVGL cycle. Most of that
cycle is timer work and rendering into the draw buffer, which issues no
SPI at all - but on boards where the TFT, SD card and LoRa radio share
one bus (T-Deck), every radio operation on the main loop still waited it
out. That is tens to hundreds of milliseconds whenever the UI animates,
felt as mesh RX/TX latency.

device-ui now takes the lock around its own transfers instead
(meshtastic/device-ui#356), so the coarse hold here can go and the bus is
contended only during real traffic.

Lend it spiLock through a reentrant adapter. device-ui nests its guards -
SdFsCard::usedBytes() calls cardSize() and freeBytes(), each of which
takes the lock - while spiLock is a plain binary semaphore that would
self-deadlock on the second take, so track the owning task and only touch
the underlying lock on the outermost acquire.

Requires the device-ui pin bump included here.

Tested on T-Deck: flush, touch, panel init, SD detect, powersave sleep
and wake all exercised; LoRa RX decoding under a live UI, no deadlocks,
no watchdog resets. Also builds seeed-sensecap-indicator-tft.

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-07-31 10:49:39 +00:00
Jason PandAustin 575788ae7b Implement Fixes for Meshnology W12's Two Color Display (#11288)
* Round 1 Fixes

* Update GPS icon

* Update calculations for revised GPS icon after regression testing

* Add BICOLOR_OLED_DISPLAY and actually use it

---------

Co-authored-by: Austin <vidplace7@gmail.com>
2026-07-31 04:54:54 +00:00
597f6767b5 Add Elecrow ThinkNode M8 board support (thinknode_m8) (#11226)
* Add Elecrow ThinkNode M8 variant scaffold (thinknode_m8)

nRF52840 + SX1262 + 2.4" e-paper + ATGM336H-5NR32 GPS.
All pins resolved from ThinkNode_M8_V0.3.sch; cross-checked
against meshtastic/firmware#9181 (Elecrow V0.1 reference).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add Elecrow ThinkNode M8 board support (nRF52840/SX1262, 1.54in e-ink, ATGM336H GNSS, SC7A20, EC04 encoder)

* Address review: keep the stored backlight level out of blanking, match only the SC7A20 WHO_AM_I byte, and transfer detents atomically

* Use std::atomic for the press-and-turn detent counter so native builds compile

* Drop the ThinkNode M8 LED_BUILTIN redefinition that warned on every translation unit

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-30 15:59:35 +00:00