* nRF52, RP2xx0: drain the USB console before an assert reset
Serial.flush() on both TinyUSB cores only moves the next packet into the
endpoint buffer and returns, so the reset that follows an assert discards
the rest of the CDC TX FIFO and the packet still waiting for the host.
Field logs end mid-line ("assert failed src/platfor"), losing the file
and line.
Wait, bounded to 250 ms, for the FIFO to drain, then 20 ms for the host
to collect the last packet. Skipped with no host connected and where the
USB task can't run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* nRF52: say more when an assert resets
The assert line alone can't tell an out-of-memory crash apart from heap
corruption or a stack overflow, and for a failed operator new it always
names alloc.cpp.
- After the assert line, log the FreeRTOS task (the log's [thread] tag is
the last OSThread to run), its stack high-water mark, free heap and the
largest free block, or that the free list failed a bounds-checked walk.
- A failed operator new logs the requested size and the caller's address
first, for addr2line.
- MemGet::getMaxAllocHeap() walks newlib-nano's free list on nRF52, so the
periodic heap line reports the largest block there. That line no longer
prints "min 0" where the minimum isn't tracked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Keep out-of-memory logging from allocating or recursing
RedirectablePrint::log() heap-allocated a copy of every format string to
append the newline, so with the heap exhausted the log calls in a failed
operator new and in the assert handler failed their own allocation and
recursed. Copy usual-length formats on the stack instead (96 B; some tasks
have well under 1 KB of stack free) and only use the heap for long ones.
Guard logAllocFailure() and __assert_func() against re-entry for the paths
that still allocate (long formats, BLE).
getMaxAllocHeap() on nRF52 now reports what malloc() can return (chunk
size less 8 B of header and alignment), and 0 if the free-list walk
fails rather than a partial figure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* nRF52: drain before a nested assert reset; bound the heap walk by heap size
A nested assert reset at once, dropping the outer assert's lines still
in the USB FIFO; drain first (the drain neither logs nor allocates).
The free-list walk treated more than 2048 chunks as corruption, which a
valid but badly fragmented heap could reach. Bound it by the most chunks
the heap can hold at the 8 B minimum, so passing the bound means a loop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* nRF52: treat free-list chunks under newlib-nano's minimum as corruption
newlib-nano never frees a chunk smaller than MALLOC_MINCHUNK (12 B on
32-bit), so a smaller size in the list means it is damaged. Reject those,
and derive the walk's chunk bound from the same minimum.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* nRF52: compare heap addresses as integers in the free-list walk
__HeapBase and __HeapLimit are distinct linker objects, so subtracting
them as pointers is undefined, and cppcheck's high-severity
subtractPointers check failed the static-analysis jobs. Do the bounds
arithmetic on uintptr_t, as the STM32 sbrk headroom code already does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The 'Add sources' and 'Add bins and debug elfs' steps only ran when the
dispatch targeted the default branch, so a release run from a maintenance
branch created an empty draft release. Gate them on workflow_dispatch so
any manually dispatched release run uploads its assets.
Claude-Session: https://claude.ai/code/session_011C7D9eV9iMt7VkhQcLo961
Co-authored-by: Claude <noreply@anthropic.com>
* NodeDB: encode satellite maps in place when saving
saveNodeDatabaseToDisk() copied each satellite map into nodeDatabase's
vectors just to encode them, so every save needed a second, contiguous
copy: up to ~19 KB at the nRF52 cap of 40, 40 x 312 B for environment
alone. On busy meshes this ran nRF52 sensor nodes out of heap
(new(10920) failed in saveNodeDatabaseToDisk()).
Mirror the decode path: arm per-map encode sources for the duration of
the save, and have the NodeDatabase encode callback walk the map, one
stack entry at a time. Same entries in the same order, so nodes.proto
is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* NodeDB: clear satellite vectors before encoding the save
With a satellite DB compiled out, nodes.proto's entries for it still
decode into nodeDatabase's vector. The projection used to clear those
vectors before encoding; without that, the first save after boot wrote
the stale entries back. Clear all four before arming the encode sources;
an armed source ignores its vector anyway.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* MeshBeacon: wait out the remaining interval after a reboot
When the transmit history shows a beacon inside the interval, runOnce now sleeps until that interval ends instead of a full interval from boot. Saving mesh_beacon reschedules the broadcaster, so enabling broadcast or changing the interval takes effect within the broadcast start delay rather than on the old period.
fixes#12273
* MeshBeacon: retry an airtime-held beacon within the hour
A due beacon held by the air-util limit now rechecks after 5 minutes, doubling to a 60 minute cap, instead of waiting a full interval. The history check runs first, so a beacon that is not due no longer logs a skipped send.
A reschedule requested while runOnce runs, as from the nRF52 BLE write callback, is now kept rather than overwritten by runOnce's return. Declare the transmit_history.dat write in the state manifest.
InputBrokerDebug (which gated LOG_INPUT) and INPUT_DEBUG (raw #ifdef
blocks in ButtonThread and TrackballInterruptBase) were two switches for
the same thing. LOG_INPUT now lives in DebugConfiguration.h next to
LOG_DEBUG_RADIO and is enabled with -DINPUT_DEBUG; InputBrokerDebug is
gone.
Per-event input logs that previously ran unconditionally now go through
LOG_INPUT: button double/multi/long-release, rotary encoder and TCA6408
turns and presses, touch taps/swipes/long-press, MPR121, Hackaday,
matrix and RAK14004 keypad keys, and T5S3 touch coordinates. Several of
these logged typed keystrokes on every build. Init, warning and state
change logs (mute, backlight, interrupts attached) are unchanged.
The ButtonThread "held" line drops from WARN to DEBUG with the rest.
Claude-Session: https://claude.ai/code/session_011C7D9f2qD7EhCiM5P74SiV
Co-authored-by: Claude <noreply@anthropic.com>
* Leave TCXO powered during standby
To avoid its startup delay
* Fix cppcheck warning
* Use dedicated result variable for standbyXOSC call
---------
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
* Report the BLE address in DeviceMetadata.ble_address
Fills the field added in meshtastic/protobufs#1159 with the address the
BLE stack advertises on, so a client can read it over USB instead of
scanning for it (e.g. to print a connect QR code for a fixed-PIN node).
- getBluetoothAddr() joins getMacAddr()/getDeviceId() as a per-arch hook.
- ESP32: esp_read_mac(ESP_MAC_BT), the controller's public address that
NimBLE uses; it differs from the base MAC. None on ESP32-S2.
- nRF52: sd_ble_gap_addr_get() while the SoftDevice runs, else the FICR
identity address getMacAddr() already derives.
- nRF54, RP2xx0, STM32WL, native: no address yet; the field stays empty.
- formatMacAddr() renders "AA:BB:CC:DD:EE:FF".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9ehe42gSGx3n1gXFJw
* test(ble_address): run under the native test harness
Include TestUtil.h and call initializeTestEnvironment(), as test/README.md
requires: the suite calls getDeviceMetadata(), so it is not one of the
pure-function suites that skip the harness, and its RUN_TEST then gets
the per-test environment and state checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9ehe42gSGx3n1gXFJw
---------
Co-authored-by: Claude <noreply@anthropic.com>
AdminModule treats from == 0 as a local client, skipping the admin_key and
session-passkey checks. That trust rested entirely on every ingress path
dropping from == 0 (RadioLibInterface, MQTT downlink, UDP multicast and
frame injection all do). Make the handler check it itself:
- MeshModule gains a static currentRequestSource, set by callModules()
alongside currentRequest, so modules can see the RxSource of the packet
they are handling.
- AdminModule drops a from == 0 payload unless callModules() is dispatching
that exact packet with RX_SRC_USER or RX_SRC_LOCAL. It fails closed for a
radio source, for no dispatch context, and for a context that belongs to
another packet. The drop is silent (no NAK to a forged sender).
Tests: AdminModuleTestShim::handleReceivedProtobuf now runs the handler
inside a dispatch context (default RX_SRC_USER, the phone client existing
suites stand in for), plus handleWithDispatchContext() for the negative
cases. test_admin_session_repro adds six cases: from == 0 via RX_SRC_RADIO,
with no context, and with another packet's context are rejected, both
directly and through MeshModule::callModules(); the phone-client controls
still apply the setter. With the gate removed, the four rejection cases fail.
Claude-Session: https://claude.ai/code/session_01Y1BYe4ahUHxyhw3nGTX67D
Co-authored-by: Claude <noreply@anthropic.com>
test_radiolib_drivers has been red on develop since the RadioLib pin moved
to 5878f990 (#12277): the three lr20x0_interface_tests cases added by #12036
all fail at the same assertion with -12, RADIOLIB_ERR_INVALID_FREQUENCY.
Nothing in the firmware is wrong. At the new pin LR2021::setLoRaModulationParams()
ends in setDCDCworkaround() (RadioLib#1864), whose last act is
setFrequency(this->freqMHz, true). The suite builds the interface without
calling init(), so begin() never runs and freqMHz is still 0, and the range
check in setFrequency() rejects it. Every setter that reaches the modulation
params now needs a frequency first, where before the jump it did not.
lr2021_tests.h already handles this: LR2021_RADIO() calls lr2021Tune() for
exactly this reason. The interface suite builds its radio by hand and skipped
that step, so give it the same tune before it writes the SF.
Claude-Session: https://claude.ai/code/session_011C7D9biGfpxLruq7DFLzsP
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
develop pinned 510e00cf, a commit between the 7.7.1 and 7.8.0 tags and 375 behind
RadioLib master. It is where the 7.8.0 bump landed back after being reverted three
hours later on 2026-09-28 (11eebcb5, then 8e6a88d0): 7.8.0 carried two buffer
overflows, and 7.8.1 is 7.8.0 plus exactly the fixes for them - jgromes/RadioLib#1880
(a sizeof(uint32_t) where a count of 32-bit words was meant, overrunning a stack local
by 12 bytes on every setRxPath()) and a writeCommon() buffer-size fix. So 7.8.1 is the
first safe revision past the old pin, and this moves past it.
5878f990 is the jgromes/RadioLib#1895 merge. No tag carries it - the newest tag, 7.8.1
(2325a38a), is already 353 commits behind master - so the pin stays a commit hash, as
it was before.
What the 63-commit jump brings that the tree is waiting on:
- LR2021 setRegulatorDCDC()/setRegulatorLDO() and RadioLib's own LR2021 DC-DC
sensitivity workaround (#1864), which arrived in 7.8.0.
- setStandbyXOSC() on the SX128x, LR11x0, LR2021 and STM32WLx (#1884). The SX126x
call already compiled, from #1827, long before the old pin.
- LR2021 CAD exit modes (#1882), so the local CAD_EXIT_MODE_RX constant can go.
- writeTxBuffer()/clearTxBuffer() on every driver, with stageMode(TX) no longer
writing the buffer (#1890). TX is staged by hand here, so nothing regresses.
- getMode() on PhysicalLayer and both LR classes (#1895), which replaces reading the
chip mode through a spiConfig status-width swap.
- The LRxxxx startReceiveDutyCycle overflow fix (#1887), the SX128x blocking-receive
timeout fix (#1892), the LRxxxx getStatus read-offset fix,
RADIOLIB_LR11X0_STANDBY_XOSC 0x00 -> 0x01, and the SX128x status framing change
(#1872), which the driver tests already set spiConfig.statusPos against.
Master head is deliberately not the pin. RadioLib merged its 8.0.0-staging branch into
master on 2026-10-28 (#1867), and the 312 commits since #1895 rename
ChannelScanConfig_t's cad.timeout to rxTimeout and add cad.rxSymbols, remove
LR11x0::getFrequencyError() without a PhysicalLayer fallback, and rework the output
power offset path across six drivers. The first two do not compile here, and the third
sits directly under setOutputPower() and limitPower(). Worth revisiting once 8.0.0
settles: the LR11x0/LR2021 RF switch table restore after a modem switch, and the
Module BUSY timeout millis() wrap fix.
Every lora.<method>() call site in src/mesh/ resolves at this revision. readChipMode()
and readTxFifoLevel() replicate getStatus by hand rather than calling it, so the
upstream getStatus offset fix changes nothing here.
Claude-Session: https://claude.ai/code/session_011C7D9biGfpxLruq7DFLzsP
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* test: pin the CAD commands and LR20x0Interface's busy check against a modelled chip
The LR2021 refuses SetLoraCadParams exit mode 0x02, the value of RadioLib's
RADIOLIB_LR2021_CAD_EXIT_MODE_RX, with CMD_PERR (-706), and
LR20x0Interface::isChannelActive() reads the error as a free channel: on the
bench the node transmitted over live frames (16/16), and a Mesh-Tracker X1
gave no busy verdict in 539 scans.
RecordingHal gains deferred replies N transactions out (the IRQ status read
has no opcode), a rejectUnless() chip model, and an IRQ pin that reads high so
scanChannel() returns. lr2021_tests.h and lr11x0_tests.h pin the CAD command
bytes, the exit-mode defines, and that a refused command returns an error and
starts no CAD. lr20x0_interface_tests.h runs the real isChannelActive()
against the modelled chip: a detected preamble must read busy, and sending
0x02 fails both of those tests (checked by mutating LR20x0Interface.cpp).
* test: bring RecordingHal and suite-order comments in line with the code
digitalRead() also models the IRQ line now, a deferred reply has a stated
precedence when two come due on one transaction, and the LR20x0Interface set
runs after the LR2021 set for the same DC-DC reason.
* test: three small corrections to the CAD test comments and macros
The RADIO macros still passed `true` for reply()'s fourth argument, which this branch turned
from a bool into `uint8_t after`, a count of transactions to defer by. It converts to 1, so
nothing changed, but 1 and 2 now mean different things - lr2021AnswerIrq() passes 2 - so the
call sites should not read as a bool.
The comments described the refused exit byte as something develop does. It does not: develop's
isChannelActive() sends 0x01 from its own local CAD_EXIT_MODE_RX, and these tests are what keep
it there. Reworded in test_main.cpp, lr2021_tests.h and lr20x0_interface_tests.h so a later
reader does not go looking for a live bug.
test_lr2021_cad_irq_routed_to_dio's comment claimed DIO5 is "the line the interface's ISR
watches". 5 is LR2021::irqDioNum's in-class default in RadioLib; the firmware assigns irqDioNum
in LR20x0Interface::init() from LR2021_IRQ_DIO_NUM, IRQ_DIO_NUM or portduino_config.irq_dio_num,
and falls back to RadioLib's default only when the variant defines neither. The assertion is
worth keeping - it just pins RadioLib's default rather than the firmware's choice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9biGfpxLruq7DFLzsP
---------
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* adapt to device-ui #446: storage (FATFS) and SD bus class
* Refactor chip variable assignment in ffconf_paths
Refactor chip variable assignment to improve clarity.
* Update extra_scripts/esp32_fatfs_exfat.py
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Pin device-ui to master a5b80d4 and fix black formatting
a5b80d4 is the current device-ui master head and already contains the
storage refactor (device-ui #446) this PR adapts to, so this PR now
supersedes the Renovate bump in #12058.
Re-wrap the chip lookup in esp32_fatfs_exfat.py as black expects, which
was failing Trunk Check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011C7D9dVfgrXSj2xHzyerZi
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
* Router: drop received packets that carry our own node number
Every packet we transmit is recorded in PacketHistory when it is sent, so
an echo heard back off the mesh is consumed by shouldFilterReceived():
the implicit ACK fires and retransmissions stop. A LoRa frame with our
number that the filter does not recognise (one of ours that has aged out
of PacketHistory, or that was sent before a reboot) was then processed as
new inbound traffic: dispatched to modules and, when addressed to us,
forwarded to the client as a fresh packet. MQTT and UDP ingress already
drop from == us.
Drop such packets in Router::perhapsHandleReceived() right after
shouldFilterReceived(), so recognised echoes keep their implicit ACK and
retransmission cancel.
test_own_number_ingress pushes frames through the real ingress path: an
unrecognised own-number frame to us and an unrecognised own-number
broadcast reach neither modules nor the phone; a frame from another node
still does; and a recognised echo of our own want_ack broadcast still
yields the implicit ACK and clears the pending retransmission. Without the
drop the two own-number cases fail; with the drop moved ahead of the
filter the echo case fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1BYe4ahUHxyhw3nGTX67D
* FloodingRouter: skip dupe reprocessing for our own packets
The first copy of an unrecognised own-number frame enters PacketHistory
before Router::perhapsHandleReceived() drops it, so a second copy takes
the dupe path. With hop_start == hop_limit that path reprocesses it as an
originator retry (traceroute handling, and an ACK for a want_ack frame to
us), and an "upgraded" copy with more hops removes our own queued
transmission from the TX queue.
Neither applies to our own packets: we never relay them, and their
implicit ACK is generated earlier in ReliableRouter::shouldFilterReceived.
reprocessPacket() now returns false for them, and
perhapsHandleUpgradedPacket() leaves them alone, so the upgraded copy
falls through to the own-number drop.
test_own_number_ingress adds a repeated want_ack own-number frame (must
not be ACKed) and an upgraded own-number copy (our queued packet must stay
queued). Both fail without the guards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1BYe4ahUHxyhw3nGTX67D
* FloodingRouter: consume upgraded copies of our own packets
PacketHistory reports an upgraded copy as seen, so when
perhapsHandleUpgradedPacket() returned false for an own-number copy it
fell through to the ordinary dupe handling. With hop_start > hop_limit
that reached perhapsCancelDupe(), which on a client cancels our own queued
transmission. Return true for own-number copies instead, so they are
consumed without touching the TX queue.
test_own_number_ingress adds the hop_start > hop_limit upgrade case
(our queued packet must stay queued); it fails without this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1BYe4ahUHxyhw3nGTX67D
* test_own_number_ingress: follow the implicit ACK to the phone
The echo case only counted the ACK request through the harness's stub
sendAckNak(). AuthPipelineRoutingModule gains a deliverAcks switch that
also runs the real RoutingModule::sendAckNak(), so the ACK travels
Router::sendLocal() -> RoutingModule -> MeshService to the phone queue.
- The broadcast echo case now requires exactly one frame on the phone: a
ROUTING_APP success ACK carrying the original request_id.
- New case: an opaque echo of our own want_ack PKI DM (unknown channel
hash, OPAQUE_RELAY_ONLY) must cancel the retransmission and deliver the
same ACK.
Filtering our number at phone delivery (as in #11487) fails both;
skipping the opaque implicit ACK (as in #11502) fails the PKI DM case.
The switch is off by default and cleared on reset, so
test_rebroadcast_mode is unaffected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y1BYe4ahUHxyhw3nGTX67D
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
P2, the fast GPIO port, has no GPIOTE instance on the nRF54L15 (only P0 -> GPIOTE30 and
P1 -> GPIOTE20 can raise pin interrupts), so DIO1 on P2.06 never interrupted and the radio
ran on the driver's polling fallback, dropping back-to-back packets and retrying every DM.
Route DIO1 to P0.00 (a free P1 pin would work as well). Every port runs at the DK's VDD,
which must be set to 3.3 V in the Board Configurator for the E22's 3.3 V logic (it is 1.8 V
out of the box), and P0.00 must be released from the interface MCU's VCOM UART there too.
Verified on the DK + E22-900M30S: ACKs on the first transmission, no missed-IRQ polling.
The core's Uart::begin() and SPIClass::begin() select the pins on the peripheral but leave
the pads at reset: the UARTE TXD pad stays an input and never drives the pin, and the
RXD / MISO pads keep their input buffer disconnected, so the SPIM read back junk. Configure
them in initVariant() until the core does.
* fix(eink): let UP/DOWN scroll the message screen
The e-ink branch of the message screen set scrollY = 0 on every redraw to turn off
autoscroll, which also discarded the position UP/DOWN had just set. It now resets
only when the user hasn't scrolled.
EInkDynamicDisplay::hashImage() XOR-ed buffer[b] << b: from byte 32 on the shift is
undefined, and 0 on ARM, so only the first 32 bytes counted and a scrolled message
list hashed like the previous frame, so the update was skipped. It is now FNV-1a over
every byte (as the Radioddity fork does on the Nodara).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(eink): encoder scrolling and the whole buffer in the frame hash
nudgeScroll() (rotary encoder, e.g. Mini ePaper S3) sets manualScrolling, so
the next e-ink redraw keeps the position. hashImage() covers displayBufferSize
bytes: on 250x122 panels (Heltec Wireless Paper, Vision Master E213) it missed
the last 218 of 4,000 bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Only enable manualScrolling on e-ink
---------
Co-authored-by: Kongduino <Kongduino@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
On the W12 (schematic W12-MB-V0.2, July 2026) the sub-GHz GC1109 was
never turned on, so every packet left through a shut-down, unpowered
front end about 50 dB below the board's working output.
- Its LDO is enabled by IO4 (PA_EN_M), whose pull-up is not fitted:
drive it as LR2021_POWER_EN.
- Its CTX/CPS/CSD pins are LR2021 DIO9/10/11, not hardwired: add an
RF-switch table (LR2021_DIO_AS_RF_SWITCH, rfswitch.h).
- Hold IO3 (PA_EN_G, 2.4 GHz front end) low instead of floating.
- Add the GC1109 TX gain curve (same front end as the Heltec V4.2), so
the working PA stays within the configured and regional power.
- Correct the comments that called the enables pulled high, swapped
IO3/IO4, and said RF switching was hardwired.
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
* STM32WL: seed random() from the on-chip RNG at boot
The STM32WL port never called randomSeed(), so newlib's random() started from
the same state on every boot and anything drawn from it repeated after a
reset. On a bench, a DMShell client reused its session id across reboots and
collided with the previous, still-open session on the server.
HardwareRNG::fill() gains an STM32WL branch that reads the on-chip TRNG,
clocked from MSI for the duration of the read (every STM32WL variant runs
SYSCLK from MSI). Before this, STM32WL fell through to std::random_device.
stm32wlSetup() now seeds random() from it on every board, folding in the
chip UID and boot timer, and starts the LSE as before where HAS_LSE is set.
Costs 524-800 bytes of flash per STM32WL env.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* HardwareRNG: log an STM32WL RNG failure where it happens
cppcheck flagged the warning's if (!filled) as a duplicate of the fallback
condition right after it (duplicateCondition).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* feat(beacon): per-target and offered frequency slots
Implements the slot fields from meshtastic/protobufs#1059 on top of the
broadcast_targets scheme from #11646:
- BroadcastTarget.frequency_slot pins a target's slot. Unset derives it the
way a node on that channel would: the target region's override slot, else
the hash of the target channel's name on the target preset. A target on
the home radio keeps the home slot. A pin the target region does not hold
skips the target. The deprecated ChannelSettings.channel_num is no longer
read, and the home node's channel_num no longer leaks into other presets.
- broadcast_offer_frequency_slot is advertised as MeshBeacon.offer_frequency_slot
only when a receiver could not derive it. An offer pinned to a slot its
region lacks is withheld; the text still goes out.
- The listener keeps a received offer's slot.
- Admin clears slot 0, and a slot past an explicit region and preset.
- userPrefs: USERPREFS_MESH_BEACON_OFFER_FREQUENCY_SLOT and
USERPREFS_MESH_BEACON_TARGET_<N>_FREQUENCY_SLOT.
Also from #11662:
- A beacon is addressed at its target's channel index, so perhapsEncode()
keys it like any other packet; the primary channel is never overwritten.
The TX hook writes the target preset's channel hash for a blank-named
channel on another preset.
- A beacon whose send() did not queue releases its target entry on every
failure, not only ERRNO_SHOULD_RELEASE.
- A beacon still queued a broadcast interval after it was armed, or whose
entry was evicted from a full table, is dropped at TX instead of going
out on the home radio.
Supersedes #11662.
Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
* fix(beacon): address review of frequency slots
- TX gate checks the target preset against its region or EU sibling, not the home slot
- Targets and offers run an EU sibling's preset in that sibling, as the switch does
- Switch clears override_frequency and restores tx_power; restore keeps fields edited mid-switch
- An unusable channel_index skips the target instead of falling back to the primary
- Beacon config validation moved to MeshBeaconModule::sanitizeConfig(), also run on load;
message cap no longer ends mid-character
- Evicted-id ring sized to the TX queue
* fix(radio): frequencySlotCount() reports no slots for UNSET
UNSET's table span is a placeholder; an unknown region code resolves to it too.
* fix(beacon): slot count and restore for custom bandwidth
- frequencySlotCount() counts a custom bandwidth wider than the region at the region default, as the clamp runs it
- resolveFrequencySlot() falls back to the hash when the region override slot exceeds the count
- The beacon switch restores a custom bandwidth applyModemConfig() clamped for the target region
* fix(beacon): run a target's named preset on a node with custom modem params
The switch sets use_preset for a target that names a preset and restores it after TX. A target naming no preset keeps the custom params.
* fix(beacon): keep an out-of-range target channel_index
sanitizeConfig() cleared has_channel_index for an index past the channel
table. That turns the entry into an unset-index target, and sendBeacon()
sends those on the primary channel - under a name and key the operator
never named. sanitizeConfig() now also runs from loadFromDisk(), so a
stored or userPrefs index did that at every boot, not only on the write
that set it.
Keep the index and only warn. sendBeacon() already bounds-checks it
against channels.getNumChannels(), which is tighter than
MAX_NUM_CHANNELS, before getByIndex(), and skips the target - so nothing
can index out of bounds, which is what the clear was there for.
test_adminValidation_targetChannelIndexOutOfRange_isCleared becomes
_isKeptAndSkipped: the index survives the write, and the stored config
emits no beacon rather than one on the primary.
Reported by NomDeTom on #12126.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwNPp17REf1c9je42aWyJa
---------
Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The Adafruit BLEDfu handler switches to the main stack and jumps to the
bootloader. Since the nRF52 toolchain moved to GCC 12.3.1 (#12027) that
call is compiled as a tail call whose epilogue pops the task frame from the
main stack, past the end of RAM, and the CPU locks up; the bootloader then
starts with GPREGRET 0xB1 and no SoftDevice, faults, and resets into the
app. Handle START_DFU here instead: write the same peer data and leave
through enterOTADfu(), a reset with the 0xA8 magic that every bootloader
handles.
* Rework XEdDSA signing policy: Compatible stops signing, AES-CCM overrides
Signing costs 66 bytes per packet. On a crowded mesh that matters, and
packets on an AES-CCM channel were paying it on top of the CCM tag.
Sending (perhapsEncode):
- Compatible, the default, no longer signs. Balanced and Strict sign
broadcasts as before; licensed (ham) nodes also sign unicasts, but now
only under Balanced or Strict.
- Packets on an AES-CCM (use_aead) channel are never signed. This also
removes the case where a signed CCM packet passed signedDataFits() but
then failed the CCM size check with TOO_LARGE.
Receiving (checkXeddsaReceivePolicy):
- Only Strict drops a packet for lacking a signature. Balanced no longer
drops unsigned broadcasts from known signers; that downgrade rule and
its canonical-sizing helper are removed.
- An AES-CCM channel overrides Strict: its unsigned or unverifiable
packets are accepted, since the CCM tag authenticates them.
- A malformed or failing signature still drops the packet in every mode.
Identity (new NodeDB::refusesUnsignedIdentity, used by updateUser,
NodeInfoModule and TrafficManagementModule):
- Balanced and Strict still refuse an unsigned NodeInfo that would change
a known signer's identity, except on an AES-CCM channel.
- Compatible accepts it, since firmware before 2.8 strips signatures when
relaying.
Channels::isAEADEnabled() now returns false for an out-of-range index
instead of logging, since receive paths pass a packet's index unchecked.
XEDDSA_SIGNATURE_FIELD_BYTES lost its only user and is removed.
Tests: test_packet_signing drops the cases that pinned the removed
downgrade sizing (A7-A9, A18-A19, E5, E10-E13, D1), flips A4 and E1 to
acceptance, and adds AES-CCM, Compatible and ham cases (A18-A20, B18-B20,
N12-N13). test_mqtt and test_traffic_management pin the same rules on the
plaintext-MQTT and NodeInfo-cache paths.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Sign NodeInfo unicasts, forward signed requests, check signed NodeInfo keys
A NodeInfo request or reply carries the key a peer will use for PKI direct
messages, but off ham mode only broadcasts were signed. A Strict node
therefore dropped every NodeInfo reply and could only learn a new peer's key
from a signed broadcast, hours apart.
- perhapsEncode: under Balanced and Strict, NodeInfo is signed even when
unicast (still never on an AES-CCM channel, never in Compatible).
- TrafficManagementModule: a NodeInfo request that carries a signature is
forwarded instead of answered from the cache. Its sender may need a signed
reply, and a reply sent on the target's behalf can never be signed.
- checkXeddsaReceivePolicy: a signed NodeInfo verified against a stored key
must advertise that same key, and the key must hash to the sender's node
number, as first contact already requires. Otherwise it is dropped. This
stops a valid signature vouching for another key, and stops a key planted on
trust under the wrong node number proving itself with fresh signatures.
Tests: B7 now expects NodeInfo unicasts signed; B21 pins signing per policy;
A21-A23 pin the key-consistency check; a traffic-management case pins that a
signed request is forwarded while the same request unsigned is answered.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Strict: accept unsigned acks and traceroutes; never sign traceroutes
Strict dropped every unsigned packet that was not PKI-encrypted. Acks and
traceroutes are never signed, so a Strict node saw no acks, retransmitted
every reliable packet to exhaustion, and could not take part in traceroutes.
- checkXeddsaReceivePolicy: Strict no longer drops an unsigned ROUTING_APP
packet; an ack carries its own proof (AckProof.h). Traceroutes are accepted
in every mode, and any signature on one is ignored rather than verified.
- perhapsEncode: a traceroute is never signed, including in ham mode, which
signs other unicasts. Every relay rewrites the route, so a signature fails
verification from the second hop on, and a bad signature drops the packet in
every mode.
- TraceRouteModule::alterReceivedProtobuf: strip any signature when rewriting
the route, so a traceroute signed by older firmware is not dropped by older
receivers further along.
Tests: A24-A26 (Strict accepts unsigned acks and traceroutes; a traceroute
signature is ignored), B22 (traceroutes never signed), B7 updated, and
test_traceroute_nexthop pins the signature strip on rewrite.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Drop the traceroute signature strip
Only 2.8.0 and 2.8.1 pre-release nodes ever signed a traceroute: ham mode,
which signs every unicast, and client-sent broadcast traceroutes. Stripping
the signature on rewrite only helped those pre-releases interoperate, which
does not justify the code. Receivers still ignore a traceroute signature, and
traceroutes are still never signed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Sign unicast positions under Balanced and Strict
A position request or reply is PKI-excluded, so a signature is its only
authentication, but off ham mode only broadcasts were signed. Strict dropped
every position request and reply, and under Balanced any channel member could
fake another node's location with an unsigned reply.
perhapsEncode now signs POSITION_APP unicasts under Balanced and Strict, as it
does NodeInfo. Nothing rewrites a position in flight - precision is applied by
the originator before signing - so the signature survives relaying, and it
binds request_id so a reply cannot be retargeted. Compatible and AES-CCM
channels still sign nothing.
Tests: B23 pins position unicast signing per policy and Strict acceptance; B7
expects position unicasts signed; B2 and B5 use PRIVATE_APP as their unsigned
unicast now that position is signed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Strict: accept unsigned key verification messages
Key verification's bootstrap messages (M1, M2) go out channel-encrypted
because the peer's key is not known yet, so they cannot carry a signature the
receiver could verify. Strict dropped them, so two Strict nodes that did not
already hold each other's keys could never run key verification.
The handshake does not rely on the transport: hash1 binds both public keys to
the security number the users exchange, and a key is only stored after the
numbers match and the user accepts. A forged request can at most raise a
verification prompt, bounded by the single session slot and the 60 s remote
cooldown - as it already can under Balanced.
Test: A27 pins that Strict accepts an unsigned key verification message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* test_nodedb_identity_hygiene: pin the identity refusal to Balanced
Two cases expected updateUser() to refuse an unsigned update from a known
signer under a zeroed config. Zeroed is Compatible, which now accepts it, so
they failed in CI. Run them under Balanced, and add the Compatible
counterpart so both halves of the rule are pinned at the updateUser level.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Ham mode: sign in place of PKI, authenticate acks with the ack proof (#12200)
Ham mode signed every unicast under Balanced and Strict, acks included. A
signed ack from a node whose key a Strict receiver lacks is dropped, so the
sender retransmits to exhaustion, and the signature costs 66 bytes where the
ack proof costs about 10.
Ham mode now signs the unicasts PKI would cover in regular mode, plus
broadcasts, NodeInfo and position as before. Acks are no longer signed; they
carry the ack proof, which needs only the two nodes' keys, not PKI. Strict
already accepts unsigned acks.
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
Co-authored-by: Claude <noreply@anthropic.com>
* Notify the client when an unsigned identity update is refused
Balanced and Strict refuse an unsigned NodeInfo that would change a known
signer's name or role, but the refusal only reached the device log, so the
user saw a stale name with no reason.
Send a ClientNotification naming the node (by the name already held, never
the refused claim) and how to accept its updates again: remove the node.
Rate limited to once per node per boot, over the last 8 nodes, and to one
notice per minute overall, so a signer relayed through pre-2.8 firmware or a
burst of them cannot flood the client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Refused identity notice: only for a name or role change, drop the warnings
Notify only when the refused NodeInfo would have changed the stored long
name, short name or role, compared through CopyUserToNodeInfoLite so
truncation and sanitizing cannot read as a change. A signer held only in
the warm tier has no stored name to compare and is not reported.
Remove the two LOG_WARNs at the refusal sites; the notice replaces them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Refused identity notice: name the security setting that refused it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
* Refused identity notice: one global hourly throttle, no per-node tracking
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add the Nodara nRF52840 board skeleton with e-ink init.
* Fix the Nodara e-ink driver leak and rename the board macro to NODARA.
* Nodara: use the stock S140 v6 linker script
The board-specific script had the same flash layout as
nrf52840_s140_v6.ld (app 0x26000-0xEA000) and differed only in the RAM
origin (0x20006000 vs 0x20004000). The SoftDevice's RAM requirement is
set by the shared Bluefruit config, not the board, so the extra 8 KB was
unused. Tested on hardware: BLE comes up, and a phone connects and syncs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: build the same modules as other nRF52 boards
Drop the variant's module exclusions and the -<mqtt/> source filter.
nRF52 boards keep MQTT for the phone client proxy, and audio/paxcounter
are already excluded for the whole platform in nrf52.ini. Flash use is
89.5%, in line with t-echo (88.7%).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: probe SX1262/SX1268 and TCXO/XTAL at boot
Define both radio types and TCXO_OPTIONAL so initLoRa() picks whichever
chip answers (RadioLib tells them apart by the version string) and
falls back from a DIO3 TCXO to the crystal. The boot log reports the
result. The test unit reports "SX1262 init success, XTAL, Vref 0.0V".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: map the six keys to the trackball and cancel buttons
Use the existing trackball driver for up/down/left/right/select (B key)
and CANCEL_BUTTON_PIN for the A key, which also gets the SENSE wake from
System OFF. Same pattern as the Wio Tracker L1 e-ink.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: power on/off through the power MCU
Nodara's power button goes to an always-on power MCU, which reports it
as Modbus RTU frames on a receive-only 1200 baud line (Serial2, P1.00).
Handle it in extra_variants: a shutdown request takes the standard
shutdownAtMsec path, and on a power-on reset boot waits up to 5.5 s for
the power-on confirm the MCU sends once the button has been held long
enough. Also give Serial2 a valid TX pin so the core doesn't index the
pin map with -1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: fix cppcheck and millis() deadline lint in the power MCU link
Zero-initialize the frame buffers and use Throttle::deadlinePassed()
for the power-on wait.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: report the NODARA hardware model
Map NODARA to meshtastic_HardwareModel_NODARA and add the flasher
metadata (hw model 151, slug, display name, tags, support level).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: power MCU link review fixes
Scan every buffered frame for the power-on confirm, poll the idle line
every 250 ms instead of 20 ms, and name the boot wait as the uptime
deadline it is.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Nodara: menu shutdown points at the power button
A software shutdown leaves the board powered, since the power MCU only
cuts power on its own button. Add POWER_BUTTON_SHUTDOWN_HINT: when a
variant defines it, the Shutdown menu shows it as a notification instead
of the confirmation. Nodara sets it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: steve.su <steve.su@sainstore.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* T-Beam 1W: support the LR2021 radio module
LilyGo ships the T-Beam 1W with either an SX1262 or an LR2021 module on
the same pins. Build both drivers into the t-beam-1w env; initLoRa()
probes the SX1262 first and keeps whichever radio answers, so SX1262
boards are unchanged.
LR2021 settings follow LilyGo's t_beam_1w_lr2021 documentation: IRQ on
radio DIO10 (GPIO1), TCXO at 3.0 V, and PA/LNA switching on radio
DIO5-DIO8 (rfswitch.h). The 2.4 GHz PA must not see more than 8 dBm, so
LR2021_MAX_POWER_HF is 8. GPIO21 is radio DIO11 on this module; the
SX1262 probe only claims it as RXEN after a successful begin(), so it is
never driven on an LR2021 board.
TX_GAIN_LORA stays at the SX1262 module's 10 dB. The LR2021 module's
gain is about 8 dB, so it is driven slightly below its maximum.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* T-Beam 1W: release SX126X_RXEN before the LR2021 probe
SX126xInterface::init() calls setRfSwitchPins() before setCurrentLimit(),
setCRC() and setOutputPower(), so a begin() that succeeds before one of
those fails leaves RXEN configured as an output driven low. initLoRa()
then drops the interface and probes the LR2021, which drives the same
pin itself - GPIO21 is radio DIO11 on the T-Beam 1W's LR2021 module.
Release the pin on that path, for the boards that can carry either
radio and wire an RXEN.
* T-Beam 1W: also support the LR1121 radio module
LilyGo also ships the T-Beam 1W with an LR1121 module on the same pins.
Add it to the t-beam-1w env; initLoRa() probes SX1262, LR1121, LR2021
in that order and keeps whichever answers.
LR1121 settings follow LilyGo's t_beam_1w_lr1121 documentation and
factory example: TCXO at 3.0 V, PA/LNA switching on radio DIO6-DIO8
(sub-GHz PA on DIO8 in TX_HP, 2.4 GHz PA on DIO7, 2.4 GHz LNA on DIO6).
LilyGo warns that more than 1 dBm into the 2.4 GHz PA can damage it, so
LR1120_MAX_POWER (the LR11x0 LORA_24 clamp) is 1.
On an LR2021 board the LR1121 probe returns RADIOLIB_ERR_CHIP_NOT_FOUND
after about 3.2 s and the LR2021 then initialises normally.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* T-Beam 1W LR1121: IRQ on GPIO21, force the high-power PA
The LR1121 module's IRQ is radio DIO11 on GPIO21 (LilyGO utilities.h:
RADIO_DIO_IRQ_PIN = RADIO_DIO11_PIN), not GPIO1. With GPIO1 the first
listen-before-talk CAD waited forever for CAD_DONE and froze the node.
The module's sub-GHz antenna is only reachable through the high-power PA
path (external PA enabled on DIO8 in MODE_TX_HP), but RadioLib selects the
low-power PA at 14 dBm and below, where every switch is off. Add an opt-in
LR11X0_FORCE_HP_PA that calls setOutputPower(power, true), clamped to the
HP PA's -9 dBm floor, at init and in programModemParams(), and enable it
for this board.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* T-Beam 1W LR1121: list RF switch DIOs from DIO5
RadioLib's LR11x0::setRfSwitchTable() sets each mode's bits by the pin's
position in the array, counting from DIO5, while the enable mask uses the
DIO number. A table starting at DIO6 therefore drove every line one DIO
low: sub-GHz TX raised DIO7 (the 2.4 GHz PA) instead of DIO8, so the
sub-GHz PA and TX/RX switch never left RX and the signal only leaked out.
List DIO5 (unused, always low) first, as LilyGo's documentation and
factory firmware do. Measured at 10 dBm against an SX1262 node: peer RSSI
-100 dBm before, -29 dBm after; 20/20 each way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* T-Beam 1W: also release SX126X_RXEN before the LR1121 probe
GPIO21 is the LR1121 module's IRQ (radio DIO11), so an RXEN left driven
low by a partly failed SX1262 init would mask its interrupts and hang the
first channel scan. Extend the release to boards that build the LR1121.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A push to membrowse/membrowse-action's v1 tag today shipped an action.yml that
uses ${{ github.workspace }} in the `description:` of its new source_root input.
Expressions are evaluated in the `inputs:` section too, and the github context
does not exist there, so the runner refuses to load the manifest:
membrowse/membrowse-action/v1/action.yml (Line: 77, Col: 18):
Unrecognized named-value: 'github'
Every membrowse job now fails in "Set up job", before any of our steps run, and
build_firmware.yml's membrowse job feeds ci-gate - so the required check is red
on every PR in the repo.
Pin both references to 34719bdb0472, the SHA v1 resolved to on the last green
run (membrowse 1.2.15, 03:34 UTC today), which also stops a third-party tag
move from reaching us unannounced again. comment-action/action.yml is identical
at both SHAs; pinning it is for consistency, not because it broke.
The repo's own size enforcement is unaffected either way: size-budget-gate
reads the manifest-* artifacts from pio-build, not anything MemBrowse produces.
Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* Fail the native simulator job on a sanitizer fault, and fix the one it found
The integration step backgrounded meshtasticd and ended in `wait "$PID"
2>/dev/null || true`, which discards the simulator's exit status. The coverage
env is built with -fsanitize=address, so a fault there prints a full report and
exits non-zero on the way out, after the Python harness has already printed its
own success - and the job concluded green with an AddressSanitizer
heap-use-after-free sitting in the log.
Gate it: capture the simulator's output, read the status `wait` reports, and
scan the output for a sanitizer report. The pattern moves to bin/lib/sanitizer.sh
so bin/run-tests.sh and the new bin/check-sanitizer-log.sh share one matcher
rather than drifting apart; the output is uploaded as an artifact, since a fault
on the exit path prints after the last test has passed. Leak detection is off,
as in bin/test-config-check.sh - exit_simulator exit()s with the node's globals
still live.
The fault it had been hiding: OSThread::run() points currentThread at the
running thread for the whole of runOnce(), and exit_simulator calls exit(0) from
AdminModule while ServerAPI is that thread. ServerAPI is declared
`public StreamAPI, private OSThread`, so its Thread base frees ThreadName first
and PhoneAPI::~PhoneAPI's own LOG_DEBUG then reads it back through
currentThread. Clear the pointer in ~OSThread when it names the thread being
destroyed, which covers any thread deleted from inside its own runOnce, not
just this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw
* Detach observers when an Observable is destroyed
The new sanitizer gate turned the simulator job red on a second
heap-use-after-free on the same exit path, this one in
Observable::removeObserver.
~Observer removes itself from every observable it watches, but Observable has
no destructor and dies with its watchers still pointing at it. exit_simulator
exit()s from AdminModule, and static destruction takes the global xModem - whose
packetReady is an Observable that PhoneAPI::handleStartConfig attached to -
before the heap-allocated ServerAPI that is watching it. PhoneAPI::close() then
calls unobserve(&xModem.packetReady) on the dead observable, and ~Observer would
walk it again afterwards.
Both halves of the contract, so neither side has to outlive the other:
~Observable drops itself from each watcher's list, and unobserve() checks
membership before reading through the pointer it is given.
Also take CodeRabbit's grep -m 4 in bin/check-sanitizer-log.sh: with more than
four matching lines, head closing the pipe is a SIGPIPE that pipefail reports
and set -e exits on, so the script gave 141 where it documents 1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JjAvRt6xuztC3hKqWkW9Dw
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* LR2021: time received frames on the configured coding rate
The LR2021's reported RX coding rate can pick up extra bits at a bad
status read right after our own TX and keep them until a hard reset.
Explicit-header frames still decode, since the rate travels in the
header, but the wrong value inflates RX airtime: a latched 4/8 against
a configured 4/5 times a 200-byte frame about 55% long. That feeds
channel utilisation, the CSMA contention window and the reliable-router
retransmit timers.
computePacketTime() gains useRxHeaderInfo; LR20x0Interface passes false,
so received frames are timed on the configured coding rate and CRC, and
the readout task no longer reads the header info on that chip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTiifXrVc4YdonmLRCeWhw
* LR2021: describe the coding-rate accumulator in the comments
The reported RX coding rate was described as latching after a post-TX
status read. That attribution was wrong: GetLoraPacketStatus accumulates
the bitwise OR of every received header's coding rate since reset, which
an SX1262 injector reproduces on demand - frames at 4/5, 4/6 and 4/7
each OR their bit in, a frame at the configured rate changes nothing,
and the field reaches values DS rev 2.2 Table 9-4 does not define. It is
invisible on a single-coding-rate mesh, since cr | cr == cr.
No behaviour change: comments only.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The handshake's third message must be PKI-encrypted, and licensed operation
neither sends nor decrypts PKI. A licensed node still opened a session,
raised the security-number prompt, and waited until it timed out.
A licensed node now refuses to start a session, from the admin message or
the screen, and tells the client why. It ignores a remote node's request
without a NAK, as a busy node does. The screen's node menu hides the Key
Verification entry in licensed mode.
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
Co-authored-by: Claude <noreply@anthropic.com>
* fix(motion): include NodeDB.h for the global `config`
MotionSensor.cpp uses config.display unconditionally (compass
orientation, wake-on-motion) but only reached the global `config`
through a fragile transitive include via Power.h/Screen.h. On a
screenless board that also excludes the WiFi/web stack, that
transitive path disappears and the file fails to compile with
'config' was not declared in this scope. Include mesh/NodeDB.h
directly so MotionSensor always compiles regardless of which
modules a variant excludes.
Co-authored-by: Cursor <cursoragent@cursor.com>
* cleaning up comments
Removed comments explaining the inclusion of NodeDB.h.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
* fix(gps): match Airoha idle_int sleep to the documented sequence
For boards that wake the AG3352 through idle_int (ThinkNode M8 and M9):
- Drop $PAIR382,1 before the sleep command. It locks the CPU out of
sleep, which works against both $PAIR003 and $PAIR650.
- Hold idle_int low 50 ms before sending, and wait 50 ms after the
command takes before EN cuts the core, as Elecrow's reference does.
- A successful $PAIR650 need not reply, so also accept NMEA going quiet
for a full cycle as success; resend once otherwise.
Boards with RTC_INT are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ThinkNode M8: reset the GPS out of backup mode at boot
If the MCU resets while the AG3352 is in backup mode, the GPS stays
there and the cached-probe check gets no answer, forcing a full re-probe.
Pulse RESETB in earlyInitVariant(), as the M5 does with its wake pulse
in #12113.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Key verification: create the responder's NodeDB entry on commit
An initiator that did not hold the responder in NodeDB could not complete
manual key verification: DO_VERIFY logged "Attempted to commit key, but
unknown node" and the key was never marked verified.
KeyVerificationModule consumes the responder's hash2 reply, so module
dispatch stops before RoutingModule sniffs the packet into NodeDB and no
entry is created. commitVerifiedRemoteNode() assumed one existed. When it
does not, commit the pending key the reply carried (the key the user has
just confirmed) through commitRemoteKey(), which creates the entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Key verification: commit only from the final-confirmation state
DO_VERIFY with a matching nonce, and the on-screen Accept callbacks, called
commitVerifiedRemoteNode() in any state. The app learns the nonce at the
security-number prompt, so an early DO_VERIFY (or one after a wrong number)
committed the unchecked pending key as ManuallyVerified. Require
SENDER_AWAITING_USER or RECEIVER_AWAITING_USER before committing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DjtCrF8NLMGs9DtEVQkUK
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* initial commit for L76K/AG3352 dual GNSS detection
* GPIO fixes according firmware demo
* resolve rabbits
* define PIN_GPS_SWITCH
* fix wake button and power pin
* clear response to avoid logging junk data
* increase IO07 samples to more reliably detect version
* ThinkNode M5: read the v1.0 GPS switch on GPIO10
On a v1.0 (L76K) board the GPS switch is sensed on GPIO10, not GPIO18.
Watching GPIO18 there meant flipping the switch did nothing. Pick the pin
by revision, as the GPS UART pins already are; m5HasAg3352 is set in
earlyInitVariant() before createGps() configures and starts watching it.
Tested on a v1.0 board: switch off and on now turns the GPS off and on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ThinkNode M5: trunk fmt variant.h
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* SX126x: follow RadioLib's XTAL fallback and log the chip's state after init
Since 7.5.0 (92e2f333f1) RadioLib retries config() on the XTAL inside begin() when the TCXO
will not start, zeroes its own Vref and returns success. The TCXO_OPTIONAL ladder therefore
never builds its XTAL instance, and Meshtastic kept reporting "TCXO, Vref 1.8" on crystal boards.
- reinitChip(): take RadioLib's zeroed Vref after a successful begin()
- init(): one line with the oscillator in use, chip mode, command status and device errors
- the ladder's SX1262/SX1268 success lines report the oscillator actually in use
- drop the embedded pre-init "DIO3 as TCXO Vref" debug line, which only stated the attempt
* tronk
* SX126x: don't report a stale chip state when the diagnostic read fails
status is a function-local static filled by parseStatusCb, so an
SPIreadStream() that fails before parsing left the previous call's byte in it,
and the init log reported that as the current mode and command status. Check
the read and log the failure instead.
* fix(sx126x): keep RadioLib's status validation on the diagnostic read
The stand-in parseStatusCb captured the status byte but always returned
RADIOLIB_ERR_NONE, so a failed command or a 0x00/0xFF (no chip) byte was
logged as chip state. Chain to the saved parser so those fail the read.
* SX126x: comment out the post-init GetDeviceErrors read
The raw SPI read and parseStatusCb swap run after startReceive(), outside a
RadioSequence, so they can race the RX readout task. Kept as a commented-out
debug aid for init failures.
---------
Co-authored-by: Tom <116762865+Nestpebble@users.noreply.github.com>
* CI: let a running push run finish instead of cancelling it
#12162 put pushes in one concurrency group per branch with
cancel-in-progress, so a merge landing mid-run threw away a part-built
full matrix. Set cancel-in-progress only for non-push events. A push
now lets the running run finish and waits as the group's single pending
run; a newer push replaces that pending run before it starts, so pushes
that land mid-run collapse into one follow-up run. PRs still cancel
their superseded runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: correct the concurrency group comment
A PR groups on head_ref and a push on the full ref, so the two never
share a group; the comment said they did. The expression is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(variants): add RAK WisMesh Dongle. USB-powered headless adapter on the RAK4631 module. Hardware model stays RAK4631 until protobufs add WISMESH_DONGLE. The boot VDD gate is disabled because phone VBUS makes the SAADC sample look unsafe.
* add board_level
* fix(rak_wismesh_dongle): set VDD gate to 1 mV so cppcheck passes
---------
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
* CI: run pio check on PRs only, not in the merge queue
The merge queue re-ran cppcheck on the same --level pr boards the PR run
had already checked. Restrict the check job to pull_request; ci-gate
already treats a skipped check as passing, so merge_group runs gate on
the builds alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: cancel superseded push runs on the same branch
Push runs fell back to github.run_id for their concurrency group, so
every merge to develop ran the full 156-board matrix to completion even
when a newer merge had already landed. Group pushes by github.ref so the
newer push cancels the older run. PRs keep grouping by head_ref, and
merge_group, schedule and workflow_dispatch runs keep a group of their
own, so the queue, the nightly and releases are never cancelled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: move axiometa-genesis-mini out of the PR board set
Set its board_level to release. PRs and the merge queue now build 17
boards instead of 18; the board is still built in the full matrix on
push, nightly and release.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: reuse the merge queue's PR-level builds in the post-merge run
A post-merge push to master/develop lands the exact commit its
merge_group run already built, so the PR-level boards were compiled
twice. When the push's merge_group run is found and its ci-gate passed,
setup marks those boards 'reuse' and build_firmware.yml copies their
firmware and manifest artifacts from that run instead of building. The
artifacts keep their names, so the size report baseline, the RAM/flash
budget gate and MemBrowse are unchanged.
If no such run is found (event/* and feature/* pushes, a push that
bypassed the queue, an API failure), every board builds as before.
ci-gate is read through the check-runs API: the run's jobs endpoint
returns 502 for a run this size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: skip macOS, Windows and debian-src on reused post-merge pushes
When setup finds the merge_group run that already built this commit
(reuse_run_id), these jobs ran on that exact commit there and nothing
on push consumes their output: the debian-src artifact is only read by
release-artifacts on workflow_dispatch. test-native, WASM and Docker
still run, because only pushes to develop save their caches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: only skip OS builds on push when the merge queue run passed them
ci-gate needs only build and check, so a merge_group run whose macOS,
Windows or debian-src job failed still merges, and skipping those jobs
on the push hid the failure. setup now sets skip_os_builds only when the
latest attempt of every MacOS, Windows and build-debian-src check run in
that merge_group run concluded success and all three are present; the
jobs skip on that flag instead of reuse_run_id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: harden the post-merge reuse lookup (review fixes)
- Find the merge_group run at any status. The queue merges once ci-gate
passes, often while jobs outside it still run, so requiring a
completed run usually found nothing and rebuilt every board.
- Mark a board 'reuse' only if its firmware and manifest artifacts are
still present and unexpired in that run; any other board compiles.
setup now needs version to build the exact artifact names.
- Never skip MacOS, Windows or build-debian-src on the default branch:
those jobs save their caches only on its pushes. The skip stays for
master.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: drop the macOS/Windows/debian-src skip on post-merge pushes
Those jobs save their caches only on default-branch pushes, so the skip
was already limited to master, where it saved too little to justify the
check-run lookup. They now run on every push exactly as before this PR.
Reuse of the PR-level firmware builds is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: look up the merge_group run by workflow file, not display name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
* CI: build no firmware in the merge queue; drop post-merge artifact reuse
Per review: the merge queue now only batches PRs into develop/master.
setup is skipped on merge_group, which skips the build and check
matrices that depend on it, and ci-gate accepts a skipped build on
merge_group only; a PR must still build. Post-merge pushes build the
full matrix as before.
With no firmware built in the queue there is nothing to reuse, so the
post-merge artifact copy (reuse-build, the merge_group run lookup and
setup's dependency on version) is removed and build_firmware.yml is
back to develop's version. The merge_group branches of setup's matrix
generation are removed with it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DdE8uBENCwUMApqYdut2L
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Stop treating BATTERY_PIN -1 as a battery ADC
Six variants defined BATTERY_PIN -1 to mean "no battery sense", but every guard
is `#ifdef BATTERY_PIN`, so the analog battery read compiled in anyway. On the
Seeed Xiao S3 it samples an unwired GPIO1 and reports a battery that is not
there: 0% at 3112 mV on one board, charging at 6310 mV on another. On nRF52,
analogRead(-1) indexes past the pin map.
Leave BATTERY_PIN undefined on those boards, which takes the existing
external-power path, and fail the build when a variant defines a negative pin.
* Keep the INA battery path on boards without a battery ADC pin
AnalogBatteryLevel is the only reader of a battery INA configured through
power.device_battery_ina_address, and analogInit() only installed it when
BATTERY_PIN was defined. Dropping the -1 placeholder from six variants
therefore also dropped their INA path. Install AnalogBatteryLevel on any
board without a pin when the environmental sensors are compiled in, and
let the PowerFSM treat such a board as externally powered only while no
battery source has been found.