Commit Graph
12706 Commits
Author SHA1 Message Date
AustinandClaude Opus 5 514b476189 feat(admin): append the optional ham long_name to the call sign (#11612)
* feat(admin): append the optional ham long_name to the call sign

HamParameters gained a long_name field (meshtastic/protobufs#941) that
handleSetHamMode never read, so a client that sent one still ended up with a node
named after the bare call sign. Join it behind the call sign with the "//"
separator hams already use on the air: call_sign "N0CALL" plus long_name "Attic
Heltec" becomes "N0CALL//Attic Heltec". An absent long_name keeps the previous
call-sign-only name, which is what the on-device region picker still sends.

Being cosmetic, long_name stays out of the whitespace-only rejection that guards
call_sign and short_name: a blank one is dropped rather than costing the operator
the whole licensing request over a stray space, which that path would report only
as a LOG_WARN and so would be invisible from the app. The composed name is
finished with clampLongName() rather than a bare sanitizeUtf8(), matching
handleSetOwner and NodeDB: the proto caps the parts at 7 + 2 + 14 bytes, inside
the 24-byte local budget, and clampLongName is the backstop if either cap moves.

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

* feat(admin): enhance handleSetHamMode to return status for request validation

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 20:34:02 +00: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
Thomas Göttgens 576a1bb008 Fix trackball dropping short presses and losing the click when tilted (#11599)
* Fix trackball dropping short presses and losing the click when tilted

* Do not let a direction counter overwrite an emitted press event

* Accept the first press interrupt when the clock still reads zero

* Classify a press released before the first poll by its latched time
2026-08-26 20:00:42 +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 8eda86045b fix(audio): amp settle window, and start melody after codec init (#11604)
* fix(audio): amp settle window, and start melody after codec init (#11597)

* chore(audio): condense the new code comments to two lines
2026-08-25 20:40:39 +00:00
TomandBen Meadors 7e11bde8c8 fix(beacon): repair the MeshBeacon radio switch/restore regression from #11573 (#11596)
* fix(radio): put the beacon restore back inside completeSending's if (p)

Reverts the RadioLibInterface and RadioInterface changes from #11573
(ac330e6a6). Hoisting MeshBeaconModule::reconfigureForBeaconTX() out of the
if (p) block changed its meaning from "a send completed" to "the radio went to
standby, for any reason" - and every driver's setStandby() calls
completeSending() unconditionally: on the pre-TX LBT scan, on startReceive(),
and inside reconfigure().

Two shipping faults followed, both confirmed on hardware the next day.

Every beacon transmitted on the wrong preset. isChannelActive() standbys the
radio immediately before each transmit, so the restore ran between the switch
and the key-up. The packet went out carrying the beacon channel hash with home
modem settings - inaudible to listeners on the target preset, an unknown hash
to listeners on the home one. Inert in both directions.

And unbounded recursion: the restore calls iface->reconfigure(), which
standbys, which calls completeSending(), which restores again, each level
running a full applyModemConfig(). It terminated in a HardFault and a silent
reboot (Reset reason 0x4 on nRF52, no panic output). The crash masked the
misdirection - the node died before Started Tx, so the wrong preset was
invisible until the recursion was fixed.

completeSending() clears sendingPacket at the top, so any nested call sees
p == NULL. The if (p) block was an accidental re-entrancy guard, and nothing
named it as such; removing it created both faults at once. Name it now.

This also reverts the beginSending() failure return that motivated the move,
and the startSend() scaffolding built to reach the restore on that path. The
payload bounds check it replaced is reinstated in the next commit, at a point
where refusing a packet is already a supported outcome.

* fix(radio): bound the payload at the radio queue, not mid-transmit

#11573 replaced beginSending()'s assert with a runtime check that logged,
released the packet and returned 0. beginSending() had never returned 0
before, so startSend() gained a failure path it had to unwind - and the
release moved ownership of the packet out of the caller that held it. That
new return value is what made hoisting the beacon restore look necessary.

The check itself is worth keeping. MeshPacket.encrypted has a nanopb maximum
of 256 bytes against a 240-byte radio buffer, and beginSending() is on the
path for relayed frames and phone-sourced packets, neither under our control.
Asserts are commonly compiled out in release builds, so what shipped was an
unchecked 256-into-240 memcpy driven by remote input.

Move it to Router::send(), immediately before iface->send(p) - the single
funnel for every over-the-air transmit. Refusing a packet there is already a
supported outcome: it returns TOO_LARGE, which is what perhapsEncode() already
returns for the same condition on the decoded path, and releases or NAKs
exactly as the duty-cycle limit above it does. Nothing radio-side has happened
at that point, so there is no half-started transmit to tear back down.

perhapsEncode()'s existing check does not cover this case: relayed and
phone-sourced frames arrive already encrypted and never reach it.

beginSending() keeps a last line of defence, but clamps rather than failing,
so it stays a call that always succeeds. Adds MAX_RADIO_PAYLOAD_LEN so both
sites name the same number instead of recomputing it.

Nothing about a beacon can trigger any of this - broadcast_message is
admin-truncated to 100 bytes, the whole MeshBeacon protobuf tops out at 180,
and observed beacons run to 106 - which is why this is separated from the
beacon changes rather than carried with them.

Tests: Router::send() refuses an oversized payload and still sends one that
exactly fills the buffer; beginSending() clamps instead of rejecting, and
leaves ordinary traffic whole.

* fix(beacon): guard the radio switch/restore against re-entry and early restore

Two checks in reconfigureForBeaconTX(), both independent of radio state, so
the switch/restore state machine no longer rests on sendingPacket's lifetime -
which is exactly the implicit coupling that let #11573 through.

A re-entrancy guard. Both branches end in iface->reconfigure(), whose
setStandby() runs completeSending(), which calls straight back in here. While
one call is applying a config, a nested call returns false and leaves it
alone. This covers the switch branch too, which had the same exposure with a
quieter symptom: a second switch before the restore would take the re-entrant
call as a restore and undo the switch still being applied, sending the beacon
on the home channel instead of its target.

A restore gate. The restore now waits for the packet that armed the switch to
actually finish, tracked by id against our own target table rather than by
asking the radio. Every caller that completes or abandons a beacon clears that
packet's target settings first, so a live entry means the TX has not happened
yet. cancelSending() now clears too, which is what keeps a cancelled beacon
from pinning the radio on the beacon config.

Together these make explicit the invariant completeSending()'s if (p) block
was carrying by accident: a future hoist of that call gets a logged no-op
instead of a crash and a misdirected beacon.

Also sets radioSwitched before reconfigure() rather than after, in both
branches, so the flag never describes a radio state that is not yet true.

Diagnostics, because every step of this dance was previously silent about its
own state. Count consecutive switches with no restore between them and log the
depth on both sides, so a change-change-change-restore run reads off the log;
switch #2 onwards prints the held home snapshot, which is the value that has
to survive a second switch. The restore names the config it is restoring to,
so a stale snapshot is visible directly. The re-entrancy guard logs when it
fires - expected exactly twice per beacon, so a burst means something new is
re-entering rather than a silent reboot. And setTargetRadioSettings() now
warns on the slot eviction that previously left a packet to key up on whatever
config was running - no crash, no log, wrong channel.

Reachable only with beacon broadcast enabled (the default flags are
LISTEN_ENABLED | LEGACY_SPLIT, so broadcast is off) and a target differing
from the running config; an identical target takes the early return and never
switches.

Tests: three re-entrancy cases against a RadioInterface whose reconfigure()
re-enters exactly as completeSending() does - bounded, so a regression fails
an assertion instead of overflowing the stack and taking the runner with it -
plus a restore that must defer until the beacon it switched for completes.

* fix(beacon,radio): address review findings on #11596

Payload ceiling was one byte too generous. RadioBuffer::payload is 240 bytes
because the buffer reserves MAX_LORA_PAYLOAD_LEN + 1, but the PHY caps a whole
frame at 255 and beginSending() adds a 16-byte header - so a 240-byte payload
produced a 256-byte frame. Define the ceiling as MAX_LORA_PAYLOAD_LEN -
sizeof(PacketHeader), matching what perhapsEncode() already enforces, with a
static_assert that it still fits the buffer.

Target-table eviction could unblock the restore gate. With every slot live,
setTargetRadioSettings() overwrote slot 0 - and if that slot held the packet the
outstanding switch is gated on, the restore came unblocked and put the home
config back under a beacon that had not keyed up. Skip that entry when choosing
a victim, and refuse the target outright if every slot is in flight. Needs
radioSwitched/switchedForId at file scope so the setter can see them.

Restore on every abandon path, not just the clear. cancelSending() dropped a
queued packet's target without restoring, so a beacon pre-switched by onNotify()
and then cancelled left the radio receiving on the beacon config;
removePendingTXPacket() did neither. Both now route through
abandonBeaconTarget(), as does startSend()'s tx-disabled branch. The restore
gate makes it a no-op when the abandoned packet is not the one we switched for.

No NAK on the oversize drop. p->channel is a wire hash by that point, not an
index, and Channels::getIndexByHash() is declared but never defined. Only
already-encrypted ingress can reach the gate anyway - perhapsEncode() bounds
everything it encodes - and those carry no index to answer on. Release and log.

Tests clear sendingPacket before releasing their packet, and assert against the
payload ceiling rather than the buffer size.

* fix(beacon): route the invalid-target drop through abandonBeaconTarget

onNotify()'s invalid-config drop was the one packet-abandonment path still
clearing the target directly instead of going through abandonBeaconTarget(),
so a packet that armed the radio switch and then failed validation would be
released with the radio left on the beacon config and nothing to restore it.
The helper's restore gate (targetRadioSettingsLive(switchedForId)) makes the
call a no-op for any packet that did not arm the switch, so this closes the
gap without risking a premature restore.

Also trims the switch-state comment to the two-line limit.

* fix(radio): take the abandoned packet as a pointer to const

cppcheck's constParameterPointer failed the check matrix on every board:
abandonBeaconTarget() only forwards the packet to clearTargetRadioSettings(),
which already takes a const pointer, so the parameter should be const too.

* refactor(radio): drive the beacon radio switch through TX hooks

RadioLibInterface named MeshBeaconModule at six call sites behind
MESHTASTIC_EXCLUDE_BEACON guards, so the driver carried per-packet beacon
state: when to switch preset, when a target config was invalid mid-transmit,
and when not to listen on a busy channel. Review on #11596 asked for the
module dependency to come out.

RadioTxHook is what the driver knows instead - beforeTransmit() returning
send/defer/drop, holdsRadio(), packetReleased() - on a self-registering
intrusive list, so nothing is allocated and a build without the beacon module
registers nothing and every call is a no-op. The four abandon paths (cancel,
remove-pending, TX disabled, completeSending) collapse onto one
packetReleased(), and the tri-state means the driver no longer has to know why
a packet wanted a re-delay or a drop.

MeshBeaconTxHook wraps the existing statics; the switch/restore logic, its
re-entrancy guard and its restore gate are untouched. It is created in
Modules.cpp inside the existing exclusion guard, so MESHTASTIC_EXCLUDE_BEACON
now works by nothing registering rather than by #ifdefs in the driver.

Behaviour is unchanged. The invalid-config LOG_DEBUG moves into the module and
the driver logs a generic refusal. Four tests cover the send/defer/drop mapping
and that an empty hook list is a no-op; native:test_mesh_beacon is 59/59.

Also notes in sendBeaconPacket that beacons uplink to MQTT on the primary
slot's uplink_enabled, and that the topic follows the beacon channel under the
crypto-override swap - both intentional.

* fix(beacon): restore the home config for a packet that jumps the queue

The restore gate added in 9cb7b96c9 refused to put the home config back while
the beacon that armed the switch was still live. That is right for a release -
completeSending() runs on every setStandby(), and restoring there would undo
the switch before the beacon had keyed up - but it also caught the case where
the driver is asking about a different packet it is about to transmit.

MeshPacketQueue::enqueue() inserts by priority (std::upper_bound over
CompareMeshPacketFunc), so an ACK or routing packet queued during the beacon's
deferred transmit delay lands ahead of it. beforeTransmit() then saw an
untagged packet, found the beacon still queued, skipped the restore and
returned PRETX_SEND - and the packet transmitted on the beacon's preset, slot
and region. It was encrypted and hashed for the home channel, so no receiver
on either preset could use it.

Apply the gate only to a null p. A non-null untagged packet is the driver
about to key up, which always restores; the restore returns PRETX_DEFER, so
the driver re-runs the delay and the channel scan on the config it will
actually transmit on. beforeTransmit() is the only caller that passes a
non-null untagged packet, so nothing else changes.

Found by CodeRabbit on #11596. native:test_mesh_beacon 60/60, including a
regression test for the queue transition; the four re-entrancy tests still
cover the null-p gate.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-25 19:29:34 +00:00
Ben Meadors 709504cc25 Revert "Skip Bluetooth wait when Bluetooth is disabled (#10571)" (#11608)
This reverts commit f1b1e35a79.
2026-08-25 10:46:58 -05:00
Ben MeadorsandClaude Opus 5 98c88d7e19 fix(position): halve the stationary/fixed-position broadcast floor to 6h (#11606)
The 12h floor introduced with traffic management was too aggressive: a
fixed_position or stationary node goes quiet for half a day after its
boot broadcast, so anything that missed that one packet - a node that
joined later, or one that restarted - shows it with no position until
the next refresh.

Drop the floor to 6h, and drop the traffic-management identical-position
dedup window from 11h to 5h with it. The two are a pair: the dedup window
was deliberately sized just under the broadcast floor so a stationary
node's periodic refresh clears its neighbours' window instead of being
dropped as a duplicate. Leaving it at 11h would have made the extra
broadcast pure airtime - aired, then discarded by every receiver - so the
mesh would still have seen a 12h refresh.

Role caps are unchanged and still bind: tracker 1h, lost-and-found 15m.
Both remain shorter than the new 5h default, so those exceptions apply
exactly as before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:51:36 +00:00
Thomas Göttgens 56ce743f75 Show waypoints sent with no expiry and stop expiring on an unset clock (#11600) 2026-08-25 11:37:43 +00:00
Thomas Göttgens 9c0a331309 fix(test): stop the survivor scan reporting a hit as a miss (#11603)
* test-state: match the sandbox HOME in-shell so a survivor hit cannot report as a miss

* Trim the survivor-scan comment to two lines

* test-state: read the environ with a NUL-delimited read loop, not mapfile -d

* test-state-check: report the survivor's actual HOME and the wrapper's stderr

* test-state-check: re-run the scan when it reports a miss, to separate a race from a mismatch

* test-state-check: let the survivor fixture finish exec before the suite returns

* test-state-check: fail the survivor fixture instead of staging a pid it never saw exec
2026-08-25 10:08:32 +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
Thomas Göttgens c45b66352b Idle the Wio Tracker L1 buzzer pin at boot (#11601) 2026-08-25 08:31:48 +00:00
Ben Meadors 4528018b12 fix(xmodem): close the file when a transmit is aborted (#11598)
The `else if (isTransmitting)` branch in handlePacket() cancels the
transfer and clears isTransmitting without closing the open file. It is
the only terminal path that does not close: EOT, CAN, and the ACK/EOT
completion paths all do. The next transmit then reassigns `file` in the
STX handler, orphaning the previous handle.

Any client that can speak the XModem ToRadio path can drive this in a
loop (STX seq=0 to start a transmit, then any non-seq-0 frame to hit the
abort branch), and XModem is not subject to the PhoneAPI packet
throttle, so the loop runs as fast as the link allows.

Measured on real hardware with a DEBUG_HEAP build, 60 iterations:

  Heltec Mesh Node T096 (nRF52840)   44,908 -> 32,896 B free (-200 B/iter)
  Heltec Wireless Tracker V2 (S3)    57,360 -> 51,472 B free ( -98 B/iter)

The heap is not reclaimed afterwards. On the T096 that exhausts ~45 KB
of free heap in roughly 225 iterations.

With this change, 200 iterations on a T096 leave free heap unchanged
(44,968 B before and after).
2026-08-25 01:52:47 +00:00
48357538ba Meshnology W10: enable the AXP2101 power key as a second button (#11593)
* Meshnology W10: enable the AXP2101 power key as a second button

SW3 is wired to the AXP2101 PWRON pin (via R44 510R, schematic W10-MB-V1.1
pg3), but the key did nothing in firmware.

Power::runOnce() already polls the PMU IRQ status registers over I2C and maps
a PEK short press to INPUT_BROKER_CANCEL when PMU_POWER_BUTTON_IS_CANCEL is
set. However the matching PMU->enableIRQ() lives inside #ifdef PMU_IRQ, while
PMU init runs disableIRQ(ALL) first. Without PMU_IRQ the PKEY_SHORT status bit
is never armed, so the polled read is always false and the define alone is
inert.

AXP_IRQ on this board reaches only expander EXIO5 and is not routed to any
ESP32 GPIO, so define PMU_IRQ as the MCP23017 virtual pin, mirroring how
LORA_DIO1 is handled on this variant. The attachInterrupt() and
gpio_wakeup_enable() uses of PMU_IRQ are inert on a non-GPIO value (both are
unchecked calls, so an invalid pin is ignored rather than fatal); what the
define buys is the enableIRQ() they gate.

Tested on Meshnology W10 hardware: short presses of SW3 now log
"[Power] Input: Corona Button Click", the existing GPIO0 user button continues
to work independently, and the board boots normally. Events surface on the 20s
Power::runOnce() cadence, since with no real interrupt the ISR's
setIntervalFromNow(0) never runs to force an immediate poll.

* style: apply clang-format to meshnology-w10 variant.h

Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
2026-08-25 01:44:06 +00:00
renovate[bot] e257e2a487 chore(deps): update meshtastic/device-ui digest to 27443d0 (#11571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 00:44:15 +00:00
renovate[bot] 15e16839d3 chore(deps): update meshtastic-esp8266-oled-ssd1306 digest to bb93fd4 (#11594)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-25 00:43:48 +00:00
IxitxachitlandBen Meadors e8d4573af7 fix(t-watch-ultra): wrap esp_flash_read so NVS survives, keeping BLE bonds (#11583)
* fix(t-watch-ultra): wrap esp_flash_read so NVS survives, keeping BLE bonds

The IDF 5.5 manual-read regression on this board's flash is already worked
around for esp_partition_read, but nvs_flash does not use that API: it reads
the NVS partition through the lower-level esp_flash_read, which still returns
0x00. NVS therefore initialised empty on every boot -- zero entries, zero
namespaces -- even though the data was intact on flash.

Everything stored through NVS was lost each boot, including NimBLE's bond
table. A phone that had already paired was not recognised on reconnect, so
the device ran a fresh pairing and displayed a new passkey every time. The
PIN worked, but the bond never persisted.

Wrap esp_flash_read the same way, using the raw (non-partition) spi_flash_mmap
so it serves callers that never go through the esp_partition_t API. Reads for
any chip other than the default fall back to the real implementation, as do
mmap failures. Gated on T_WATCH_ULTRA; no other board is affected.

* fix(t-watch-ultra): keep the raw-read contract when flash encryption is on

esp_flash_read is specified to return raw, still-encrypted bytes; the flash
cache is what decrypts transparently. Reading through spi_flash_mmap therefore
hands back plaintext where the caller asked for ciphertext.

No target here enables CONFIG_SECURE_FLASH_ENC_ENABLED, so nothing is affected
today, but --wrap is a global interposition and encryption can be burned into
efuse independently of the build config. Check at runtime and leave encrypted
flash to the real implementation.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-24 23:02:04 +00:00
Thomas Göttgens 8a15d9258f fix(test): unbreak test_radio under ASan (#11589)
* test_radio: prove the rejected packet was released via pool accounting, not pointer identity

* test-state.sh: silence the shell's own open failure when scanning /proc for survivors

* Trim the comments added with the test_radio and test-state fixes
2026-08-24 20:14:48 +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
Thomas Göttgens ee48094ea8 Fix backwards GPS_RX_PIN/GPS_TX_PIN direction comments (#11585)
GPS.cpp passes GPS_RX_PIN as the MCU's RX pin and GPS_TX_PIN as its TX
pin. Nine variants documented the opposite, which reads as if the pins
were swapped on working hardware (see #11584).

Comment-only change; no pin assignment is touched.
2026-08-24 11:29:54 +02:00
Clive BlackledgeandClaude Opus 5 0271be9369 fix(SafeFile): remove a stale .tmp before opening it for write (#11428)
* fix(SafeFile): remove a stale .tmp before opening it for write

SafeFile writes to <filename>.tmp, verifies it by readback, then renames it over
the real file. openFile() never removed a pre-existing .tmp - an unfinished
FIXME - and FILE_O_WRITE appends rather than truncates on Adafruit_LittleFS
(nRF52) and STM32 LittleFS.

So a .tmp left behind by a reset in the window between close() and renameFile()
is appended to on the next save. The readback hash covers only the bytes just
written, so it mismatches, close() returns false, and the tmp is left behind
again - the failure latches and every subsequent save of that file fails. Today
saveProto() discards close()'s result, so this is silent and permanent.

Guard the remove with exists(): a bare remove() of a missing file logs on
Portduino. The same guarded pattern is already used for this exact append trap
in xmodem.cpp.

Note the FIXME's commented-out body named the wrong path - it removed
'filename', the real file, not 'filenameTmp' - so it would have destroyed the
good copy had it ever been enabled.

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

* test(SafeFile): cover the stale-.tmp path, and trim the fix comment

Adds test_safefile, the first coverage of SafeFile's write-tmp / verify-by-readback /
rename-over path that every saveProto() caller goes through. Five cases pin the contract
the fix restores: whatever the backend does on open, a completed save leaves the real file
holding exactly the bytes written and nothing else, on both the fullAtomic and the
!fullAtomic construction, with no .tmp left behind.

These tests cannot go red on the native host, and that gap cannot be closed here. FILE_O_WRITE is an append-then-seek-to-end open only on
Adafruit_LittleFS (nRF52) and on the in-repo STM32 port (STM32_LittleFS_File.cpp: LFS_O_RDWR
| LFS_O_CREAT followed by lfs_file_seek to LFS_SEEK_END). On Portduino FILE_O_WRITE is the
string "w" (FSCommon.h:13), which reaches fopen() and truncates. Reverting the source fix
and re-running leaves all five green, verified rather than assumed. test_write_open_truncates
_on_this_host asserts that premise out loud, so if the host ever gains the append behaviour
the suite starts discriminating instead of quietly agreeing.

Why the original FIXME stayed commented out, since that is the real history here. It read
"if (fullAtomic) FSCom.remove(filename)" and named the real file, not the tmp. Running it
would delete the last good copy before the replacement had been written and verified, which
is precisely the guarantee fullAtomic exists to provide. Disabling it was correct. The fix
under test removes filenameTmp instead, which is the file that actually carries the stale
bytes, and is safe to drop at any point because nothing has been promised about it yet.

Scoping the remove to fullAtomic would be wrong for the same reason. Both paths open the
same filenameTmp with the same FILE_O_WRITE; fullAtomic only decides whether the real file
is nuked up front to free space. The !fullAtomic path is the space-constrained one, so it is
if anything the more likely to be interrupted mid-write and inherit a stale tmp. Test 2
pins that.

On the cost of the added exists(). Every saveProto() already ends in SafeFile::close(), which
calls testReadback(): it reopens the tmp and reads the whole proto back one byte at a time
through f2.read() to XOR a verification hash, then renames. So the per-save cost is already an
open, a full write, a close, a full byte-wise reread, and a rename. One exists() is a single
path lookup with no erase, no program and no data read, and on the common path there is no
remove() at all. Next to the readback loop it is noise. Happy to put a number on it if wanted.

Scoping it to fullAtomic would also not do what it looks like it does. SafeFile's constructor
defaults fullAtomic to false (SafeFile.h:28), and of the saveProto call sites only
saveDeviceStateToDisk passes true. Config, moduleconfig, channels, nodedatabase and backup all
take the default, so scoping would leave the stale tmp live on almost every save path,
including the space-constrained one most likely to be interrupted mid-write.

Also trims the fix's comment to two lines per AGENTS.md, and drops the stale-tmp removal log
from LOG_WARN to LOG_DEBUG: an interrupted write is recoverable and self-healing, so it does
not warrant a warning on every boot after one.

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

* fix(SafeFile): fail the write when a stale tmp cannot be removed

openFile() ignored the result of FSCom.remove(). If the removal failed on an
append-on-write backend, the open that follows appended to the stale bytes, and
the readback hash is an 8 bit XOR over the whole tmp, so polluted content has a
real chance of verifying and being renamed over the good file.

It now logs and returns an invalid File. SafeFile::write() already no-ops on
!f and close() already returns false, so the caller sees the save fail rather
than silently getting a corrupt one. This is the only checked FSCom.remove() in
the tree; the other call sites are all best-effort cleanups where failure does
not compromise anything.

Also gates test_write_open_truncates_on_this_host to ARCH_PORTDUINO. It asserts
that this host truncates on FILE_O_WRITE, which is false by design on the
Adafruit_LittleFS and STM32 backends the fix exists for, so running the suite
there would fail on a premise that is only meant to describe the test host.

Both reported by CodeRabbit on #11428.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:23:23 +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
Tadayoshi MIURA ac330e6a6b fix(radio): MeshBeacon heap leak and runtime packet payload size check (#11573)
* Fix for MeshBeacon packet leakage

* fix: add runtime payload size check against radiobuffer

* review fix for PR#11573: clear target radio settings before MeshBeacon packet release

* add unit test for radio buffer capacity check, removing related assert for the test

* review fix for PR#11573: add explicit verifaction against rejected packets
2026-08-23 11:00:30 +00:00
zelo533andBen Meadors 05f6474108 meshnology-w10: define HAS_SPI_TFT so the TFT screen initializes again (#11042)
#10803 refactored main.cpp to key SPI-TFT Screen creation on HAS_SPI_TFT
instead of the per-controller define list. The W10 variant (#10911) was
written before that refactor and crossed it mid-air, so it never defines
HAS_SPI_TFT and develop builds fall through to the I2C-OLED autodetect
branch: no Screen is ever constructed and the display stays dark, while
everything else (radio, GPS, BLE) works.

Verified on a real W10: with the define, the boot log shows TFTDisplay
creation, backlight power-on and the boot screen, and the ST7789 panel
renders the UI again.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-22 19:06:33 -05:00
Ben Meadors 73f7b35bea Report the right hardware model on four boards (#11570)
Four variants declare a custom_meshtastic_hw_model that the build never
reaches, so the device announces something else in NodeInfo and the apps
cannot match it for OTA.

Mini ePaper S3 (125) and Heltec V4 R8 (132) had no arm in the esp32
HW_VENDOR chain at all, so both fell through to #else and reported
PRIVATE_HW. Heltec Mesh Node T096 (127) had none in the nrf52 chain and
reported NRF52_UNKNOWN.

WisMesh Tap V2 defines both RAK3312 and RAK_WISMESH_TAP_V2, and the
generic RAK3312 arm sat first, so the board reported RAK3312 (106)
instead of WISMESH_TAP_V2 (116). Order the specific arm ahead of the
generic one, the same way the nrf52 chain already keeps custom RAK4630
boards ahead of the generic RAK4630.

Verified by preprocessing each platform's HW_VENDOR chain with the
env's full define set - build flags resolved through extends, the board
JSON's build.extra_flags, and the bare #defines in the variant's own
variant.h. All four now match their manifest, and rak3312, heltec-v4,
heltec-v4-tft and the ThinkNode M9 arm added in #11567 are unchanged.
2026-08-22 17:00:47 -05:00
Ben Meadors f6f116a39d Fill in device registry metadata for recently added hardware (#11567)
Audit of the custom_meshtastic_* manifest on the variants backing the
newest boards, against the protobuf HardwareModel enum, the compiled
HW_VENDOR, the board flash size and the artwork actually published by
the web flasher. No support flag changes here - actively_supported is
left exactly as each variant already had it.

ThinkNode M9 had no HW_VENDOR arm, so every M9 has been reporting
PRIVATE_HW while its manifest advertised 131; add the mapping and
rename the slug to the enum name (THINKNODE_M9) it is meant to mirror.

Seeed SenseCAP Mesh-Tracker X1 moves from the PR matrix to release, and
its images entry now points at seeed_mesh_tracker_x1.svg, which is what
the flasher actually ships - the hyphenated name resolved to nothing.

T-Beam BPF, T-Beam 1W and Heltec Wireless Tracker V2 declared the
architecture as "esp32s3"; the value is copied verbatim into the
manifest, and the flash flow matches on the normalized "esp32-s3".

T-Beam BPF and M5Stack Unit C6L both build default_16MB.csv on 16 MB
flash but declared no partition scheme, which leaves the flasher on the
4 MB fallback offsets for a legacy clean install.

Meshnology W10 and W12 gain the artwork and vendor tag that already
exist for them.
2026-08-22 14:34:49 +00:00
Austin 4de20187f5 Actions: Update to trunk-io/trunk-action v2 -- remove annotations (#11563)
trunk-action v2 removed support for PR annotations (they have been broken for a while anyways)
2026-08-21 13:58:58 -04:00
vidplace7 f22ce82f5a fix t-deck-pro: disable BHI260AP support until SensorLib replacement is available
Missed in the previous commit
2026-08-21 12:42:16 -04:00
Austin 5f7077c44e fix t-deck-pro-v1.1: disable BHI260AP support until SensorLib replacement is available (#11562) 2026-08-21 11:25:39 -05:00
Austin 4d9d0f8a16 chore(deps): Correct library dependencies for T-Deck Pro and T-Watch Ultra (#11561) 2026-08-21 10:59:26 -05:00
renovate[bot] 4c640270f2 chore(deps): update lovyangfx to v1.2.27 (#11533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 13:46:14 +00:00
Tom bc035bb812 feat(lora): state a pinned userPrefs preset as the unset region's intent (#11507)
* feat(lora): state a pinned userPrefs preset as the unset region's intent

A vendor build can pin USERPREFS_LORACONFIG_MODEM_PRESET while leaving the
region unset, so a fresh flash comes up as region UNSET plus a deliberate
preset. Stock installs come up as region UNSET plus the LONG_FAST placeholder,
and nothing in FromRadio told the two apart - so clients treat every
unset-region node as factory-fresh and replace its preset with the region
default as soon as the user picks a region. A mesh pinned to SHORT_TURBO loses
every new node to LONG_FAST or LONG_TURBO, silently.

getRegionPresetMap() now emits an UNSET entry when, and only when, the build
pins a preset, stating that preset as both the group's sole entry and its
default. Stock builds are unchanged on the wire: no UNSET entry, which clients
already read as unconstrained.

This is intent, not enforcement. supportsPreset() still accepts any known
preset while the region is unset (#11496) and the radio is held silent either
way, so the device continues to honour whatever the user or an admin sets.

Costs one group slot and one region slot on pinned builds only (6->7 of 8,
34->35 of 38); exhaustion is logged and degrades to the existing unconstrained
behaviour.

* Trim comments to the project's one-to-two-line limit
2026-08-21 10:48:14 +00:00
renovate[bot] 1afcdabbe9 chore(deps): update esp8266audio digest to 3430246 (#11557)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 10:47:19 +00:00
Thomas Göttgens 68bfe015e6 ci: build newly added variants in the PR matrix (#11549)
* ci: build newly added variants in the PR matrix

A new board declares board_level = release, so it gets no CI build until
after merge. Build the first env of each platformio.ini added by a PR,
regardless of board_level.

Only added files qualify; adding an env to an existing config does not.

* ci: also detect added variants in merge_group runs

merge_group uses the same --level pr subset as pull_request, so a newly
added variant was skipped there. Derive the diff base from
github.event.merge_group.base_sha for those runs.

* ci: fail the matrix step when the variant diff errors

Process substitution hides the exit status, so a failed diff silently
yielded an empty list and dropped the new board from the matrix. Capture
into a variable so 'set -e' aborts the step instead.
2026-08-20 14:42:28 +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
90a6dec3f3 fix(NodeDB): require a full 32-byte key when demoting to the warm tier (#11431)
* fix(NodeDB): require a full 32-byte key when demoting to the warm tier

meshtastic_User.public_key is a wire `bytes` field with max_size 32, so any
size in 0..32 decodes off the air, and nothing validates it on ingress:
NodeInfoModule hands the decoded User straight to NodeDB::updateUser, whose
PKI gates are all `== 32` and so fall through for a partial key, and
TypeConversions::CopyUserToNodeInfoLite then stores it with the short size.

demoteOldestHotNodesToWarm() admitted that partial key into the warm tier on
a `size > 0` gate. WarmNodeEntry has no length field - it distinguishes "has
a key" from "no key" purely by all-zero - so N real bytes plus 32-N zeros
become indistinguishable from a genuine key. copyPublicKeyAuthoritative()
then hands that fabricated key back with size = 32 and reports it
AUTHORITATIVE, and re-admission writes size = 32 into the hot store. From
then on updateUser's key pin permanently rejects the node's real NodeInfo,
and DMs to it are encrypted to a key nobody holds.

Require a full 32-byte key, so a partial one is absorbed as "no key"
(nullptr) rather than as a truncated one. WarmNodeStore::place() already
treats a null key as keyless and clears the slot's stale key when
repurposing it. This aligns the site with its two siblings, which both
already gate on `size == 32` (the purge path in cleanupMeshDB and the
runtime eviction in getOrCreateMeshNode).

The ingress gap - updateUser accepting a 1..31-byte key at all - is a
separate, larger change and is left for its own review.

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

* docs(NodeDB): shorten warm-demotion comment to two lines

Repo guideline (AGENTS.md): keep code comments to one or two lines. Retains the
non-obvious invariant - warm entries have no key length field - and drops the
restated detail.

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

* test(NodeDB): cover short-key demotion into the warm tier

A warm record stores 32 raw key bytes with no length field, so a partial
hot-store key is indistinguishable from a real one once demoted. The
public_key.size == 32 gate in demoteOldestHotNodesToWarm() is what keeps a
truncated key from being laundered into a full-looking warm key, but nothing
exercised it.

test_migration_dropsShortKeyOnDemotion overflows the hot store with one node
carrying a 31-byte key and asserts it lands as a keyless placeholder while a
genuine 32-byte key still survives. push() grows a keySize parameter to seed
the partial key, and clearWarm() gives the test an empty warm tier, which it
needs because the warm store outlives setUp() and a prior run's warm.dat.

Verified to discriminate: with the size gate reverted to size > 0 the new
test fails on "a 31-byte key must not be demoted as if it were a full key",
and passes again once restored.

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

* test(NodeDB): assert the keyless placeholder carries last_heard

The test only proved a warm metadata row survived the demotion, not that the
placeholder does the job the nullptr is there for, which is preserving
last_heard when the key is dropped.

Asserting the value needed the seeds fixing first. Warm entries pack role,
protected category and the xeddsa flag into the low 7 bits of last_heard
(WARM_TIME_MASK is 0xFFFFFF80), so warm time has 128 second granularity and the
old seeds of 1, 2, 3 all quantised to 0. They are now multiples of 128, which
keeps the demotion ordering identical and makes the values survive the round
trip. Real last_heard is epoch seconds, so this is closer to production than
the old counter was.

Reads the entry through WarmNodeStore::take() rather than getOrCreateMeshNode(),
which does not restore last_heard from the warm tier and would have been
asserting a path that does not exist.

Reported by CodeRabbit on #11431.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-20 12:19:57 +00:00
renovate[bot] abd3348790 chore(deps): update meshtastic/device-ui digest to 44b86e1 (#11552)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 19:42:23 -05:00
Thomas Göttgens 80f8611e65 feat(variants): add Seeed Wio Tracker L1 Pro 1W (#11542)
* fix(sx126x): allow boards to opt out of the PA optimization table

Boards driving an external PA can define SX126X_NO_POWER_OPTIMIZATION_TABLE
to use the fixed PA config instead of RadioLib's table, which is tuned for a
bare SX126x.

Default behaviour is unchanged. init() applies the fixed config after begin(),
which programs power through the table.

* feat(variants): add Seeed Wio Tracker L1 Pro 1W

nRF52840 + SX1262 with a 1 W external PA, L76K GNSS, SH1106 OLED.

Uses hw_model 144 (meshtastic/protobufs#1038), opts into
SX126X_NO_POWER_OPTIMIZATION_TABLE and declares SX126X_MAX_POWER explicitly.
The PA gain table is indexed by SX1262 output power in dBm.

Requires protobufs#1038 and a protobuf regen before it builds.

* chore(deps): bump RadioLib to 510e00cf

Carries the current LR11x0 and LR2021 fixes.

* fix(variants): correct L1 Pro 1W QSPI pins and clean up comments

PIN_QSPI_* are logical pin indices. The QSPI flash sits at D19-D24 in
variant.cpp, but the defines carried D21-D26 from seeed_solar_node, where
that block does start at D21. D25 and D26 are trackball pins.

Also replaces mis-encoded characters in the pin comments and drops the
migration note, which referenced a private repo path and a stale PINS_COUNT.

* fix(variants): move L1 Pro 1W out of the per-PR build matrix

board_level = pr is the high-attention tier that builds on every PR. This
board belongs with the mainline set, which uses board_level = release.
2026-08-19 17:05:34 +00:00
Thomas Göttgens 9c027a24ea Toggle GPS and buzzer together on the ThinkNode M8 function button double click (#11551)
* Toggle GPS and buzzer together on the ThinkNode M8 function button double click

* Shorten the comments added with the ThinkNode M8 double click toggle

* Only sync the buzzer when the GPS mode actually toggles, and unmute before the tone plays
2026-08-19 15:52:23 +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
Andrew YongandThomas Göttgens 93d15a5368 Add AS3935 lightning sensor support (#10931)
* Add AS3935 lightning sensor support

Implements meshtastic/firmware#10774: an AS3935Sensor (TelemetrySensor
subclass) that reports lightning_strike_count_1h and lightning_distance_km
on the normal environment telemetry interval, like a rain gauge -
strikes are counted over a fixed rolling ~1h window and read
non-destructively, so replying to a peer's telemetry request in
between broadcasts can't silently drop counted strikes.

The AS3935's IRQ pin (opt-in per board via AS3935_IRQ) is polled with a
plain digitalRead() in runOnce(), deliberately not attachInterrupt():
the IRQ line is a level that stays asserted until its interrupt
register is read, so polling can't miss an event regardless of timing,
matching the SparkFun library's own reference examples. An interrupt
would also buy nothing here even setting that aside - classification
requires an I2C read (readInterruptReg(), which itself calls delay(2)
per the datasheet's settle-time requirement), and blocking I2C/delay()
calls aren't safe from ISR context on any of this codebase's target
platforms, so the ISR could only ever set a flag for later draining -
no less work than just polling the pin directly on the next tick.

A genuine lightning classification also requests an immediate
out-of-cycle send via a new EnvironmentTelemetryModule::
requestImmediateSend() hook. There's no fixed debounce on the request
itself - EnvironmentTelemetryModule's existing airtime/duty-cycle gate
already paces every send, so it sends as often as airtime allows rather
than an arbitrary fixed rate. The request does expire after 5 minutes
unfulfilled, so it can't fire an arbitrarily stale broadcast if airtime
was blocked for a long stretch.

The AS3935's I2C addresses (0x01-0x03) fall inside the range this
codebase's I2C scanner otherwise skips as reserved, so detection is a
small dedicated probe gated behind AS3935_IRQ and respecting the
caller's address filter, rather than a change to the general scan
loop. Presence is confirmed via a register write/readback round-trip
rather than a fixed expected value, since the AS3935 has no WHOAMI
register and a power-on-reset-only check can't survive a warm reboot
that doesn't power-cycle the sensor (initDevice() permanently rewrites
that register on first configuration).

Generated files under src/mesh/generated/ are intentionally excluded
from this commit - they're regenerated from the protobufs submodule by
update_protobufs.yml, and hand edits get overwritten and conflict once
the companion protobufs PR merges and the submodule pointer updates.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* fix(as3935): calibration and telemetry logging

initDevice() never called the library's calibrateOsc(). The AS3935's
internal oscillators are calibrated against the antenna's resonance,
which the AFE/watchdog/spike-rejection thresholds depend on; without
it, only a directly-driven IRQ pin (bypassing detection entirely)
reacted during testing.

The sensor could already have a historical detection event latching
the IRQ pin high before our initialization. Added an explicit drain
read after the IRQ pin is configured, so the sensor doesn't start out
stuck asserting IRQ.

EnvironmentTelemetryModule::sendTelemetry() logs every other
environment metric category on send but was missing lightning; added
a matching log line.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Andrew Yong <me@ndoo.sg>

* Support AS3935 without an IRQ line, make the antenna trim configurable

Detection no longer requires AS3935_IRQ. The probe is gated like the
other environmental sensors, so an I2C-only breakout is found on any
board. Where AS3935_IRQ is defined the pin still gates the I2C read,
otherwise runOnce() polls the interrupt register, which latches until
read.

Antenna tuning capacitance moves to
AdminMessage.sensor_config.as3935_config, persisted to /prefs/as3935.dat
and defaulting to 96pF. The chip does not retain it across power loss.

Disturbers are masked in the chip, since runOnce() now polls every
second. The lightning telemetry log is guarded so nodes without the
sensor no longer log it on every send.

Requires meshtastic/protobufs#981.

* Revert protobufs pointer to the develop baseline

The submodule bump conflicts on merge and the generated headers come
from an out of band CI job, so the pointer moves with that job rather
than in this branch.

* Report lightning strikes over a true rolling hour

strikeCountWindow was zeroed on a fixed interval, so
lightning_strike_count_1h reported strikes since the last reset rather
than over the preceding hour.

RollingCounter is a fixed memory sliding window: one counter per bucket,
nothing stored per event, so a storm cannot grow it. The ring holds one
bucket more than the window needs so none is recycled while part of it
is still inside, and the oldest bucket contributes only the fraction
still in range. Both are needed to hold the span at exactly the window
length rather than letting it drift by a bucket either way.

Expiry is exact to one bucket rather than to the event, which is below
the 5 minute floor on mesh telemetry sends.

The distance expires with the last strike in the window instead of on
the interval reset. Covered by test/test_rolling_counter.

* Widen the RollingCounter edge weighting to 64 bit

counts * inWindow is a 32 bit product, so a bucket holding more than
2^32 / BucketMs events wraps. At a 5 minute width that is about 14k: a
bucket of 50000 reported 11367 instead of 40000 once it reached the
window edge.

Below the threshold nothing changes, so lightning was unaffected, but
the helper is meant to be reused by counters with far higher rates.

test_large_burst_at_window_edge covers it. The existing burst test
sampled only inside the window, where the bucket is whole and never
weighted.

* Trim RollingCounter comments to the house limit

---------

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-19 10:17:02 +00:00
Ben MeadorsandClaude Fable 5 74119c088b fix(mesh): don't reference the position module on MESHTASTIC_EXCLUDE_GPS builds
The event-channel position-request reply added in #11545 calls positionModule->
replyOnPositionChannel() guarded only by USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL.
Targets that set MESHTASTIC_EXCLUDE_GPS (repeaters such as
rak_wismesh_repeater_mini_hp) never construct PositionModule in Modules.cpp, so an
event build for one of those fails to link:

  undefined reference to `PositionModule::replyOnPositionChannel(...)'
  undefined reference to `positionModule'

Guard the call, the include and the isEventChannelPositionRequestForUs() helper with
!MESHTASTIC_EXCLUDE_GPS, matching how AdminModule guards its positionModule use. A
node with no position module has nothing to answer a position request with, so
skipping the reply is the correct behavior there.

Not reachable on develop, where the userpref defaults off and the whole block
compiles out - it only breaks builds that enable it, which is why #11545 was green.
Verified by building rak_wismesh_repeater_mini_hp with the pref enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 20:44:48 -05:00
Ben Meadors 9fcb289643 fix(thinknode_m9): define SPI_FREQUENCY for the non-MUI build (#11546)
The M9's variant.h defines ST7789_CS, so TFTDisplay.cpp compiles its
ST7789 LGFX branch, which reads SPI_FREQUENCY for the panel write clock
(SPI_READ_FREQUENCY, its pair, is already in variant.h). The flag was only
set in the -tft env, so `build (thinknode_m9, esp32s3)` has failed on
develop since the board landed in #10908:

  src/graphics/TFTDisplay.cpp:504:30: error: 'SPI_FREQUENCY' was not
  declared in this scope; did you mean 'SD_SPI_FREQUENCY'?

Move the flag up into thinknode_m9_base, keeping the 75 MHz the -tft env
already used for the same panel and matching the SD card's 75 MHz on the
bus they share. The -tft env inherits the base flags, so device-ui's
LGFX_GENERIC.h - which falls back to 20 MHz when the macro is absent -
still sees the identical value.
2026-08-18 20:28:01 -05:00
Ben MeadorsandClaude Fable 5 a5fc95f774 fix(mesh): coerce coordinate traffic to the position channel on event builds (#11545)
Under USERPREFS_BLOCK_POSITION_ON_EVENT_CHANNEL every coordinate packet a
client aimed at the event channel was rejected with the "Location sharing is
disabled on this channel" notification - including the phone's own location
feed. Both apps hand a GPS-less node its fix as a POSITION_APP packet
addressed to the node itself on channel 0; that packet never leaves the
device (Router::sendLocal delivers it locally) but resolved to the event
channel and was dropped before PositionModule saw it. Result: the toast on
every location tick, and nodes without a GPS never learned a position to
share on their private channel.

Position traffic now converges on the position channel - findPositionChannel(),
the first channel with non-zero on-wire precision, which is never the event
channel:

- From-us-to-us coordinate packets are exempt from the event block.
- Local coordinate sends aimed at the event channel (phone share-location,
  request-position, waypoints, any module/UI originator) are moved onto the
  position channel in Router::sendLocal and PhoneAPI instead of rejected. The
  client notification is only sent when no channel carries positions at all.
- A position request DM'd to us on the event channel is answered on the
  position channel at that channel's precision (request_id preserved, same
  reply throttle); the requester's coordinates are still not stored,
  forwarded, relayed or published. want_response from the bitfield is merged
  before the event-channel decode short-circuit so such requests are seen.
- PositionModule::sendOurPosition, positionUnchangedSinceLastSend and
  MeshService::trySendPosition use the shared helper instead of three copies
  of the same walk.

Non-event builds are unaffected: the coercion compiles out and the helper
matches the previous walk.

Tests: coverage-event-policy (test_event_channel_phone_api,
test_event_channel_router, test_position_precision, test_mqtt,
test_nexthop_routing) and the same suites with the policy off.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 21:58:51 +00:00
Ixitxachitl 8fe246e250 fix(mesh): relay foreign packets whose channel hash collides with a local channel (#11544)
* fix(mesh): relay foreign packets whose channel hash collides with a local channel

The channel hash is one byte, so a foreign channel's name/PSK can fold to the
same hash as a local channel (~1/256 per local channel held). Since d6b12ea3f,
perhapsDecode returns DECODE_FAILURE whenever any local channel matched the
hash, and passesRoutingAuthGate turned that into REJECT - silently blackholing
legitimate foreign traffic that master and 2.7.x relay. A node with a wrong PSK
for a channel name stopped relaying the real channel entirely.

Channel crypto (AES-CTR) has no authentication tag, so "wrong key, foreign
channel" and "our channel, tampered payload" are indistinguishable at this
decision point. The strict drop bought nothing: an attacker picks a hash
matching no local channel and gets DECODE_OPAQUE relay anyway (test_C6), so
the rule only suppressed honest colliding traffic.

Return OPAQUE_RELAY_ONLY on DECODE_FAILURE unless the packet is addressed to
us or claims to be from us. isFromUs stays REJECT because OPAQUE_RELAY_ONLY
reaches perhapsGenerateImplicitAckForOwnOverheard, which matches pending sends
on header bytes alone - a forged sender with a colliding hash and matching id
could otherwise fake-ACK a DM and cancel its retransmissions. Other
DECODE_FAILURE sources are unaffected: legacy-DM rejection, pending-key
refusal, and failed PKI candidates are all isToUs, and the KNOWN_ONLY early
return is re-gated by relayOpaquePacket's own mode check. Opaque frames still
never touch PacketHistory, NodeDB, modules, MQTT, ACKs, or the phone.

test_C12's collision leg now expects OPAQUE_RELAY_ONLY (its tampered packet is
a broadcast - byte-identical to the foreign case); it still pins per-exact-byte
cache reevaluation. test_C9 renamed to match what it now verifies. New test_C17
covers the colliding-hash foreign broadcast and the spoofed-sender REJECT.

* style(mesh): trim collision-relay comment to two lines
2026-08-18 21:12:44 +00:00
Ben Meadors 48699a7a48 fix(http): keep reaping open TLS connections under low heap so the heap can recover (#11539)
* fix(http): keep reaping open TLS connections under low heap so the heap can recover

Once free heap dropped below MIN_HEAP_FOR_SSL (40 KB) with HTTPS connections
open, the node's heap never came back and every later HTTPS or TCP-API
connection failed until a reset - node alive, on WiFi, unusable.

handleWebResponse() skipped secureServer->loop() entirely under low heap so no
new TLS handshake would be attempted on a heap that can't hold its context.
But HTTPServer::loop() is the only place already-accepted connections are
serviced and reaped: its first pass calls ->loop() on each open one (where the
20 s idle timeout and the SSL close-notify state machine run) and deletes the
closed ones. Skipping the whole loop froze the up-to-MAX_HTTPS_CONNECTIONS TLS
sessions already open. Never looped, they never timed out, their mbedTLS
contexts and pbufs were never freed, so free heap never climbed back over
40 KB, so the loop was skipped forever. The guard's own precondition was what
kept it from clearing.

Split the two halves. Under low heap keep driving and reaping the connections
we already hold, and only skip the accept. HTTPServer keeps its connection
table protected, so a thin MeshHTTPSServer subclass exposes
serviceExistingConnections(), the first half of HTTPServer::loop() verbatim.
Log line reworded to say what now happens: not accepting, not skipping.

Verified on a Heltec V3 (Endor AP) against a control build with #11537 (so the
node survives the squeeze instead of aborting first):

- Recipe: held sockets on 80/4403 + pending TLS, 100 s of HTTPS pokes, repeat.
  Control: Low heap pins at 6-17 KB, HTTPS dead, and 3 min after all pressure
  is released heap is still ~12 KB with Low heap firing every 30 s - permanent
  until reset. Fix: never dips under 40 KB, both pressure rounds 3/3, 65 KB
  after.
- Branch driven deliberately (verify-only heap hog pinning free heap at ~28 KB
  with a real idle TLS session held open): under the guard the fix logs
  open=1 -> reaped=1 at the 20 s idle timeout, and heap goes 26 -> 65 KB
  before the hog is even released. On the control logic that session stays
  frozen for the whole window.

Fixes #11538.

* fix(http): trim the low-heap comments to the two-line guideline

The mechanism is in the commit message and PR; the source keeps the one-line
why. No code change. (CodeRabbit)
2026-08-18 20:38:20 +00:00
github-actions[bot]andcaveman99 692adc8131 Update protobufs (#11543)
Co-authored-by: caveman99 <25002+caveman99@users.noreply.github.com>
2026-08-18 22:02:32 +02:00
Ben Meadors fe15786dc1 fix(api): stop rebooting ESP32 nodes when a client connects to a fragmented heap (#11537)
* fix(api): stop rebooting ESP32 nodes when a client connects to a fragmented heap

Connecting a client to an ESP32 node over WiFi/TCP rebooted the node. Two
allocations on the accept + config path use operator new, and on ESP32 that is
fatal when it fails: the framework builds with CONFIG_COMPILER_CXX_EXCEPTIONS=n
(esp32-common.ini), and ESP-IDF's cxx component then --wraps __cxa_throw and
every unwinder entry point straight to abort(). libstdc++'s operator new throws
std::bad_alloc on a NULL from malloc, so any new that cannot get its block is a
reboot with no chance to recover. Both hit on a Meshnology W12 running develop
c308d0a (no PSRAM detected, WiFi + HTTPS + TLS up, ~83 KB free heap,
fragmented):

1. PhoneAPI::handleStartConfig -> getFiles() -> filenames.reserve(64)
   64 * sizeof(meshtastic_FileInfo) = 14,848 B contiguous, requested with the
   SPI lock held, on the very first client handshake. The try/catch around
   it (from #10778) is dead code on this platform for the reason above.

       abort() was called at PC 0x4216f733 on core 1
       __cxa_throw / operator new
       std::vector<_meshtastic_FileInfo>::reserve   (getFiles, FSCommon.cpp:275)
       PhoneAPI::handleStartConfig                  (PhoneAPI.cpp:325)
       StreamAPI::readStream / ServerAPI<NetworkClient>::runOnce

2. APIServerPort::runOnce -> openAPI.reset(new T(client))
   sizeof(WiFiServerAPI) is 4,512 B (stream rx/tx buffers + FromRadio/ToRadio
   scratch). Under a little more pressure - a few TCP sockets held open on
   80/4403 plus pending TLS handshakes - the accept itself aborts, before the
   manifest is ever reached:

       abort() was called at PC 0x4216f66b on core 1
       __cxa_throw / operator new
       APIServerPort<WiFiServerAPI, NetworkServer>::runOnce (ServerAPI.cpp:120)

new (std::nothrow) is not the answer on this platform. libstdc++ implements it
as `try { return operator new(sz); } catch (...) { return nullptr; }`
(new_opnt.cc:39; objdump shows call8 to the throwing form then
__cxa_begin_catch), so with the unwinder wrapped to abort() it aborts one frame
deeper - verified by decoding exactly that. malloc() does return NULL here
(HEAP_ABORT_WHEN_ALLOCATION_FAILS is off), so both fixes go through it:

- getFiles(): size the reservation to what the allocator can actually give,
  and never let reserve() be the thing that finds out there is no room. On
  ESP32 ask heap_caps_get_largest_free_block(MALLOC_CAP_DEFAULT) - the
  capability heap_caps_malloc_default() (what new resolves to) falls back to
  across every region - less a 1 KB margin, divided by sizeof(FileInfo).
  Nothing is freed before the reserve, so no hole to lose to another task.
  Elsewhere, probe with malloc() and halve until it fits. The walk is capped
  at the reserved count so push_back() never grows the vector, and wasLimited
  reports the truncation exactly as it did for the 64-entry cap. The manifest
  degrades to fewer entries; the handshake completes.

- APIServerPort::runOnce(): take the ServerAPI's block from malloc(), construct
  it in place, and hold it in a unique_ptr whose deleter runs ~T() and free()s.
  If there is no room, log and drop that client instead of the node; it
  retries and the next accept gets a fresh look at the heap. The
  ServerAPI/PhoneAPI/OSThread constructors do not allocate (default-constructed
  containers, fixed-size thread table), so nothing inside the placement new can
  throw either. malloc()'s alignment is the one operator new gives (it calls
  malloc), so the object is well-formed.

Also: the two manifest LOG lines used %zu, which newlib-nano's vsnprintf on
ESP32 does not know - they printed "Got zu files in manifest". Cast to unsigned
like the rest of the file.

Not in this PR, flagged for discussion: every other operator new / container
growth in the image has the same failure mode on ESP32, and so does every
try/catch in firmware source. A project-wide nothrow global operator new
(returning nullptr per the platform's own -fno-exceptions contract) would close
the class, but it changes semantics for every library in the image and moves
the failure from a clean abort-with-backtrace at the alloc site to whatever the
caller does with a nullptr. That is a policy call, not a bug fix.

Verified on the W12 (Endor AP): before, the first TCP-API connect aborts;
after, 6/6 connects complete full config sends, 3/3 under held-socket + TLS
pressure, node never reboots. Both degraded branches driven deliberately with a
verify-only heap starvation build: largest block pinned at 7.4 KB gives
"reserved=27 of 64 ... (limited to 64 entries/depth 3)" and the handshake runs;
pinned at 2.8 KB gives "No heap for API connection (4512 bytes), dropping
client" three times with no reboot, where the std::nothrow version aborted
three times. test_fscommon_getfiles 8/8 on native-macos; full native suite
green in Docker.

* fix(api): cap the manifest probe count; suppress cppcheck's placement-new memleak

- getFiles(): cap reservedCount at filenames.max_size() before the byte-count
  multiply in the portable probe. A huge maxCount could wrap
  reservedCount * sizeof(FileInfo), let malloc() succeed on the wrapped size,
  and then hand reserve() the original count - a length_error, which on ESP32
  is the abort this change exists to remove. max_size() is also exactly the
  bound reserve() would reject, so one comparison covers both. (CodeRabbit)

- APIServerPort::runOnce(): cppcheck 2.20 reports "Memory leak: block" at the
  end of the accept scope because it does not model ownership passing through
  placement new into openAPI (MallocDeleter frees it). Inline-suppress with the
  reason, per the tree's convention. pio check -e rak3172 goes FAILED -> PASSED;
  every check job in CI was red on only this finding while all builds passed.
2026-08-18 18:15:02 +00:00
github-actions[bot]andvidplace7 ee401242aa Update protobufs (#11536)
Co-authored-by: vidplace7 <1779290+vidplace7@users.noreply.github.com>
2026-08-18 08:32:39 -05:00