Commit Graph
12848 Commits
Author SHA1 Message Date
HarukiToreda 8d7d5cdd18 fast refresh for everything 2026-10-16 15:33:35 -04:00
HarukiToreda acc5afd75b Faster refresh 2026-10-16 01:25:28 -04:00
HarukiToreda b3f408de95 Merge branch 'develop' into InkHUD-Book-Reader 2026-10-15 21:24:47 -04:00
Carlos Valdes a2a27f4f99 Track meshtastic/Crypto with git-refs on rp2040/rp2350 and bump to 9ceb213 (#12157)
The other platforms moved their Crypto comment to datasource=git-refs in
#11065 and Renovate has kept them current since (#11879, 1c817c2). rp2040
and rp2350 still say datasource=github-tags, and meshtastic/Crypto has no
tags, so Renovate never updates them: both are still on 591ff9a.

Switch the comment to the same git-refs form and bump the pin to the
current main (9ceb213), which brings in meshtastic/Crypto #4, #5, #7 and #8.
2026-10-15 14:38:41 -05:00
James CroneyandHarukiToreda 7579bfe4d3 InkHUD: Feature/sdcard eink maps (#12118)
* InkHUD: load offline map tiles from SD cards

InkHUD map backgrounds previously came only from MapTile.h compiled into
the firmware image. This commit expands that to read map tiles from an SD Card.

* Firmware-compiled tiles remain the first choice, with SD Card secondary.
* SD Card MapTiles' header position table and payload descriptors loaded into PSRAM.
* The index is cached in PSRAM when allocation succeeds; otherwise streamed index access.
* Validates the file magic, layout, counts and index/data bounds before use.
* Added an MTB1 reader for /Map/MapTile.bin
* Supports sparse and grid indexes together with white, black and raw LZ4 block entries.
* Moved the tile format constants and LZ4 decoder out of MapApplet.
* Reuses the applet's existing decoded-tile buffer.

Compressed tile imagery remains on the card. Rendering resolves
coordinates from the cached index, takes spiLock for a bounded
open/seek/read/close operation, releases the bus, then decompresses
the tile. Memory use therefore stays bounded by the index and one
compressed and decoded tile rather than the size of the map file.

The T5S3 E-Paper V2 connects its SD card and LoRa radio to the same SPI
pins. Drop SDCARD_USE_SPI1 only from the InkHUD environment so both use
the default SPI instance and the existing shared lock. Keep SD map
enablement and preload setup in the T5S3 variant; the non-InkHUD V2
environment retains its existing SPI1 configuration.

Update MapApplet to walk only the visible tile-coordinate range, wrap
tile X coordinates across the antimeridian and check embedded tiles
before requesting an SD tile. A missing, invalid or temporarily busy SD
source simply leaves that background tile undrawn without changing the
existing map applets or their compiled-in tile behavior.

* PowerFSM: service non-input GPIO wakeups after light sleep

Before c308d0aca, a light-sleep wake that was not caused by the button
triggered EVENT_WAKE_TIMER. Despite its name, that event is also the
PowerFSM path for leaving LS after a peripheral interrupt so the normal
loop can service the pending hardware IRQ.

c308d0aca (feat: Support Elecrow ThinkNode M9, August 17, 2026) split
ESP_SLEEP_WAKEUP_GPIO out of the default switch branch to recognize
KB_INT as a key press. The new GPIO branch retained the EVENT_PRESS path
but omitted the previous fallback for GPIO wakes that were not buttons.
546b678d5 later added explicit motion-interrupt handling, but other GPIO
wake sources still produced no PowerFSM event.

LoRa DIO and other ESP32 peripheral wake sources are reported as
ESP_SLEEP_WAKEUP_GPIO. When neither the button nor motion line is
asserted, lsIdle() therefore remains in LS and can enter light sleep
again before the cooperative loop services the pending interrupt. A
latched IRQ can repeatedly wake the CPU into the same path, producing a
wake/sleep loop while delaying radio and other peripheral handling.

Restore the old fallback after the button and motion checks. An
unattributed GPIO wake now triggers EVENT_WAKE_TIMER, moving the FSM
from LS to NB or DARK so the regular loop can handle the interrupt
before light sleep is attempted again. Button wakes still use
EVENT_PRESS, motion wakes still use EVENT_INPUT, and non-input GPIO
wakes do not turn on the screen.

* InkHUD: centralize map tile zoom lookup

Select the embedded zoom metadata once and share the lookup between the zoom count and index helpers. Preserve the SD-card fallback while avoiding duplicate ternary expressions when the default tile metadata is empty.

* InkHUD: bound or stream SD map indexes

Limit index caching to a configurable percentage of available PSRAM
and fall back to block-based SD streaming when the index is too large or
allocation fails. Release the SPI lock and yield between blocks to keep
other tasks responsive.

Retry temporary SD failures and publish map header state only after all
required index reads complete successfully.

* added requested build flag for sdcard maps to t3s3 variant

---------

Co-authored-by: HarukiToreda <116696711+HarukiToreda@users.noreply.github.com>
2026-10-15 14:47:48 +00:00
Ethac.chenandThomas Göttgens 4964a0c6a5 feat(raksensorhub): SensorHub uplink for WisMesh Hub (RAK2560) (#11817)
* feat(raksensorhub): add SensorHub uplink driver for WisMesh Hub
Add HAS_RAKHUB env rak_wismesh_sensorhub with 1-Wire bus + IPSO cache.
Ship standard environment metrics only; soil/water chemistry stays gated until SoilWaterMetrics lands in firmware.

* fix(raksensorhub): harden RX length, MODBUS log, and power TTL
Reject oversized frames without uint16 wrap, validate MODBUS before head[] access, TTL hub power like other scalars, and set board_level for CI matrix.

* fix(raksensorhub): track HubPower freshness per field
Use separate timestamps for voltage, current, and capacity so one IPSO cannot keep stale siblings fresh, and percent no longer depends on volMv.

* fix(raksensorhub): stamp 0-unset cache times with Time::skipZero
A wrap or boot 0 tick must not look like never-updated; freshness reads use Time::stampMillis() too.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-10-15 13:16:43 +00:00
Jonathan BennettandClaude Opus 5 dc9999280f Don't let a stale RX flag re-arm RX over a live frame (#12153)
* Don't let a stale HEADER_ERR re-arm RX over a live frame

Before: a header that fails on noise, a collision or a foreign frame
latches HEADER_ERR and no RX_DONE follows it. `checkStaleRxFlags()`
counts HEADER_VALID and HEADER_ERR as one boolean, so the error starts
the max-packet window that re-arms RX. A good frame arriving inside that
window raises HEADER_VALID beside the error, which does not restart the
window - a header was already up. One max packet after the error was
first seen, the poll re-arms, and `startReceive()`'s standby aborts the
frame still on the air.

After: the window is timed from the valid header alone. A HEADER_ERR
beside a HEADER_VALID is retired at once and decides nothing, because one
header raises one flag or the other (SX1262 DS rev 2.1 table 13-29), so
the two together are two frames and the error is the older one.

A lone stale HEADER_ERR past its window is now cleared rather than
re-armed on every chip but SX128x. The re-arm exists for the SX1280 wedge
of DS rev 3.3 16.2, which a clear does not unwedge; on SX126x, LR11x0 and
LR20x0 the demodulator is already hunting for a preamble again, and a
clear never aborts a frame that may be in one. `headerErrWedgesRx()` is
the per-chip hook, true only on SX128x. SX126x already clears HEADER_ERR
unconditionally in `resumeRunningReceive()` for the same reason.

The busy decision is unaffected: `receiveDetected()` reads only
HEADER_VALID and PREAMBLE_DETECTED, so the cost of the bug was a dropped
reception, never a talk-over.

How: `staleRxFlagAction()` takes HEADER_VALID and HEADER_ERR separately
plus the per-chip wedge flag, and gains a ClearHeaderErr verdict.
`checkStaleRxFlags()` keeps a second stamp bit for "the window was
started by a valid header", so a valid header restarts a failed one's
window the way develop already has a header restart a bare preamble's.
Five native cases cover it, two of them the regression itself.

Found and analysed by Tom (NomDeTom), who also wrote the commit the
behaviour came from, 45262f825 in #11137.

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

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

* Retire a dead frame's preamble with its stale header error

A failed header latches PREAMBLE_DETECTED beside HEADER_ERR, and both belong to
the frame that died. Clearing only the error leaves the preamble up, so the next
send's receiveDetected() holds TX for one max packet time over a frame that is
already gone - a wait the re-arm this path replaces never cost, because
startReceive()'s standby cleared the whole flag register at once.

Clear both when no valid header stands beside the error. A valid header's own
window is untouched, as before.

Found by NomDeTom while reviewing the parent change.

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

* Re-arm on a wedging chip when both header flags are latched

Where a header error wedges RX (SX1280 DS rev 3.3 16.2), HEADER_VALID and
HEADER_ERR latched together do not mean what they mean elsewhere. RX cannot
reach a later frame's header while wedged, so the error is the newer of the
two: the frame behind that valid header is already dead and the chip is deaf.
Retiring the error and leaving the valid header's window to expire kept it deaf
for another poll tick. Re-arm instead - there is nothing left for the standby
to abort.

Every other chip keeps hunting for a preamble after a header error, so there
the error is the older flag and a clear is still right.

Found by NomDeTom reviewing the parent commits.

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

* Retire a dropped frame's RX flags, not just its RX_DONE

clearReadIrqs() is the give-up path: a frame has completed, nobody will
read it out, and its flags are cleared so the next RX_DONE raises a fresh
edge. It cleared RX_DONE and CRC_ERR only, leaving HEADER_VALID and
PREAMBLE_DETECTED latched for a frame that no longer exists.

checkStaleRxFlags() then times its max-packet window from that dead
frame. The next frame's own HEADER_VALID lands on a bit that is already
set, so the window is never restarted - the same shape as the stale
HEADER_ERR bug this PR fixes, one step earlier in the path - and when it
expires startReceive()'s standby aborts the live frame. The valid-header
restart cannot help here: the stale flag and the live frame's flag are
the same bit.

readData() clears every flag on a successful read, so a drop now clears
what a read would have. That takes the same risk a read takes: a frame
arriving during the drop loses its latched preamble, so LBT is blind to
it until its header. The alternative is the frame being aborted outright,
which is what happens today.

Found by Tom (@NomDeTom) reviewing this PR.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 09:12:22 +00:00
Jonathan BennettandClaude Opus 5 e9c046547d LoRa: size the preamble hold for the worst-case frame, and let the CAD settle an old bare preamble (#12125)
* LoRa: size the preamble hold for the frame that could be arriving

The hold ran for getPacketTime(DATA_PAYLOAD_LEN + sizeof(PacketHeader)):
249 bytes at our own coding rate. A sender's header carries its own CR, up
to 4/8, and that header is unreadable when the hold starts, so a legal
255-byte frame at CR 4/8 with CRC outlives the hold and we key up over it.
That is the case the hold exists for, since LoRa we cannot decode - a
foreign sync word - never produces HEADER_VALID at all.

maxRxFrameMsec() times the 255-byte PHY frame at CR 4/8 with CRC from the
configured modem, through the radio's own calculateTimeOnAir(), and falls
back to getPacketTime(MAX_LORA_PAYLOAD_LEN) if the radio answers an error
code (#11935). At SHORT_TURBO that is 159 ms rather than 100 ms, at
LONG_FAST 3.3 s rather than 2.1 s. The HEADER_VALID ageing in
receiveDetected() takes the same bound, for the same reason.

checkStaleRxFlags() and checkCadHandoffTimeout() keep the shorter window:
they decide a latched flag is dead and recover the chip, where waiting
longer delays the recovery rather than protecting a frame.

Three test cases in test/test_radio pin the sizing, the error fallback and
that the hold outlives the flag clear that starts it. FakeIrqRadio now
answers calculateTimeOnAir() and honours a clear.

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

* LoRa: let the pre-TX CAD settle an old bare-preamble hold

A hold runs for one max frame whether or not anything is really arriving:
159 ms at SHORT_TURBO, but 2.1 s at LONG_FAST and 14 s at LONG_SLOW. For
all of it the TX path only re-rolls its backoff - canSendImmediately() is
false, so the pre-TX CAD never runs and nothing re-examines the channel.
A noise preamble therefore costs seconds of TX and can overflow the Tx
queue, which is what @GUVWAF objected to on #11968.

A frame we could decode latches HEADER_VALID within the rest of the
preamble plus the ~12.25 symbols of sync word, SFD and explicit header.
Once that grace has passed with no header, the sighting is noise or LoRa we
cannot read, and neither deserves the rest of the hold: preambleHoldPeekable()
lets the TX path fall through to its CAD and send if the channel is clear.
At LONG_FAST the grace is 248 ms against the old code's own 262 ms deadline,
and 8 ms at SHORT_TURBO, so a spurious preamble now blocks TX for slightly
less than develop did before the hold existed - and then actually listens.

HEADER_VALID keeps the full hold: that is a real frame on our own modem.
receiveDetected() notes a header even while the hold short-circuits, so a
frame whose header arrives mid-hold is never peeked past, and a new hold
forgets the previous one's header. Our own transmit ends the hold, since the
CAD has just judged the channel.

preambleHeaderGraceMsec() computes the grace from sf and bw rather than
RadioInterface::preambleTimeMsec, which is whole milliseconds: above 2 GHz a
12-symbol preamble is 0.95 ms, so that value is 0 and the grace would be 0,
making every sighting peekable the moment it was seen.

Six more cases in test/test_radio: the grace at wide-LoRa and sub-GHz
presets, its rounding, and the peek inside the grace, after it, behind a
header and across a new hold.

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

* LoRa: ask for the frame bound before reading back the CR it timed

test_preambleHold_isSizedForTheWorstCaseFrame read phy.lastCodingRate
straight after the first preamble sighting, where nothing has yet asked
the chip to time anything: preambleHoldActive() short-circuits on the
unset stamp, so the sighting that starts a hold never reaches
calculateTimeOnAir() and the fake still reports a coding rate of 0.

Ask maxRxFrameMsec() for the bound first, then assert the CR and CRC it
was asked for. The case pins the same thing it always meant to - that the
hold is timed at CR 4/8 with CRC rather than at our own CR.

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

* LoRa: scale the preamble hold's airtime fallback to CR 4/8

When calculateTimeOnAir() cannot time the worst-case frame, maxRxFrameMsec()
falls back to getPacketTime(), which times it at our own coding rate. A
configured rate below 4/8 then produced a hold shorter than the frame the hold
exists to cover. Scale that figure up to 4/8; the preamble does not scale with
CR, so the result runs long, which is the safe direction for a hold.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 03:11:15 +00:00
Jonathan BennettandClaude Opus 5.5 ed072eb4b5 ThinkNode M8: switch the GPS to the AG3352Q (#12151)
* ThinkNode M8: switch the GPS to the AG3352Q

Later M8 units replace the ATGM336H with an Airoha AG3352Q. Drop the
ATGM336H standby pin and wire up idle_int (P1.07) so the existing
GPS_SLEEP_INT path stops the engine with $PAIR003 before GPS_EN cuts
the 0.8 V core, as on the ThinkNode M9. Default baud is now 115200.

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

* ThinkNode M8: put the AG3352 into backup mode for sleep

$PAIR003 only stops the GNSS engine, so with the 1.8 V rail always on
the receiver never reached low power (1.5 mA measured by Elecrow).

Add GPS_AIROHA_RESET_WAKE for boards without RTC_INT that have RESETB:
sleep sends $PAIR650,0 (with idle_int low) before EN goes low, and wake
pulses RESETB once EN is back up. Enable it on the M8.

At shutdown, hold idle_int high and RESETB released instead of letting
the variant's pin sweep float them through System OFF.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:49:21 +00:00
Jonathan BennettandClaude Opus 5 bda9f8c22d Add configurable USB console light sleep veto for ESP32 (#12160)
* Add a build option to ignore the USB console's light-sleep veto

#12141 vetoes light sleep while a USB host has the hardware USB-serial/JTAG
console open, because no shipping chip keeps the USJ pad alive across a sleep
and the host gives up before we surface again. That is right for a user, and
it also means light sleep can no longer be watched in a debug log over USB,
which is how the path is normally developed. mverch67 asked for a way to turn
it off on #12141.

MESHTASTIC_LIGHT_SLEEP_IGNORE_USB_CONSOLE does that. It gates the veto rather
than the detector, so usbConsoleHostAttached() still answers honestly and the
only thing that changes is whether lsIdle() lets the sleep through - and, with
it, the "Skip light sleep: USB console host attached" line, which would
otherwise be logged once per stay in LS by a build that is deliberately
sleeping anyway.

Nothing defines it. A build that does accepts losing the console to the first
sleep until the board is power cycled.

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

* Make the light-sleep USB opt-out a single defaulted define

The helper added a function to say what the condition can say itself. Define
MESHTASTIC_LIGHT_SLEEP_IGNORE_USB_CONSOLE to 0 by default and test it inline,
so there is one name to grep for and a variant can override it with a -D.

Behaviour is unchanged: with the define at its default the condition reduces
to the same !usbConsoleHostAttached(), and the detector is still untouched, so
usbConsoleHostAttached() keeps answering honestly for any other caller.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 19:31:12 +00:00
Jonathan BennettandClaude Opus 5 2d61ca6736 Latch the LoRa IRQ skip-sleep give-up until the line is seen low (#12155)
doLightSleep() skips a light sleep when the LoRa IRQ line is already
asserted, and bounds that to LORA_IRQ_SKIP_SLEEP_MAX consecutive skips so a
line stuck high cannot stop the node sleeping for good. But the counter was
reset on the sleep it finally allowed, so the bound never stuck: a genuinely
stuck-high line produced five skips, one sleep, five skips, one sleep, for as
long as it stayed high.

Each of those skipped attempts is not free. It runs waitEnterSleep() and the
full notifyLightSleep / notifyLightSleepEnd observer pass, which detaches,
arms and restores every wake pin - the path #12100 and #12131's interrupt
storm live on - so the loop costs far more than the sleep it is protecting.

Reset the counter only when the line is actually read low, which is the
condition that says the radio is clearing its IRQs again. Nothing changes for
the transient case the skip exists for: the line goes low as soon as the main
loop services the packet, and the next sleep clears the count.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 17:06:40 +00:00
Jonathan BennettandClaude Opus 5 660680cea5 Prepare the LoRa wake pin on every ESP32 variant, not just ESP32/S2/S3 (#12154)
enableLoraInterrupt() had two branches for the same job. The first did the
DIO1 pull-down, the LORA_RESET and LORA_CS pull-ups and the HAS_LORA_FEM
sleep-RX setup before arming the wake pin; the second armed the pin and did
none of that. The condition choosing between them was
SOC_PM_SUPPORT_EXT_WAKEUP, an IDF backwards-compatibility alias defined only
on ESP32, S2 and S3 - the same alias main-esp32.cpp:427 already warns about
for the ext1 deep-sleep path - so C3, C6, H2 and P4 compiled the second one.

On those chips DIO1 therefore entered light sleep with no pull-down, which
on a line that is not externally pulled reads high and wakes the node from
every sleep; LORA_CS and LORA_RESET entered unpulled; and a LoRa FEM was not
put into its sleep RX mode. Nothing here uses ext wakeup: it is a GPIO
light-sleep wake source, and gpio_wakeup_enable() and the pin pulls are
available on every ESP32 variant. So the first branch now compiles
everywhere and the second is gone.

The restore half of the same path was already unconditional - its
#if SOC_PM_SUPPORT_EXT_WAKEUP / #else carried two identical branches,
vestigial from #12131 - so that is collapsed too.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 17:05:19 +00:00
Jonathan BennettandClaude Opus 5 1c366c2d05 Radio: end a CAD handoff whose RX hits a damaged header (#12144)
* Radio: end a CAD handoff whose RX hits a damaged header

When CAD hands off to RX and that RX ends on a damaged header (CRC_ERR or
HEADER_ERR with no RX_DONE), the RX readout task cleared those flags and
returned. Nothing was notified and cadHandoffRxStart stayed set, so the
radio thread never re-armed. Where the chip had stopped listening, the node
stayed deaf until the main loop's backstop logged "CAD>RX timeout" and
re-armed: 200 ms to over a second later. Before the readout task, the same
edge went to handleReceiveInterrupt(), which read the error, cleared the
flags and re-armed.

The bench saw this on an LR2021 (X1) running develop: 5 "CAD>RX timeout"
lines across 3 runs, where pre-#11983 develop logged none in 4 runs.

Now the task hands every frameless end of a handoff's RX (TIMEOUT, CRC_ERR,
HEADER_ERR) to the radio thread, which clears the flags through
handleReceiveInterrupt() and re-arms, as it already did for TIMEOUT.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Radio: don't strand a damaged header's flags when its notification is dropped

Three follow-ups to the handoff end, from review of the first commit.

The readout task's notify(ISR_RX, !rxArmedBeforeTxDone) deliberately does not
overwrite a pending TX notification, so the RX notification can be dropped.
That is pre-existing for the TIMEOUT case, and the ISR_TX path does put the
chip back into RX, but the first commit moved the clearing of CRC_ERR /
HEADER_ERR to the radio thread, so in the dropped case nothing clears them
until checkCadHandoffTimeout(). notify() returns false exactly when it
dropped, so clear them at that site in that case, as the code did before, and
leave the open handoff to the backstop.

handleReceiveInterrupt() logged "CAD>RX pkt" for a frameless end, and read a
length that is not a frame's. A damaged header now takes the same exit as an
empty window, with its own log line, so a capture says which of the two
happened and getPacketLength() is not asked about a packet that never
arrived. rxBad is unchanged either way: neither case counted before.

An empty window inside a handoff can carry a damaged header's flags alongside
the timeout, both routed to DIO1, so that exit clears them together. Plain RX
is untouched there and still leaves HEADER_ERR to checkStaleRxFlags().

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:02:48 +00:00
Jonathan BennettandClaude Opus 5.5 a2984a5d28 ThinkNode M8: make button E the cancel button (#12150)
Button E was PIN_BUTTON1 with a hand-rolled FunctionButton map in
InputBroker (single press: ping, double: privacy toggle, 5 s hold:
shutdown). The rotary encoder already drives the UI, so define the pin as
CANCEL_BUTTON_PIN instead and drop the M8 special case: a press now
cancels and a 4 s hold shuts down, through the generic cancel button.
variant_shutdown() follows the rename so the wake-on-press pin is still
parked and sensed.


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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:02:07 +00:00
Jonathan BennettandClaude Opus 5 0af1396ea3 Let a configured ls_secs survive the phone API round trip (#12145)
The config-send path copies config.power and then overwrites one field with
the default, unconditionally:

    fromRadioScratch.config.payload_variant.power = config.power;
    fromRadioScratch.config.payload_variant.power.ls_secs = default_ls_secs;

The note at the end of that state explains why the effective value is sent:
0 means "use the default" and the app needs to know what the device will
actually do. But the write is not conditional on the stored value being 0, so
a configured value is replaced on the way out. Every client, the CLI included,
reads back default_ls_secs (IF_ROUTER(ONE_DAY, 5 * 60)) no matter what is
stored, and a set that took effect looks like a set that was refused.

It also loses the setting. AdminModule's power case stores what it is sent
(src/modules/AdminModule.cpp:967), so a client that reads this config and
writes it back - which is what happens when a user changes any other field in
the same message - stores the default over the configured value.

Observed on an ESP32 node: ls_secs set to 60, read back as 300, and the node
then light slept on the 60 s cadence, so the stored value had taken all along.

Default::getConfiguredOrDefault() keeps the behaviour the note describes, since
0 is exactly the case it falls through on, and changes only the readback of a
non-zero stored value. Note that config.power.ls_secs is initialised to
default_ls_secs in NodeDB::installDefaultConfig() (src/mesh/NodeDB.cpp:1366)
and consumed raw in PowerFSM's lsIdle() (src/PowerFSM.cpp:133), so a stored 0
is not how a default-configured node looks in the first place.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-14 17:01:50 +00:00
Thomas Göttgens afdaccc25f Makerfabs nomad terminal (#12112)
* Add Makerfabs Nomad Terminal variant

* Configure external power detect pins before probing battery gauges

* TFTDisplay: add ILI9488 reset pin and colour inversion options

* Makerfabs Nomad Terminal: add BaseUI env, early power latch, soft-start LCD/GPS rail, GPS wake

* I2C scan: tell FocalTech touch from AHT10 at 0x38 and BMM150 from VEML7700 at 0x10

* Run a BMM150 next to an IMU on the magnetometer thread

* Makerfabs Nomad Terminal: BaseUI touch

* BMM150: add hard/soft-iron calibration, read at 10 Hz

* Makerfabs Nomad Terminal: fix MUI build, restart Wire after MUI touch init

* BMM150: seed calibration from the first valid sample

* Makerfabs Nomad Terminal: combined BaseUI+MUI -tft firmware, SD defines in build flags

* Makerfabs Nomad Terminal: reset touch controller at init, wake touch thread on INT

* BMM150: poll every 200 ms

* Makerfabs Nomad Terminal: scale CW2015 SOC to the on-USB full reading

* BMM150: end an expired calibration when the sample is rejected
2026-10-14 15:18:24 +00:00
tornadus f7f66b4731 Initial Elecrow ThinkNode M5 InkHUD port (#12097)
Adds an InkHUD driver (nicheGraphics.h) and build target
(platformio.ini) for InkHUD on the M5
2026-10-14 06:09:48 +00:00
HarukiToreda 60f7b8be7e BookReader 2026-10-14 02:07:47 -04:00
Manuel 04cb888511 Wio Tracker L2 fixes (#12133)
* fix WakeKey not responding when pressed during boot

* fix I2C error address 0x48 during scan

* enable GT911 gesture recognition in MUI

* shutdown_variant()

* disable io expander INT during light sleep

* fix BaseUI wakeup

* prevent bootloop in case DIO1 fires

* fix ADS1X15 scan type

* fix power down sequence

* external keyboard support

* fix I2C port 1 define; skip false positive
2026-10-13 19:50:53 +00:00
Jonathan BennettandClaude Opus 5 aa405226c2 Disarm the PMU_IRQ light-sleep wake after the sleep (#12143)
doLightSleep() arms PMU_IRQ with GPIO_INTR_LOW_LEVEL and, unlike every other
wake pin it arms, never disarms it. The low-level interrupt type and the
wake-enable bit are therefore still set in the GPIO registers once the node
is awake, and they stay set for as long as the chip keeps its GPIO
peripheral.

A panic or software reset does keep it: only a chip-level reset clears those
registers. Measured on a T-Beam S3 Supreme (ESP32-S3), reading
GPIO_PIN40_REG immediately before the first attachInterrupt() of each boot:
0x00000000 after a flash/chip reset, then 0x00002600 (int_type 4 low level,
wakeup_enable 1, int_ena set) on each of three successive CPU-only resets.
With this change the same recipe reads 0x00002100 (int_type 2 negedge,
wakeup_enable 0) on all three.

That leftover level arming is the precondition for the interrupt storm
#12131 fixed for DIO1: the first attachInterrupt() of a boot installs the
GPIO ISR service inside a critical section, and a level condition that is
already true re-enters gpio_intr_service with nothing able to clear it,
which ends in an interrupt WDT panic that only an EN reset recovers from.
On the boards that define PMU_IRQ that first attach is
attachPowerInterrupts(), which is where the panic in #12114 landed.

The storm itself was not reproduced here: PMU_IRQ is open-drain with an
external pull-up, and the pad read 0xff both as found and with no internal
pull on every boot, so the low-level condition was never true across the
attach. A VBUS insert or remove asserting the line in the window between
axpChipInit() clearing the PMU status and attachPowerInterrupts() would make
it true. This closes that window instead of relying on it staying narrow.

gpio_wakeup_disable() on its own leaves int_type behind - the #12131 lesson
- so the pin is also set back to an edge type, matching what the BUTTON_PIN
path above it does.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-13 16:55:19 +00:00
c7df2e85bc fix(traffic): round the default position dedup window to 6h, derive the floor from it (#11765)
The 5 h dedup window and the 6 h stationary broadcast floor were paired by
a hand-picked one-hour margin. Round the window to 6 h and derive the floor
from it: one dedup tick (6 min) above the window, which is the exact margin
a stationary node's refresh needs to clear its neighbours' tick-quantised
window regardless of tick phase. A static_assert enforces the pairing.

Role caps (tracker 1 h, lost-and-found 15 min) are unchanged. Saved configs
keep their value, as with the 11 h -> 5 h change in #11606; the new default
applies when traffic management config is first installed.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
2026-10-13 16:37:17 +00:00
AustinandClaude Opus 5.5 d5638e64d7 Fix rak_wismeshtap build: replace non-standard uint with int (#12152)
GCC 12.3.1's newlib no longer exposes the BSD `uint` typedef on nRF52,
breaking CannedMessageModule::keyForCoordinates on the only nRF52 variant
with USE_VIRTUAL_KEYBOARD. Use int, matching the Letter rect fields it
compares against.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:59:42 +00:00
Austin b48e1e0a2f Update RAKwireless RAK12034 library git ref (#12147)
These three variants were referencing the older (broken) library, where other RAKs had already updated to this fixed version. This PR aligns everything on the fixed version.
2026-10-13 01:11:41 +00:00
2148c6253a Fix native discovery recovery, retry backoff, and worker logging (#12142)
* Add native Portduino mDNS discovery via Avahi and Bonjour

* Guard native discovery in Portduino WASM builds

* Add linux dependencies in packaging, support "buildroot"

Adds libavahi dependencies for Debian, RPM, Docker and CI.
Moves the libavahi definition to the shared `build_flags` (which is only consumed on Linux targets)

* Fix platformio formatting

* fix(portduino): disable Avahi when daemon unavailable

* fix(portduino): retry native discovery and defer worker logs

* fix(portduino): preserve pending discovery warnings

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: vidplace7 <vidplace7@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:21:23 +00:00
Austin 2e69aecf19 nRF52: Update gcc to v12.3.1 (same as nRF54) (#12027)
Align the two nRF platforms on v12.3.1
Switch to forked RAK12035_SoilMoisture library for now
2026-10-12 21:44:55 +00:00
Jonathan BennettandClaude Opus 5 6e447f599e Don't light sleep while a USB host has the console open (#12141)
Before: an ESP32 with native USB, power saving on and a cable attached
drops off the USB bus within a couple of minutes and does not come back.
The host logs `device descriptor read/64, error -71`, `device not
accepting address`, `unable to enumerate`, and from then on the port is
gone - pressing the button wakes the screen, so the firmware is alive and
responding, but nothing re-triggers enumeration from the host side. Only
removing power restores it. A bench board was measured in that state for
over ten hours.

After: a node whose USB-serial/JTAG console is open by a host does not
light sleep. A node on a battery or a charger, with no host, sleeps
exactly as before.

No shipping chip keeps the USJ pad alive across a light sleep:
`SOC_USB_SERIAL_JTAG_SUPPORT_LIGHT_SLEEP` is undefined for the S3, so IDF
disables the pad for the duration of every one, and the capability is
commented out even on C6/H2. Meshtastic light sleeps in 30 s chunks with a
100 ms awake gap between them, so the device presents itself for ~100 ms
every 30 s, which is far below what enumeration needs. After a few
attempts the host gives up for good.

How: `lsIdle()` is the only entry to light sleep, so the veto goes there,
alongside the existing `doPreflightSleep()` one. The predicate is IDF's
`usb_serial_jtag_is_connected()`, which answers "a host has the port
open" rather than "a cable is in", compiled in only where the console is
really the USJ peripheral - the same `ARDUINO_USB_CDC_ON_BOOT` and
`ARDUINO_USB_MODE` pair SerialConsole.cpp uses, so a TinyUSB build is
untouched and every other target gets a `false`. It is frame-based, so a
host counts as attached for 5 s after the last frame seen; a single
missed frame must not be enough to cost the port. Not
`HWCDC::isPlugged()`, which flaps false while USB is fine (#11864).

The node stays in LS with the CPU awake for as long as the host is there,
rather than advancing to NB, which is the same shape as the preflight
veto: the state is unchanged, only the sleep is skipped.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 19:29:17 +00:00
1e3a38fc8a Add native Portduino mDNS discovery with Avahi and Bonjour (#12032)
* Add native Portduino mDNS discovery via Avahi and Bonjour

* Guard native discovery in Portduino WASM builds

* Add linux dependencies in packaging, support "buildroot"

Adds libavahi dependencies for Debian, RPM, Docker and CI.
Moves the libavahi definition to the shared `build_flags` (which is only consumed on Linux targets)

* Fix platformio formatting

* fix(portduino): disable Avahi when daemon unavailable

---------

Co-authored-by: Jake-B <jake-b@users.noreply.github.com>
Co-authored-by: vidplace7 <vidplace7@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:56:28 +00:00
Jonathan BennettandClaude Opus 5 b6c693eefe Create the radio sequence lock before any sequence can start (#12134)
Before: the radio-sequence mutex was created alongside the RX readout task,
inside startReceive(). lockRadioSequence() returns false on a null mutex, so the
first RadioSequence to wrap that call took nothing and recorded held == false,
while the task it had just created was already live and free to take the unheld
mutex. On SX126x the exposed window is the tail of SX126xInterface::startReceive()
after the base call - rxArmedContinuous = true, enableInterrupt(isrRxLevel0),
checkRxDoneIrqFlag() - so the first frame of a session could be read out from
under the arm that was still finishing. It is one window per boot, but the guard
documented the opposite invariant, which is what made it hard to see.

After: the mutex exists before any sequence can start, so every sequence in a
readout-task build holds it and the task cannot appear inside an unheld one.

How: xSemaphoreCreateRecursiveMutex() moves to the RadioLibInterface
constructor, under MESHTASTIC_RX_READOUT_TASK, and is freed in the destructor,
since initLoRa() destroys an interface whose init() failed. startRxReadoutTask()
now only checks that it exists. The lockRadioSequence() comment is corrected to
match.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 16:59:03 +00:00
Austin 8e4ccb029a Increase timeout for dput publish steps to 45 minutes (#12140)
`dput` PPA upload operations began showing their status properly after #11941, revealing that many uploads are reaching ~85% and then timing out.
This change increases the timeout for dput publish steps to prevent these failures.
2026-10-12 16:48:47 +00:00
Jonathan BennettandClaude Opus 5 e75d85f8be Drain pending notifications before standby on RF95 too (#12135)
Before: four of the five radio drivers handle any pending notification before
forcing standby, deferring only a TX whose backoff timer has not expired -
SX126x, SX128x, LR11x0 and LR20x0 all open trySetStandby() with
checkNotificationExcept(TRANSMIT_DELAY_COMPLETED). RF95 was never given it, so
on an SX127x board a notification already pending is serviced after
trySetStandby() has stood the chip down and detached the ISR, against a chip
that has left the state the notification describes.

After: RF95 drains like the other four, and a TX still waiting on its backoff
timer is left alone exactly as it is elsewhere.

How: checkNotificationExcept(TRANSMIT_DELAY_COMPLETED) as the first statement of
RF95Interface::trySetStandby(), with the same comment the other drivers carry.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 02:04:59 +00:00
Manuel b7513f4e68 fix: ESP32 light sleep crash due to DIO1 ISR storm (#12131)
* fix DIO1 storm

* fix rabbit
2026-10-11 19:21:11 -05:00
Austin 373bff393f Actions: Update/pin to Ubuntu 26.04 (#11941)
ubuntu-latest means we don't own the break (if it happens), always pin!
2026-10-11 20:46:20 +00:00
Austin 9015b2213f Update portduino to gnu++20 (#12069)
Upstream dependencies in the newest distros are beginning to require c++20.

This PR bumps all portduino-based targets to c++20, removes the shared c++17 build_flag, and adds it to each variant family where it is needed.
2026-10-11 19:02:14 +00:00
Thomas GöttgensandJason P bd951500b7 Select InkHUD font code page with the OLED_* language flags (#11869)
OLED_GR selects Win1253, OLED_RU/OLED_UA Win1251, OLED_PL/OLED_CS
Win1250, no flag Win1252. heltec_mesh_pocket returns to the Win1252
default. t5s3_epaper stays on Win1253 (18pt/24pt exist only there).

Co-authored-by: Jason P <applewiz@mac.com>
2026-10-11 03:48:37 +00:00
Jonathan BennettandClaude Opus 5.5 48d812536d LR20x0: empty the RX FIFO when a frame is dropped unread
The LR2021 receives into a FIFO that RadioLib empties only at the end of
readData(). Every path that gives up on a frame without reading it (the readout
task's bad length and full ring, the thread's early outs) cleared only the IRQ
flags, so the dropped frame's bytes stayed at the front of the FIFO and the next
frame was read out on top of them. On an X1, 37 of 37 forced drops were followed
by a "Decryptable packet failed decoding", so each drop cost two frames.

clearReadIrqs(), which every such path already calls, now also asks the driver
to discard the frame. LR20x0 sends CLEAR_RX_FIFO (private in RadioLib); the
other chips read from a buffer offset and need nothing.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9
2026-10-10 10:15:21 -05:00
Jonathan BennettandClaude Opus 5.5 1a1eee6ce4 Radio: read the airtime's chip state inside a radio sequence
computePacketTime() asks the chip for its packet type (getTimeOnAir()) on a
transmit, and for the header info on a receive the readout task did not
capture. Its callers - finishSentPacket() after every send, ReliableRouter's
retransmit timing, DMShell - hold no radio sequence, so a readout could land
inside the read. On the LR11x0/LR20x0 a read is two SPI transfers, so a readout
between them takes the other's reply.

r113's SPI probe saw radio SPI outside any sequence from the main loop task on
every board, 2 transactions per received frame on LR chips and 1 on SX126x,
which is one chip read per exchange on each. These are the per-send chip reads
on that path that were still unlocked (inferred; the next probe run confirms).

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9
2026-10-10 10:15:21 -05:00
Jonathan BennettandClaude Opus 5.5 e650149901 Radio: run the main loop's missed-IRQ poll as one radio sequence
pollMissedIrqs() runs from the main loop about once a second. Its
checkRxDoneIrqFlag() and checkTxDoneIrqFlag() read the chip's IRQ flags
without the radio-sequence lock, so the RX readout task could preempt one
mid-read.

On LR11x0 and LR20x0 that read is not self-contained. RadioLib's
LRxxxx::getIrqStatus() sets the Module-wide spiConfig.widths[STATUS] to 0
for its transfer and restores it after. A readout that lands in between
frames its own commands with that width, so its status bytes come back as
data. On an LR2021, GET_RX_PKT_LENGTH then returns the two status bytes as
the length (always 0x100 or more), and the frame is dropped as a bad length.
On an LR1110 the status byte is read as the packet type instead.

The bench caught one "RX readout task: ... 1 bad length" on an LR2021 running
develop, with the ring not full. Only a value that isn't a length can exceed
256 there.

Take the lock for the whole poll. It is recursive, and checkCadHandoffTimeout()
and checkStaleRxFlags() already take it inside.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9
2026-10-10 10:15:21 -05:00
Jonathan BennettandClaude Opus 5.5 dbc7daecc6 feat(portduino): Adafruit Arcade Bonnet input driver (#12116)
* feat(portduino): Adafruit Arcade Bonnet input driver

Add an InputBroker source for the Adafruit Arcade Bonnet: six buttons
(1A-1F) and a 4-way joystick on an MCP23017 I2C expander.

The expander is polled over I2C every 20 ms rather than serviced from its
interrupt line, because the bonnet ties INT to GPIO17, which is the HUB75
clock on the adafruit-hat mapping. The driver sets INT to open-drain and
enables no interrupt sources so the line never drives against the panel.

Buttons emit once per press and carry a gamepad button code in kbchar
(1E = BTN_SELECT, 1F = BTN_START, so Start pauses games). The joystick
emits directions with the same auto-repeat as LinuxJoystick. The analog
stick bits are ignored; they float with no stick attached.

Config:
  Input:
    ArcadeBonnet: 0x26
    ArcadeBonnetButtons:   # optional
      select: [1A, 1F]
      cancel: 1B

--check validates both keys. The action-name parser moves from
LinuxJoystick.cpp to InputBroker.h so both drivers share it.

Breakout now polls the bonnet joystick's held direction the same way it
polls the USB gamepad, so the paddle glides instead of stepping at the
auto-repeat rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vaqwGPoD64pqsnPYbvnUn

* fix(portduino): Arcade Bonnet buttons act in games; address review

1F reported BTN_START, which GamesModule treats as pause, so a bonnet
button bound to select paused Tetris and Breakout instead of dropping or
serving. 1E/1F now report BTN_C/BTN_Z; no bonnet button is Start.

From review:
- Clear the held joystick zones when an I2C read fails, so Breakout's
  paddle doesn't keep moving after the bonnet stops answering.
- Set Input.ArcadeBonnet only when the key is present, so a later
  config.d file with its own Input section doesn't disable the bonnet.
- Reject addresses outside the MCP23017's 0x20-0x27 in the driver and
  in --check, instead of truncating them to a uint8_t.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018vaqwGPoD64pqsnPYbvnUn

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:36:20 +00:00
9084439392 test(nodedb): drive the deferred node save from a real peer (#12114)
The deferred-save cases added in #11930 set the dirty flag through the test shim. These two go
through NodeDB::updateUser() with a new peer inside the one-minute throttle window, check the peer
is not yet in nodes.proto, then read nodes.proto back after the minute passes and after a reboot
and assert the peer is there under its name. Both fail if updateUser() stops marking the database
dirty.

Co-authored-by: marshallumsted <bot@marshallumsted.dev>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-10-10 00:39:24 +00:00
Jonathan BennettandClaude Opus 5.5 8c97a11613 Read received LoRa frames out from a FreeRTOS task (#11983)
* Read received LoRa frames out from a FreeRTOS task

The radio holds one received frame, and the next one overwrites it.
Until now the frame was read by the radio thread, which runs only when
the main loop gets to it, so a long hold anywhere in the loop (PKI key
derivation, serial console, display) lost the frames that ended during
it.

On FreeRTOS targets with a DIO1 interrupt the RX_DONE interrupt now
wakes a task above the main loop, pinned to the loop's core. It reads
the frame with the same RadioLib calls as before (readData, getSNR,
getRSSI) into an 8-frame ring, and notifies the radio thread, which
delivers the frames and re-arms RX as before. The interrupt stays
enabled, so frames that end back to back are all read.

It works for every RadioLib radio (SX126x, SX127x, SX128x, LR11x0,
LR20x0). -DMESHTASTIC_EXCLUDE_READOUT_TASK=1 turns it off. Portduino,
STM32WL and LORA_DIO1_SOFTWARE_POLL boards keep the thread readout.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Initialize the RX readout ring in the constructor

cppcheck's uninitMemberVar failed every FreeRTOS check job: rxRing is an
array of plain structs with no member initializers, so the constructor left
it indeterminate. The ring is only read behind rxRingTail != rxRingHead, so
nothing depended on its contents, but zeroing it costs nothing - the
interfaces are globals, so the array sits in .bss either way.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H

* Keep the RX readout task out of RadioLib call sequences

The readout task runs above the radio thread on the same core, and the SPI
lock is taken per transaction, so a readout could land between the RadioLib
calls of a sequence the radio thread was partway through. Every driver's
readData() ends by clearing the chip's IRQ flags, and SX128x's drops to
standby first, so a readout inside a channel scan, an RX arm, a transmit
setup or a reconfigure could clear flags that sequence was about to rely on,
or move the chip out from under it. The scan-to-transmit window in
TRANSMIT_DELAY_COMPLETED is the sharpest case: it is deliberately kept as
tight as possible, and startTransmit() would run on flags a readout had just
wiped.

Add a recursive, priority-inheriting mutex the task and the radio thread
share, held across each whole sequence: the two re-arms and the scan-to-
transmit pair in onNotify(), the recovery ladder, and each driver's
reconfigure(). Recursive because the sequences nest; priority-inheriting so a
waiting readout lifts the thread holding it rather than queueing behind it.
Packet delivery stays outside the lock - deliverCapturedFrames() makes no
RadioLib calls, and holding it there would delay the next readout for
nothing. This bounds a readout by the radio sequence in flight, not by the
main loop, so the latency the task exists to remove stays removed.

wakeRxReadout() loses its spin. It is called from inside driver RX arms,
which now hold the lock, so waiting there for a task that needs the same
lock would deadlock; the lock also makes the wait redundant, since the task
cannot reach the radio until the arm finishes. The guard records whether it
actually took the lock, because the mutex is created alongside the task from
within startReceive() - a sequence already in flight at that moment has
nothing to release.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H

* Do not clear an unread RX_DONE when resuming a continuous RX

resumeRunningReceive() cleared RX_DONE, CRC_ERR, HEADER_ERR and TIMEOUT
before picking the RX back up. In continuous RX the chip keeps listening, so a
second frame can complete while the first is being handled - between
readData() and the resume - and that clear took its RX_DONE without anyone
reading the frame. The checkRxDoneIrqFlag() on the next line could then no
longer find it, so the frame was lost.

The clear was there for the paths that give up on a frame without reading it,
which leave the flags latched where readData() would have cleared them; a
latched RX_DONE holds DIO1 high past the re-arm. Put the clear in those paths
instead, so it happens only where readData() never ran: not in rx mode, a bad
readback length, region unset, and readData()'s own early error returns (an Rx
timeout or a failed SPI read come back before it clears). Never on the
captured path, where the readout task has already read the frame and a latched
flag is the next one's.

With that, a latched RX_DONE at the resume is by construction a frame nobody
has read, so resumeRunningReceive() stops clearing anything and its
rxReadoutActive() special case goes away. Uses RadioLib's generic
PhysicalLayer clearIrq() rather than the SX126x flag names, so the helper
works for every radio.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H

* Clear the RX flags only where readData() left them latched

The previous commit cleared the terminal flags on every readData() error, which
put back the bug it was meant to fix, in the case that matters most. Each
driver decides a CRC or damaged-header verdict before its clear and returns it
afterwards - SX126x asserts crcState one line past clearIrqStatus(), and SX127x
has no early return at all - so those errors come back with the flags already
gone. A CRC mismatch is the routine error on a noisy channel, so that clear
fired often, and each time it could take the RX_DONE of a frame that completed
while the error was being logged.

Gate both on the returned code: RADIOLIB_ERR_CRC_MISMATCH and
RADIOLIB_ERR_LORA_HEADER_DAMAGED mean the flags are already clear, and every
other error - an Rx timeout, a failed SPI stream check or buffer read, a wrong
modem - returned before the clear and leaves them to be dropped by hand.

The readout task needs the same test for the opposite reason. When its own
readData() fails early the flags stay latched, and the radio thread cannot
clear them for a captured frame, because it has no way to tell whether the task
got past RadioLib's clear. So RX_DONE stayed set for a frame nobody would ever
read, and checkRxDoneIrqFlag() woke the task again for the same dead event,
round after round. The task now clears its own failed reads, where the answer
is known, and the ring-full path reuses the helper rather than repeating the
flag list.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H

* LR20x0: re-arm RX at TX_DONE from the readout task

After TX_DONE the LR2021 sat in standby until the radio thread restarted
RX, which a main-loop hold can stretch. Now the TX_DONE interrupt asks the
readout task to restart RX through RadioLib (an interrupt cannot call it),
and the radio thread adopts that RX instead of restarting it. On by
default wherever the readout task runs; -DLR2021_RX_REARM_AT_TX_DONE=0
opts out.

The task re-arms inside a radio sequence, and the thread's adopt runs
inside the post-TX sequence, so the two cannot interleave: if the thread
gets there first it takes the re-arm over. A stale re-arm from a TX that
the poll completed is cleared when the next TX is configured.

Measured on Jonathan's bench (Seeed Mesh-Tracker X1, LR2021, SHORT_TURBO,
DMShell workload): the X1 went from ~612 post-TX deaf windows per 300 s
run and a 3.7% board miss rate to better than the T1000-E (LR1110), which
already re-arms this way.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* LR20x0: read frames that end before the radio thread adopts the re-arm

The TX_DONE interrupt detaches the radio interrupt, and the task's re-arm
left it detached until the radio thread adopted the RX. During a long
main-loop hold the chip listened but nothing woke the readout task, so a
second frame overwrote the first in the chip.

The task now attaches the RX interrupt once its re-arm is in. Until the
thread handles that TX_DONE, a frame the task reads notifies ISR_RX
without overwriting the pending ISR_TX, which would otherwise skip
completeSending() and stall the TX queue; onNotify() delivers the
captured frames ahead of the ISR_TX as it already does for an overwritten
ISR_RX.

Found by CodeRabbit on #11983.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SX126x: re-arm RX at TX_DONE from the readout task

After TX_DONE the SX126x sat in standby until the radio thread restarted
RX, which a main-loop hold can stretch. Now, as on the LR20x0, the TX_DONE
interrupt asks the readout task to restart RX through RadioLib (an
interrupt cannot call it), and the radio thread adopts that RX instead of
restarting it. The task sends the same continuous or duty-cycled RX
command startReceive() would, now shared through startRxCommand().

On by default wherever the readout task runs except nRF52, where #11984
re-arms from the TX_DONE interrupt itself; -DSX126X_RX_REARM_AT_TX_DONE=0
opts out.

Measured on Jonathan's bench: the T-Beam Supreme (ESP32-S3, SX1262,
SHORT_TURBO, DMShell workload) loses less with it than without, in a
same-session A/B.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Take the radio-sequence lock around the IRQ-status read in canSendImmediately()

canSendImmediately() called isActivelyReceiving() with no lock, and that reads the
chip's IRQ status over SPI in every driver - getIrqFlags() on SX126x, getIrqStatus()
on LR11x0/LR20x0/SX128x, isReceiving() on RF95. The readout task can preempt the main
loop between that command and its response read, leaving the chip mid-command with
BUSY asserted; both then spin in RadioLib's BUSY wait until its 1000 ms timeout
fires, and the radio, which holds one frame, loses whatever arrives meanwhile.

Measured on a T1000-E (LR1110), 300 s sessions, two arms interleaved, 3 runs each,
with this lock and the same one on the LR11x0 early TX stage (a bench-only path not on
this branch): BUSY waits >=20 ms 5-13 -> 0, 1 s radio stalls 5/11/13 -> 0/0/0, frames
the chip decoded but the firmware never fetched 7/16/18 -> 0/0/0, total loss
0.59/1.23/1.30% -> 0.00/0.00/0.07%.

The sequence mutex is recursive, so a caller already holding it re-enters, and
lockRadioSequence() is a no-op without the readout task.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Hold the radio-sequence lock on the main loop's radio paths

The lock exists so a readout cannot land between the calls of a RadioLib
sequence, where it would clear flags the sequence is about to use. Three
paths reached from the main loop still made such sequences without it:

- checkCadHandoffTimeout()'s startReceive() after a handoff RX that never
  completed.
- checkStaleRxFlags(), which reads the IRQ flags, draws a verdict from
  them and then re-arms or clears - all of which a readout in between
  invalidates, since it retires the very flags being judged.
- resetAGC(), which is a sequence end to end: warm sleep, RC standby,
  CALIBRATE_ALL, the re-applied DIO2/boosted-gain/0x8B5 settings and the
  startReceive() that resumes. Its own mid-packet bail is a point check,
  so it cannot exclude a readout that starts just after it.

All three predate the readout task, so this is the task's own lock
coverage catching up rather than a regression.

Guarding resetAGC() at its call site in periodicRadioMaintenance() covers
every driver's override. It can hold the lock for the length of a
calibration, which delays a readout, but the chip is not readable for any
of that anyway - the RX that would read a frame is the startReceive() at
the end.

checkRxDoneIrqFlag() is deliberately left unguarded: it is a single flag
read and a wake, not a sequence, and serialising it behind a readout in
flight would only delay the handoff it exists to make.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd

* Readout task: re-arm after a CAD handoff's RX expires empty

With the readout task, an empty handoff RX raises TIMEOUT alone. The
task returned on the missing RX_DONE, so nothing re-armed the chip and it
sat in standby until checkCadHandoffTimeout() ran from the ~1 s missed-IRQ
poll. The task now hands that TIMEOUT to the radio thread, which logs
"CAD>RX empty" and re-arms; an ISR_RX that finds the handoff RX still
running leaves it alone instead of restarting RX over it.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Ask for continuous RX when the duty cycle has nothing to sleep through

RadioLib's startReceiveDutyCycleAuto(preambleLength, minSymbols) listens
for 2 x minSymbols and sleeps through whatever preamble is left over. We
pass preambleLength 16 (12 above 2 GHz) and minSymbols 8, so the sleep
window is zero or negative on every board and every modem preset: the
chip sits in continuous RX whichever of the two commands we send.

rxArmedContinuous recorded the command instead of the state, so it was
false everywhere but a CH341 host, and resumeRunningReceive() refused to
resume an RX that was in fact still running. Every RX_DONE therefore paid
for a standby and a full RX restart - the window in which a packet
already arriving is lost - for no power saved.

Have continuousRxWanted() ask for continuous RX in that case, which makes
rxArmedContinuous describe the RX the chip is really in and lets
resumeRunningReceive() pick it back up. If the preamble ever grows past
2 x minSymbols the duty cycle starts sleeping again and this goes back to
the duty-cycled command on its own.

Measured on the RAK4631, where resuming tied or slightly beat the TX_DONE
ISR re-arm. CH341 hosts are unchanged - they already took the continuous
branch. LR11x0/LR20x0/SX128x have no resumeRunningReceive() override, so
nothing changes for them.

The ISR_RX comment claimed rearmReceive() avoids the standby; that was
only ever true where the resume could fire. Say so.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd

* SX126x: re-arm RX at TX_DONE from the readout task on nRF52 too

The readout task's re-arm was held off nRF52 because the TX_DONE interrupt
there can re-arm RX itself, with raw SPI, which the task does with RadioLib
calls on every other platform. On the bench the two tied once the resume was
honest about the RX the chip was already in, so there is no reason to carry
two paths: one path for every board the task runs on is the alternative.

-DSX126X_RX_REARM_AT_TX_DONE=0 still opts out.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd

* LR11x0: re-arm RX at TX_DONE from the readout task

The LR11x0 was the one chip the readout task's TX_DONE re-arm skipped, so on
a T1000-E the chip still sat in standby from TX_DONE until the radio thread
next ran, which a main-loop hold can stretch by hundreds of ms. Same shape as
the LR20x0's: the interrupt marks a re-arm pending and wakes the task, the
task sends what startReceive() sends less the standby, and the radio thread
adopts the RX already running instead of restarting it. No raw SPI - the
interrupt only sets a flag, and the task re-arms with RadioLib calls under the
radio-sequence lock.

-DLR11X0_RX_REARM_AT_TX_DONE=0 opts out.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd

* LR11x0, LR20x0: resume a running continuous RX after a frame

Both drivers restart RX after every frame: a standby and the whole RX
setup again, deaf throughout. We arm RX with RX_TIMEOUT_INF, so the chip
is still listening at that point, and resumeRunningReceive() picks it back
up instead - what SX126xInterface already does.

The chip is asked first. Only a chip that reports RX is resumed: one that
left it unseen, through a reset or an RX that did not survive the frame,
would otherwise stay deaf with nothing left to re-arm it, and the restart
also runs the chip-state recovery. RadioLib's LRxxxx::getStatus() is
protected, and reads its reply at the wrong offset, so readChipMode()
sends the same NOP transfer itself and takes the mode out of Stat2.

-DLR11X0_RESUME_CONTINUOUS_RX=0 and -DLR2021_RESUME_CONTINUOUS_RX=0 opt
out and take the restart every time.

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

* Drop the per-chip opt-out flags for the re-arm and the resume

SX126X_RX_REARM_AT_TX_DONE, LR11X0_RX_REARM_AT_TX_DONE,
LR2021_RX_REARM_AT_TX_DONE, LR11X0_RESUME_CONTINUOUS_RX and
LR2021_RESUME_CONTINUOUS_RX were bench levers. Nothing in the tree sets any
of them to 0, and neither path needs a build-time escape hatch: a re-arm that
the chip does not take falls back to the radio thread's checked
startReceive(), and a resume only happens when the chip reports it is still
in RX, taking the restart otherwise.

So both are now unconditional wherever the readout task runs, and the gates
collapse to #ifdef MESHTASTIC_RX_READOUT_TASK. LR11X0_READ_CHIP_MODE and
LR2021_READ_CHIP_MODE went with them, having existed only to compile
readChipMode() out when the resume was off.

MESHTASTIC_EXCLUDE_READOUT_TASK stays: one switch for a board that cannot
afford the task. So does the MESHTASTIC_RX_READOUT_TASK condition itself,
which is what keeps the re-arm off Portduino and software-polled DIO1, where
there is no task to re-arm from.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VVTg7Na1pVvCcwZoSv9Hnd

* Guard updateNoiseFloor()'s chip reads, and clear a handoff's error flags

Two findings from CodeRabbit's re-review of #11983.

updateNoiseFloor() runs from the main loop on every iteration and made three
RadioLib calls - isActivelyReceiving(), isIRQPending() and getCurrentRSSI() -
with no RadioSequence, which is the same gap d2fb16a closed for
checkCadHandoffTimeout(), checkStaleRxFlags() and resetAGC(), and the one
canSendImmediately() was wrapped for. The readout task runs at a higher
priority and can land between any two of them, splitting a command and
wedging BUSY for RadioLib's whole timeout. The three now sit in one
RadioSequence block, with the RSSI validation left outside it.

The throttle check also moves ahead of the lock. It was behind the chip
reads, so every main-loop iteration paid for them to reach a check that
discards the sample; now the skipped iterations cost nothing and do not
contend for the sequence lock.

readOutFromTask() returned without clearing anything when a DIO1 edge
carried no RX_DONE. A CAD handoff's RX arms with CRC_ERR and HEADER_ERR in
its DIO1 mask on SX126x, LR11x0 and LR2021, and a damaged header raises
HEADER_ERR with no RX_DONE behind it: the task woke, found no RX_DONE,
cleared nothing and left the flag latched, so on an edge-triggered line the
next frame's RX_DONE raised no edge at all. checkStaleRxFlags() steps aside
while cadHandoffRxStart is set, so nothing noticed until
checkCadHandoffTimeout() fired at twice the maximum packet airtime. The
error flags are now retired there, inside the handoff window only - plain RX
does not route them to DIO1, and checkStaleRxFlags() needs HEADER_ERR left
alone to spot a wedged SX1280 RX.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H

* Don't strand a TX timer, and don't adopt an RX a reconfigure voided

Four fixes from a re-review of the readout task.

onNotify()'s ISR_RX case had two early breaks in its readout branch, both of
which skipped the trailing setTransmitDelay(). There is one notification slot,
and readOutFromTask() notifies with overwrite set whenever no TX is in flight,
so reaching onNotify() at all can mean a pending TRANSMIT_DELAY_COMPLETED was
discarded. Nothing else re-arms that timer: setTransmitDelay() runs from send()
and from the tail of these two cases, so a queued packet stranded this way
waited for an unrelated send() or radio interrupt. The breaks become a
rearmWanted flag, leaving the re-arm conditional and setTransmitDelay()
unconditional. Scheduling it on those two paths is safe - the
TRANSMIT_DELAY_COMPLETED handler re-delays when the radio turns out to be busy.

adoptReceiveArmedFromIsr() trusted rearmState, which the TX_DONE interrupt sets
before the radio thread runs. But onNotify()'s ISR_TX sequence calls
RadioTxHooks::beforeTransmit() first, and the beacon hook's
reconfigureForBeaconTX() reaches reconfigure(), which standbys the chip and
reprograms the modem. The task's arm is gone, rearmState still says ARMED, so
adopt reported true and onNotify() skipped its startReceive(). Worse, adopt's
own RadioLibInterface::startReceive() then cleared rxOffline and
chipRecoveryFailures for a chip that never took an RX - and rxOffline is what
periodicRadioMaintenance() gates its retry on, so a chip left deaf by a failed
reconfigure, or by a post-reconfigure arm that errored, had its recovery path
switched off. The three drivers that own rearmState now clear it in
reconfigure(). A reconfigure that succeeds ends in its own startReceive(), so
the only cost is one extra arm on the beacon-TX path, where correctness is
worth more than the standby.

sleep() made trySetStandby() and the chip's sleep command as an unguarded pair
in all five drivers, so a readout could land between them and talk to a chip on
its way down. Each now takes the sequence lock. The comment claiming interrupts
are already disabled there was wrong - disable() and NodeDB's config-reload
park both reach sleep() with the ISR attached - and now says what actually
holds the readout task off.

LR11x0Interface and LR20x0Interface's rearmReceiveFromIsr() overrides lacked
INTERRUPT_ATTR although isrTxLevel0() calls them, unlike the SX126x override
and the base-class ISR helpers. Added, so they are placed in RAM on the
architectures that care.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CuWbtYhL797NBX6kjw449H

* Read a captured frame's LoRa header info with the frame, not after it

The radio thread accounts for a frame the readout task took by asking the
chip for the LoRa header's coding rate and CRC flag, from
deliverCapturedFrames(), outside the radio-sequence lock. By then the chip
is back in RX - with the continuous-RX resume it never left it - so
GetLoRaPacketStatus describes whatever it is receiving now rather than the
frame being accounted for, and that read can also land inside a sequence
the readout task is running.

On an LR2021 that shows up as "Bogus RX coding rate 9 from radio" on about
a third of received frames once the resume is on, and on none without it.
Nothing is lost - computePacketTime() falls back to the configured rate,
which is the right one - but the airtime estimate is taken from a reading
that does not belong to the frame.

Read it in readOutFromTask() instead, with the frame's SNR and RSSI and
under the same lock, carry it in CapturedRxInfo, and have
computePacketTime() use the captured value for a captured frame. The
thread readout path still reads the chip, where the chip still holds that
frame's status.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014NeGVaLzYEgfHdj2hcXiSr
(cherry picked from commit acdb14a9b5e5886115c9e8215573d288a8c1ed0d)

* Reach the RF95 driver through its pointer when reading header info

RF95Interface holds its driver in a std::unique_ptr, so the header-info
override has to go through lora-> like the rest of the file does. The
value-held form took out every build.

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

* SX126x: drop the duty-cycled RX arm, which is unreachable

startRxCommand() chose between a continuous RX and
startReceiveDutyCycleAuto(), and continuousRxWanted() answered that choice
partly from the preamble: RadioLib's duty cycle stays awake for
2 x minSymbols and sleeps through whatever preamble is left, so a preamble
no longer than that window leaves nothing to sleep through.

preambleLength is only ever preambleLengthDefault (16) or, above 2 GHz,
wideLoraPreambleLengthDefault (12), set in applyModemConfig() and written
nowhere else. Both are inside the 16-symbol wake window, so the predicate
was true in every region and every preset and the duty-cycled branch could
not be reached. The comment on it, about saving power by letting the radio
sit in standby mostly, described something that never happened.

Send the continuous RX outright and set rxArmedContinuous unconditionally.
A static_assert on the two preamble constants fails the build if a preamble
ever outgrows the wake window, which is when the duty-cycled arm and a flag
recording which command was sent would have to come back.

No behaviour change: the command on the wire is the one the chip was
already getting.

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

* LR20x0: empty the TX FIFO of leftovers before each transmit

The LR2021's TX FIFO appends, and RadioLib's TX staging writes the payload
without clearing it first. A TX that ends without sending everything it
staged leaves those bytes behind, and the next SET_TX sends them ahead of
the new payload, at the new payload's length. The remainder stays behind
in turn, so every later packet goes out shifted.

On the X1 bench (r108) that is what the uplink collapse looked like: a
45-byte packet went out at the next packet's 59 bytes, carrying the first
14 bytes of that next packet's header, and the server could not decrypt
it. Seen only with the continuous-RX resume on, which is what cut the TX
short is not yet known.

Before each transmit, read the TX FIFO level, warn if it is not empty, and
clear it then (or whenever the read fails). One cut-short TX now costs
that packet only, and the warning marks where it happened. RadioLib keeps
GetTxFifoLevel and ClearTxFifo private, so both go out as raw SPI.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Radio: don't start a delayed transmit from inside a standby

Every driver's trySetStandby() calls checkNotification() first, to handle
any pending radio interrupt before forcing standby. But notifyLater()
stores its notification at once and only delays the wake-up, so while a
packet waits out its transmit delay, TRANSMIT_DELAY_COMPLETED is already
sitting in the slot. A standby reached from outside the radio thread's own
notification handling consumes it: the scan and transmit run right there,
ahead of the delay, and the standby that follows cuts the transmit off a
few ms into its airtime while it reports as sent.

pollMissedIrqs() is such a path, from the main loop: its CAD-handoff
timeout and its stale-header re-arm both call startReceive(). On an X1
(LR2021) running #11983 the bench caught it directly: CAD>RX timeout, CAD
arm, CAD free, Started Tx, then a standby 3 ms into a 25 ms frame. RadioLib
7.7.1 never clears the LR2021's TX FIFO, so the unsent payload went out
ahead of every later packet and the node's uplink collapsed.

The standby now handles every pending notification except
TRANSMIT_DELAY_COMPLETED, which stays pending for its own wake-up.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:40:29 -05:00
40a0178157 fix(nodedb): keep deferred node saves pending instead of dropping them (#11930)
* fix(nodedb): persist newly discovered nodes

saveNodeDatabaseToDisk() is reached from the deep sleep path (sleep.cpp),
the shutdown path (Power.cpp) and the classic screen's menu handler. A
node that runs with power.is_power_saving disabled and a UI other than the
classic screen reaches none of them, and a graceful reboot does not save
either: AdminModule::reboot() only arms rebootAtMsec, and Power::reboot()
notifies observers (only InkHUD listens) before ESP.restart().

nodes.proto therefore keeps whatever was written on first boot, usually
just the local node, and every peer learned since is dropped on restart.
Because the peer's public key lives in its node entry, PKI direct messages
to that peer then fail until it is heard again:

  Loaded saved nodedatabase v25: 1 nodes
  Received a NAK, error reason: PKI_SEND_FAIL_PUBLIC_KEY

Save when a node is first added, rate limited to once a minute.

* fix(nodedb): keep deferred node saves pending instead of dropping them

The previous commit misread the save paths: updateUser() already saves
the node DB when a peer's user info changes. But it saves at most once a
minute, and a change inside that window is dropped, not deferred: it logs
"Defer NodeDB saveToDisk" and nothing retries it. lastNodeDbSave starts
at 0, so every change in the first minute after boot is dropped, and a
graceful reboot has no save point. A later save writes the whole DB and
catches up, so the loss shows when nothing else changes before the
restart.

This removes the save on admission (nodes without user info are dropped
by cleanupMeshDB() at boot anyway) and instead:

- marks the node DB dirty when updateUser() records a change;
- clears the flag only after nodes.proto is actually written, so a save
  that was skipped or deferred (no keypair yet, xmodem busy, unsafe
  power) stays pending;
- retries a pending save from the main loop as a single attempt that
  shares updateUser()'s lastNodeDbSave budget, so no new write trigger
  is added and the worst case stays one write a minute;
- saves a pending change from a notifyReboot observer on graceful reboot.

Addresses both CodeRabbit findings on #11930.

* fix(nodedb): skip the deferred node save while encrypted storage is locked; test it

saveNodeDatabaseIfDirty() and the reboot observer call saveNodeDatabaseToDisk() directly, so they
missed the locked-storage skip in saveToDiskNoRetry() and logged a failed encrypted write every
minute on a locked MESHTASTIC_ENCRYPTED_STORAGE build. Both now apply the same check.

test_nodedb_save_retry pins the deferred save: a change held back by the one-minute throttle is
written once the minute passes, and a reboot writes it at once.

---------

Co-authored-by: marshallumsted <bot@marshallumsted.dev>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-10-09 12:01:07 +00:00
AustinandManuel e09cec6ada Update pioarduino to v3.3.12-1 (#12052)
* Update pioarduino to v3.3.12-1

Updates pioarduino platform and arduino-esp32 to v3.3.12, a minor bump from current v3.3.11
https://github.com/espressif/arduino-esp32/releases/tag/3.3.12

Meshtastic's fixes have been integrated upstream, so move off of our fork and back to official releases.

* update indicator to 3.3.12

---------

Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
2026-10-09 11:22:31 +00:00
Jonathan BennettandClaude Opus 5.5 e5998edcb3 Bound RadioLib's BUSY wait to 50 ms (#12105)
RadioLib waits up to spiConfig.timeout (1000 ms) on the chip's BUSY line before failing
a command, and the firmware never set it. A chip left mid-command holds BUSY until that
wait gives up, so each collision cost a whole second of a deaf radio and the frames that
arrived in it.

MESHTASTIC_RADIOLIB_SPI_TIMEOUT_MS (default 50; 0 keeps RadioLib's 1000 ms) is applied
after begin() in the SX126x, SX128x, LR11x0 and LR20x0 init, so the boot calibration
keeps RadioLib's default. Recovery re-inits run under the bound.

Measured on a T1000-E (round 100, before the locking fix): the 50 ms arm removed every
one-second block (21 to 0) with no re-inits, recoveries or reboots. Waits were bimodal,
one 32 ms boot calibration per run and the rest at the 1000 ms ceiling, so no wait that
would have completed falls between the bound and the old timeout.



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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:34:20 +00:00
Jonathan BennettandClaude Opus 5 315ec0e4ed DMShell: an on-board test client for the remote shell (#12111)
* DMShell: an on-board test client for the remote shell

A DMShell session is normally driven by bin/dmshell_client.py over the API,
which puts the test's own traffic on the serial port it is logging. This
builds the client into the firmware instead: -DDMSHELL_TEST_PEER=0x<node>
gives the board a client that opens a session to that node and types a
workload itself, so a run needs no host and the serial port stays in
logging mode for its whole length.

It speaks the same protocol as the Python client, on the sequence and
replay bookkeeping already in DMShellRecovery.h, and keeps the same
per-session counters so the two can be compared directly.

Unset, nothing is built and nothing is linked: the flag guards both source
files and the one hook in Modules.cpp. DMSHELL_TEST_* set the workload (the
command, keystroke interval, session length and count, how keystrokes are
batched into frames, the input window). DMSHELL_TEST_SLOT_PARITY, when set,
stamps slot_parity on every packet it sends.

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

* DMShellTest: require PKI on the peer's frames

The test client accepted any decoded packet on the remote shell port whose
`from` matched its peer. A channel-encrypted packet can claim any `from`, so
a forged CLOSED with the live session id would end a session and a forged
`ack_seq` would move the send window. The server already refuses a frame
without PKI; the client now does the same.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-09 03:34:00 +00:00
Jonathan BennettandClaude 364a111f4a Uplink PKI DMs the gateway relays but cannot decrypt to MQTT (#12110)
Since the packet authenticity gate (d6b12ea3f), a PKI DM between two other
nodes decodes as DECODE_OPAQUE, so perhapsHandleReceived() relays it and
returns before dispatchReceived(). That was the only place such a DM was
marked pki_encrypted and published on the PKI topic, so a gateway no longer
uplinked DMs (or the PKI-encrypted ACKs answering them) from its mesh, and
DMs between mesh and MQTT stopped completing.

Publish those DMs from the opaque path instead, under the same conditions as
before (MQTT and its encryption enabled, channel 0, unicast, not to or from
us, not via MQTT). A small (from, id) ring keeps the copy heard from each
relay from being published again, since opaque frames stay out of
PacketHistory. The now-unreachable DECODE_OPAQUE branch in
dispatchReceived() is removed.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-08 16:23:26 +00:00
Jonathan BennettandClaude Opus 5 015d5b1231 SX126x: re-stage a TX in full after the modem params move (#12101)
RadioLib's TX staging ends in fixSensitivity(), which programs the
sensitivity register from the configured bandwidth (SX1262/SX1268
datasheet 15.1); setBandwidth() updates bandwidthKhz and the modulation
params but does not redo it, which is why RadioLib applies it per
transmit. The CH341 launch path skips the register fixes on the grounds
that an earlier staging set them and the chip keeps them, and that holds
until the bandwidth itself changes: a preset switch into or out of
500 kHz left the register at the old bandwidth's value for every later
prestaged TX. reconfigure() now drops txStagedByRadioLib and both staged
payloads, so the next TX goes through RadioLib's full staging. The
recovery path already did this via reinitChip().


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 00:24:53 +00:00
Iris 6f0aec5ef6 rename lora-frametastic to lora-usb-frametastic (#12104) 2026-10-07 22:54:14 +00:00
Jonathan BennettandClaude Opus 5 c97e89f84a fix(boards): add the missing openocd_target to t-impulse-plus (#12034)
PlatformIO's upload-port autodetection enumerates boards from every installed
platform, not just the one being built. platform-raspberrypi's
_add_default_debug_tools() acts on any of cmsis-dap, raspberrypi-swd and
picoprobe that a board lists in upload.protocols, and asserts that
debug.openocd_target is set for each of them.

t-impulse-plus lists cmsis-dap but has no openocd_target, so that assert fired
during port detection and aborted `-t upload` for every environment in the
repo. Flashing an ESP32-S3 board failed on an nRF52840 board's manifest:

    *** [upload] AssertionError : Missing target configuration for t-impulse-plus

The manifest has been missing the key since #10497; it only started biting when
the platform pin moved in #11814. Use nrf52840-mdk-rs, as the other thirty
nRF52840 boards in boards/ already do. It is the only board in the tree that
trips this.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 21:10:00 +00:00
James Rich 023164a7ea Clamp an imported private key before deriving its public key (#12095)
Curve25519::eval uses the scalar as given while XEdDSA::priv_curve_to_ed_keys
clamps it, so a private key set through admin, an app or a backup restore
without clamping derived an X25519 public key that disagreed with the signing
key, and peers rejected every signature the node sent. regeneratePublicKey now
clamps the key in place, so the stored key, public key and node number agree.
2026-10-07 12:04:13 -05:00
95c57c27b2 ch341 server fixes (#12016)
* LoRa LBT: fix SX128x/SX126x CAD encoding + slot model, ESP32 sleep race

Three fixes from the "are we really listening?" LBT audit:

- SX128x CAD: pass RADIOLIB_SX128X_CAD_ON_4_SYMB instead of the raw count 4.
  The raw value set bits 7:5 = 000, i.e. a 1-symbol scan, leaving LBT nearly
  blind on 2.4 GHz. Now runs the intended 4-symbol CAD.

- SX126x CAD / slot time: name the symNum value explicitly as CAD_ON_4_SYMB
  (identical 0x02 raw value, so no on-air change) and add a virtual
  getCadSymbolCount() so computeSlotTimeMsec() models the real ~4.5-symbol
  scan for the SX126x/LLCC68/STM32WL family only, leaving LR11x0/SX127x at
  their correct 2.5 symbols. Fixes the too-short contention-window slots.

- ESP32 light sleep: if the DIO1/RF95_IRQ line is already asserted just before
  esp_light_sleep_start(), skip the sleep so the main loop services the pending
  packet instead of stranding it for up to a full sleep chunk (the
  detachInterrupt-clears-the-GPIO-wake disarm race).

clod helped too

* LoRa LBT: keep software-poll preamble flags latched while idle (anti-stomp)

Finding #10 from the "are we really listening?" audit, LORA_DIO1_SOFTWARE_POLL
boards (meshnology-w10).

The 1 ms poll cleared latched PREAMBLE_DETECTED|HEADER_VALID whenever they were
the only bits set, so for the rest of a reception isActivelyReceiving() read idle.
A TX queued during the payload phase then passed the send gate and startTransmit
overwrote the shared chip buffer, losing the inbound packet.

That clear only exists to stop a noise preamble from permanently blocking our own
TX (starvation). Starvation can only happen when we actually have something to
send, so gate the clear on a non-empty txQueue. While the queue is empty the bits
stay latched, isActivelyReceiving() reports the in-progress packet, and a later TX
defers instead of stomping it. A queued TX still gets the escape hatch. The poll
cadence is a fixed 1 ms reschedule either way, so latched bits don't spin the loop;
readData() clears everything once a real RX_DONE arrives.

clod helped

* LoRa LBT: optional RSSI energy second-gate before TX (default off)

Findings #4/#5 of the "are we really listening?" audit.

CAD only detects a preamble. A neighbour's packet whose preamble already aired -
during our own TX, a CAD scan, or a standby gap - is mid-payload and invisible to
CAD, so we key up on top of it. Add an RSSI energy check as a second listen-before-
talk gate in the TX-decision path: channelBusyByRSSI() compares the instantaneous
in-RX RSSI against the rolling noise floor (getNoiseFloor() + margin) and defers if
the channel is hot. It reads RSSI before isChannelActive()'s setStandby() via the
|| short-circuit, so the sample is taken while still in RX where GET_RSSI_INST is
valid - no mode juggling.

Orthogonal to CAD: CAD has processing gain and catches weak preambles below the
noise floor; RSSI catches strong energy at any packet phase, including the mid-
payload case CAD is blind to. Neither subsumes the other.

DEFAULT OFF. A mis-set threshold makes a node read "busy" on noise and stop
transmitting entirely, which is worse than the bug, so it must be tuned on hardware
before it ships live. Compiled out unless LORA_RSSI_LBT_MARGIN_DB is defined at
build time; when off it is a compile-time `return false` (zero behaviour change).
Fails open: returns not-busy unless in RX with a full noise-floor window and a sane
dBm reading, so it can never persistently silence a node.

Native build (gate off) links clean.

clod helped

* LoRa LBT: RSSI margin profiler to tune RSSI_LBT_MARGIN_DB (default off)

Instrumentation to pick the #4/#5 RSSI second-gate margin from measured data
instead of guessing. Build with -DLORA_RSSI_LBT_PROFILE; compiled out otherwise.

It accumulates (RSSI - rolling noise floor) in two channel states:
- idle   (reuses updateNoiseFloor()'s existing RX sample, no extra SPI): the noise-
         jitter distribution. Its high tail is the floor the margin must clear.
- signal (decoded-packet RSSI, hooked in handleReceiveInterrupt): the real-traffic
         rise. Its low tail is the ceiling the margin must stay under.
A throttled log dump prints both distributions (min/mean/max + percentiles) and the
derived usable window (idle p99, signal p05).

Placement: the stats/histogram engine is a dependency-free, radio-agnostic header
(RssiProfiler.h) so it stays out of the core file and is testable on its own; the
two hooks and the RSSI/log I/O live on RadioLibInterface where the state is.

Independent of the gate flag (LORA_RSSI_LBT_MARGIN_DB) on purpose: profile first to
choose the margin, then build the gate on. Native build (profiler on) compiles clean.

clod helped

* docs: describe the RSSI LBT margin profiler

Feature doc for the RSSI listen-before-talk margin profiler
(-DLORA_RSSI_LBT_PROFILE): what it measures (idle noise-jitter vs decoded-signal
distributions of RSSI-minus-noise-floor), how it's wired (RssiProfiler.h engine +
RadioLibInterface hooks, idle sample reused from updateNoiseFloor), how to build and
run it, and how to read the log output to choose RSSI_LBT_MARGIN_DB.

clod helped

* LoRa LBT: draft #3/#6 fixes behind MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE (inactive)

Firmware side of the CAD->RX handoff (#3) and ISR_RX re-arm (#6) from the LBT
audit. Both need to re-arm RX WITHOUT the standby() that startReceive() forces
(which aborts an in-flight reception) - unavailable in stock RadioLib, provided by
resumeReceive() in NomDeTom/RadioLib@rxResume.

Guarded by MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE, left UNDEFINED: against stock
RadioLib these paths compile out and behaviour is unchanged. Define it (via
build_flags) only when linking a RadioLib that has resumeReceive().

- resumeReceiveInPlace(bool startRx): base-class helper - iface->resumeReceive()
  (remap RX IRQ, optional SetRx, no standby) + re-attach the MCU ISR + base RX
  bookkeeping. One place, chip-agnostic via iface.
- #3: SX126x/LR11x0 isChannelActive() CAD exitMode -> GOTO_RX; the TX-decision path
  splits the busy check into rssi/cad flags and, on a CAD detect, hands the packet
  to RX via resumeReceiveInPlace(false) instead of startReceive(). Macro-off, the
  split is exactly the old `channelBusyByRSSI() || isChannelActive()` short-circuit.
- #6: ISR_RX re-arms via resumeReceiveInPlace(true).

Open (flagged in-code, for review + HW validation): whether re-issuing SetRx (#6)
disturbs an on-chip in-flight reception, and the #3 handoff's interaction with a
beacon-target packet on the home config.

clod helped

* LoRa LBT #4: synthesize GPIO wake cause when light sleep is skipped

doLightSleep() skips esp_light_sleep_start() when a LoRa IRQ is already
asserted, but still read esp_sleep_get_wakeup_cause() unconditionally - so
the skip path returned the previous real sleep's cause (e.g. TIMER) and
missed the ESP_SLEEP_WAKEUP_GPIO branch that sets runASAP. Capture whether we
skipped and return a synthetic GPIO cause in that case so PowerFSM takes the
packet-service path. Reads the ESP-IDF cause only after an actual sleep.

clod helped too

* LoRa LBT #10: keep HEADER_VALID latched in the software IRQ poll

The LORA_DIO1_SOFTWARE_POLL anti-starvation escape hatch cleared both
PREAMBLE_DETECTED and HEADER_VALID whenever a TX was queued. HEADER_VALID is
evidence of a real inbound frame, so clearing it made isActivelyReceiving()
read idle mid-payload and let the queued TX stomp the frame. Gate the hatch on
a bare-preamble IRQ (PREAMBLE set, HEADER_VALID clear) and clear only
PREAMBLE_DETECTED. A stuck header still can't block TX forever - receiveDetected()
ages it out after maxPacketTimeMsec - and readData() clears everything on RX_DONE.

clod helped too

* LoRa LBT #3/#6: CAD->RX handoff + continuous-RX re-arm on stock RadioLib

Reimplements the two audit fixes that previously needed the NomDeTom/RadioLib
@rxResume fork (MESHTASTIC_RADIOLIB_HAS_RESUME_RECEIVE), using only stock
RadioLib, behind a new default-off flag MESHTASTIC_LBT_CAD_TO_RX. Per the
RadioLib maintainer's feedback, no new RX API is needed.

(GOTO_RX) with the RX IRQs pre-mapped in the scan config, so the chip's own
CAD->RX transition delivers RX_DONE with no library call in between. irqFlags
(status-enable) carries the CAD verdict + full RX set incl. PREAMBLE/HEADER so
isActivelyReceiving() can read them; irqMask (DIO trigger) carries the CAD
verdict + terminal RX events only - NOT PREAMBLE/HEADER, which would fire the
ISR mid-frame and run readData() on an incomplete packet. On detection it hands
off in place: clear the latched CAD verdict, re-attach the MCU ISR, mark
receiving - no standby (which would abort the packet just detected).

standby), so re-attach the MCU ISR instead of re-issuing SetRx (which could
disturb a second packet already arriving on-chip).

Removes resumeReceiveInPlace() and the fork macro. SX126x-only: LR11x0's
startChannelScan conflates irqFlags/irqMask (drives the DIO from irqFlags), so
it keeps the safe GOTO_STDBY path, re-arming on detect to stay coherent with
onNotify when the flag is on.

Default-off: needs on-hardware validation and assumes continuous RX (the
default preamble=16/minSymbols=8 degenerates startReceiveDutyCycleAuto to
continuous). Native builds pass both flag-off and flag-on.

clod helped too

* LoRa LBT #1: re-arm RX unconditionally when deferring a busy channel

The post-CAD/RSSI defer path wrapped its startReceive() re-arm in a
!hasTargetRadioSettings(txp) guard, so a beacon-target packet that hit a busy
channel took setTransmitDelay() with the radio left in standby. When the
beacon's target config coincides with the home config the radio is on the home
channel, so the node went deaf on home traffic until the next TX cycle - and
kept skipping the re-arm every cycle the channel stayed busy. Drop the guard:
re-arm unconditionally and keep listening while we wait to key up. A beacon's
job is to get a packet out, but it should still mirror the main RX path.

(No effect under MESHTASTIC_LBT_CAD_TO_RX, where the chip is already left in RX.)

clod helped too

* docs: fix RSSI profiler fences (MD040) + RAM figure, note negative-bin limit

- Tag the formula and sample-output fenced blocks as `text` so markdownlint
  MD040 passes.
- Correct the RAM figure: RssiDeltaStats is 148 bytes (12 scalar + 34*4
  histogram), so two instances are ~296 bytes, not ~280.
- Add a limitations note that all negative deltas collapse into bin[0], so a
  percentile landing there returns the minimum sample. This does not affect the
  margin (keyed off the always-positive idle p99) but can understate signal p05
  when a node decodes below-floor packets; never biases toward a smaller margin.

clod helped too

* docs: RSSI profiler - record histogram bucketing design choices

Add a "Histogram bucketing" section laying out why a fixed histogram is used
over exact percentiles, what the axis has to resolve (the margin window sits at
roughly -a few..+20 dB), and the arguments for five bucketing schemes: the
current positive-only layout (A), a cost-neutral re-centred layout with
negative-range resolution (B, recommended if signal p05 must be trusted),
wider full-range bins (C), more bins (D), and exact/off-box (E). Explains the
current choice and when to switch.

clod helped too

* LoRa LBT: move RSSI profiler + energy second-gate to listening-now-rssi

Split the RSSI work off this branch so the active CAD/sleep fixes and the #3/#6
CAD->RX rework can land without the tuning-dependent RSSI pieces. Removed here
(preserved intact on branch listening-now-rssi):
  - RSSI margin profiler: RssiProfiler.h, the LORA_RSSI_LBT_PROFILE hooks, and
    docs/rssi_lbt_margin_profiler.md.
  - RSSI energy second-gate: channelBusyByRSSI() and the LORA_RSSI_LBT_MARGIN_DB
    gate.

The pre-existing noise-floor sampler (updateNoiseFloor/getNoiseFloor, already on
develop and consumed by telemetry) is untouched. The TX-decision block reverts
to a plain CAD scan; #3/#6 (behind MESHTASTIC_LBT_CAD_TO_RX) and #1 unchanged.
Native build green.

clod helped too

* reduce to bare essentials

* LoRa LBT: calibrate CAD symbol count and detPeak against vendor data

NUM_SYM_CAD carried two incompatible meanings: computeSlotTimeMsec() read it
as a literal symbol count, while the drivers passed it into scanChannel(),
where symNum is a register encoding on SX126x/SX128x. On SX126x the raw 2
encodes CAD_ON_4_SYMB, so the chip has scanned 4 symbols since #5749/#5772
while the slot time was priced at 2.5 - a 1.8x mis-scaling of the CSMA
backoff unit, live since January 2025.

Give the constant one meaning (a literal count of 4) and have each driver
translate at its own call site. getCadSymbolCount() reports the real scan
length so slotTimeMsec matches it; RF95 overrides it because SX127x CAD is
fixed in hardware and its legacy scanChannel() takes no symNum.

AN1200.48 Table 1 recommends 4 symbols for SF9-SF12, which is where most
shipped presets sit, so 4 is the right target. The comment claiming 2 was
reading that note's SF7/SF8 row.

detPeak is calibrated per (SF, symbol count), and on LR11x0 per bandwidth
as well. Both LR drivers ran thresholds meant for a 2-symbol scan, leaving
them less sensitive than measured-best and biased toward missed detections.
Now sourced explicitly:

  LR20x0  LR20xx datasheet rev 2.2 Table 6-19, indexed by symbols x SF
  LR11x0  Semtech SWSD003 lr11xx/apps/cad/main_cad.c optimized_parameters,
          indexed by symbols x bandwidth x SF
  SX126x  RadioLib's SF+13, which sits at or below every AN1200.48 value and
          so errs toward deferring rather than transmitting

Combinations the vendor data does not characterise fall back to the RadioLib
default rather than guessing. LR2021 also pins fastCad, which scanChannel()
reads off the radio object rather than the config struct.

Also fix the 2.4 GHz slot term: (2 * sf + 3) / 32 is integer division that
truncates to 0 for every legal SF, so the AN1200.22 fractional term never
contributed anything.

Not hardware-verified.

* LoRa LBT: drop audit-doc pointers and cross-chip asides from comments

The LBT comments referenced an external audit document by item number (#1,
#3, #3/#6, #10, and a see-.notes/lbt pointer), which means nothing to anyone
reading the tree on its own. They also described what sibling drivers do,
which goes stale silently the moment one of them changes and belongs in that
driver's own file anyway.

Keep the reasoning, drop the pointer and the comparison. Where a comment
explained a rule that genuinely spans drivers - NUM_SYM_CAD's register-
encoding caveat - state it as a rule rather than a roll-call of chips.

No behaviour change; comments only.

* LoRa LBT: don't index the LR11x0 CAD table with a 2.4 GHz bandwidth

The detPeak lookup picked its bandwidth row by range, so wideLora's 406.25 kHz
fell into the sub-GHz 500 kHz row - SWSD003's table is the sub-GHz LoRa modem
(BW enums 62.5-500), and 2.4 GHz is a separate modem with its own calibration.
Match the four bandwidths exactly instead, so anything unmatched falls back to
the RadioLib default rather than borrowing a row it has no claim to.

Those four are the whole sub-GHz set today - LR11x0::setBandwidth() rejects
anything else - so note that a newly unlocked bandwidth, or a symbol count
outside 2/4/8/16, needs a row adding here and fails silently if it doesn't get
one. Same note on LR20x0 for symbol counts above 4.

Also correct two citations. The 2.4 GHz slot term is the SX1280 datasheet's own
CAD duration formula, and the CAD symbol-count guidance is AN1200.77, not
AN1200.22 - which is "LoRa Modulation Basics" from 2015 and predates the part.
Record that SX1280 ignores detPeak/detMin/exitMode entirely: SetCadParams
carries only cadSymbolNum, and the PNR threshold lives in register 0x942.

* LoRa LBT: set the SX1280 CAD PNR threshold and scan 8 symbols

SetCadParams on this part carries only cadSymbolNum - detPeak/detMin in the
scan config are inert - so CAD sensitivity is governed entirely by register
0x942, which nothing was writing. The scan therefore ran at the chip's reset
value of 0x32, against the 19-36 that AN1200.77 Table 1 calibrates per SF and
bandwidth: at LONG_FAST wideLora the recommendation is 27 and we were using 50.
A threshold that high needs a much stronger correlation peak, so 2.4 GHz LBT
was under-detecting on top of the symbol-count bug already fixed.

Write the table value before each scan, indexed by bandwidth x SF. Unmatched
bandwidths write nothing and leave the chip's default, as on the LR parts.

This goes through our own Module rather than lora.writeRegister(), which
RadioLib keeps protected unless RADIOLIB_LOW_LEVEL unlocks raw register access
across every driver. SX128x::writeRegister() is a straight SPIwriteRegisterBurst
on the same Module we already own, and begin() has pointed spiConfig at the
0x18 WriteRegister opcode long before any scan runs, so the transaction is
identical - no subclass needed.

Also raise the CAD window from 4 to 8 symbols, matching RadioLib's own SX128x
default. AN1200.77 measures a longer window as moving P(Detection) further from
P(False Detection); its thresholds are stated to hold for any window duration,
so the table above is unaffected. NUM_SYM_CAD_24GHZ moves in step so the slot
time still reflects the real scan.

Not hardware-verified.

* LoRa LBT: hand CAD straight to RX on the LR parts

CAD exited to standby, and the busy-channel path then issued a full
startReceive() - which standbys the radio and re-enters RX from scratch,
throwing away the reception CAD had just detected. Both LR families expose a
CAD-to-RX exit mode that avoids the round trip, and it reaches the chip through
the public startChannelScan(cfg), so nothing here needs a library patch.

A note in an earlier commit claimed this was impossible because RadioLib
collapses irqFlags and irqMask into one set. That reasoning is SX126x's, where
irqMask is a status gate and an event omitted from it never latches at all.
The LR parts have no such gate: SetDioIrqParams takes a mask per interrupt pin
and SetDioIrqConfig maps IRQs to one DIO, while getIrqStatus() reads the status
bytes returned on every SPI transaction and consults no pin mapping. So the CAD
IRQ set now carries the verdict plus terminal RX events only - preamble and
header stay off the pin, where they would fire the ISR mid-frame and run
readData() on a partial packet - and isActivelyReceiving() still sees them.

rearmReceive() cannot be unconditionally standby-free the way SX126x's is.
cad_timeout 0 means "RX until one packet arrives, then standby", so the two
re-arm paths leave the chip in different states: already listening after the
handoff, back in standby after that packet's RX_DONE. It branches on a flag set
at detection and cleared on use, so the first re-arm skips the standby and the
next restores continuous RX. Forcing continuous RX through the timeout instead
is not reliable - RadioLib converts it to RTC ticks through a float divide, so
the 0xFFFFFF "no timeout" encoding is not addressable.

On a CAD false positive the chip now waits in single-shot RX rather than
returning to standby. Still listening, but a state the old code never entered.

Not hardware-verified.

* LoRa LBT: size the 2.4 GHz CAD slot per chip, not per band

computeSlotTimeMsec() branches on myRegion->wideLora, which is a region rather
than a part. LR1120 and LR2021 both run there, and both scan NUM_SYM_CAD
symbols - but that branch read the flat NUM_SYM_CAD_24GHZ, so raising it to 8
for SX128x moved the slot for two families whose scan never changed. At
LONG_FAST wideLora that priced ~22 ms of CAD against a ~10 ms scan.

getCadSymbolCount() exists precisely so the slot tracks the real scan, but only
the sub-GHz branch consulted it. Use it in both, and give SX128x an override
returning NUM_SYM_CAD_24GHZ. That constant stops being a fleet-wide 2.4 GHz
value and becomes what one driver reports; SX128x is unchanged at 8.78 symbols,
while the LR parts on 2.4 GHz drop from a wrong 8.78 to 4.78.

The error was conservative - slots too long, so throughput lost rather than
collisions caused - but it is the same scan/slot mismatch the rest of this work
removes.

* LoRa LBT: declare CAD symbols per chip and per band, 8 on 2.4 GHz

A 2.4 GHz region is shared: an LR1120 and an SX1280 can sit on the same mesh.
Leaving the LR parts at 4 symbols there while SX1280 scanned 8 gave them
different slot times, and so a different CSMA backoff scale between neighbours
- the cross-family asymmetry this branch removed for sub-GHz, reintroduced one
band up. Both LR families now scan 8 on 2.4 GHz, matching SX1280.

Rather than switch on the band inside a shared constant, each driver states its
own count for each band it works in, and derives the symNum it passes from the
same accessor - so the scan and the slot cannot drift apart:

  SX126x   4 sub-GHz
  SX128x   8 on 2.4 GHz
  LR11x0   4 sub-GHz, 8 on 2.4 GHz
  LR20x0   4 sub-GHz, 8 on 2.4 GHz
  RF95     2 sub-GHz, its CAD being fixed in hardware and not settable

NUM_SYM_CAD and NUM_SYM_CAD_24GHZ become base-class defaults nothing falls
through to; they now record the fleet convention rather than any part's
behaviour.

LR20xx Table 6-19 stops at 4 symbols, so an 8-symbol scan reuses that row -
the last measured point, erring high and so slightly less sensitive than a
true 8-symbol value. Noted at the table.

Also a comment pass over the branch: drop retrospective phrasing that describes
what the code used to do, cut trailing justification clauses, and fix comments
the per-band change left stale - one still claimed the LR parts scan fewer
symbols than SX1280 on 2.4 GHz, which is now the opposite of the truth.

* LoRa LBT: sweep up a CAD->RX handoff that delivers nothing

A handoff leaves the chip listening by its own exit mode, so nothing in the
driver arms the radio and no return value says whether it worked. If the
detection was false, or the exit mode was not honoured, the node waits in a
state we never verified - and on the LR parts that is single-shot RX, which
does not return to continuous listening on its own.

Stamp the handoff and let the existing ~1s poll re-arm if a whole max-length
airtime passes with no packet, the same bound receiveDetected() uses. Clearing
it on a real RX_DONE is obvious; clearing it in startSend() is not, but a TX
can intervene before any packet arrives and its completion re-arms RX anyway,
so leaving the marker set would have the poll re-arm a radio already listening.

Reads the clock through Time::getMillis(), the same source Throttle compares
against, so the site stays testable across the millis() wrap. 0 is the
"none outstanding" sentinel and is tested before the elapsed compare.

Also document on rearmReceive() that an override must never call the chip's own
startReceive() - its standby+SetRx aborts the packet CAD just detected, which
is the entire reason the override exists.

* define txen pin to allow sx128x testing

* LoRa LBT: bound the CAD->RX handoff in hardware, and re-arm after it delivers

cadTimeout only applies to the CAD->RX exit mode. Every driver on develop
exited CAD to standby, so the field was ignored and its value - 0, which no
datasheet defines - was dead data. Switching the exit mode to RX made it
load-bearing while the line itself never changed, so the diff showed nothing.

All three handoff parts now bound that RX at one max-length airtime, taken
from getPacketTime() so it tracks the preset. A detection that delivers
nothing expires to standby and raises TIMEOUT, already mapped to the pin, so
the existing ISR re-arms and the queued TX retries on its own backoff.
handleReceiveInterrupt() returns early on TIMEOUT without RX_DONE; otherwise
each dud detection would reach readData() and be logged as a bad packet.

That RX is single-shot, so the arm after it delivers must be a full one.
SX126x re-armed unconditionally without a standby, which left the chip in
standby while the interface believed it was listening - and nothing swept it,
since RX_DONE has already cleared the handoff stamp. A node that received a
handoff packet then went deaf until its next transmit. It now carries the
same cadHandedToRx flag the LR drivers already had.

Sourcing, since the parts disagree and RadioLib's uniform ChannelScanConfig_t
hides it: SX126x DS_SX1261-2 p98 (15.625us steps), LR11x0 UM Table 8-8
(31.25us), LR2021 lr20xx_driver cad_timeout_in_pll_step (31.25us, contra the
datasheet's "32 MHz periods"). RadioLib scales both LR parts as 30.52us, so
the programmed value lands ~2% long - harmless, and left for upstream.

Also marks the three SWSD003 cells at 2 symbols/BW500/SF10-12 as that SDK's
fallback rather than measurements.

* LoRa LBT: own the CAD->RX handoff in one place, and void it on standby

The handoff state was split across two classes: cadHandoffRxStart in
RadioLibInterface, cadHandedToRx in each of SX126x/LR11x0/LR20x0, whose
rearmReceive() overrides were byte-identical. Neither field was cleared by
setStandby(), so a standby taken while a handoff was outstanding could strand
cadHandedToRx set - and the next rearmReceive() would then re-attach the ISR to
a chip sitting in standby, marking the interface as receiving with no RX_DONE
ever to come and no recovery path. A stale cadHandoffRxStart was milder but
still re-armed the radio over whatever was arriving at the time.

Hold both in the base, arm them together in noteCadHandoffToRx(), clear both in
setStandby(), and collapse the three overrides into one non-virtual base
rearmReceive(). SX128x and RF95 never arm the flag, so they keep taking the
plain startReceive() branch. Document on isChannelActive() that a true return
must be followed by rearmReceive() - the invariant the whole scheme rests on.

Two fixes fall out of the shape:

- The handoff re-arm attaches a rising-edge ISR to a line that is not known to
  be low, unlike startReceive() which clears the IRQ status first. Follow it
  with checkRxDoneIrqFlag(), as startReceive() already does.

- checkCadHandoffTimeout() used one max-length airtime, the same figure the
  chip's own cadTimeout now uses, so the two expired together. Losing that race
  left a queued ISR_RX to run against flags the software path had just cleared,
  falling past the TIMEOUT guard into readData() on an empty buffer. Double it
  so the hardware always expires first; the backstop then only covers an RX
  whose timer stopped on a header that never completed.

* LoRa LBT: re-arm RX when a queued packet is dropped for tx_enabled=false

Pre-existing on develop, not introduced by this branch, but it is the same
failure this series exists to remove. TRANSMIT_DELAY_COMPLETED reaches
startSend() only through isChannelActive(), which leaves the chip in standby
for the transmit about to follow. When that transmit is then dropped because
LoRa TX is disabled, the early return frees the packet and returns without
arming anything: isReceiving stays false, so pollMissedIrqs() skips the radio
too, and the node is deaf until something else re-arms it.

Narrow - tx_enabled has to change between enqueue and dequeue, since send()
checks it as well - but the fix is one call on a path that already exists.

* LoRa LBT: say what the CAD slot time actually does per chip

Two comments in RadioInterface.h described intentions the code does not carry
out.

NUM_SYM_CAD/NUM_SYM_CAD_24GHZ claimed the per-band values keep the CW backoff
scale equal across a mesh. They do not: the slot has to match the scan each part
really runs, and RF95's CAD is fixed in hardware at 2.25 symbols while
SX126x/LR scan 4. A mixed sub-GHz mesh therefore runs two slot lengths, which
is correct per part and worth stating rather than implying otherwise.

The slotTimeMsec initializer runs during RadioInterface's own construction, so
the virtual getCadSymbolCount() it calls always resolves to the fallbacks here,
never a driver override. That is harmless only because applyModemConfig()
recomputes it before anything reads it - left as-is deliberately, since
initializing to 0 would make random(0, 2^CW) * slotTimeMsec fire an immediate
transmit if anything ever did read it early.

* LoRa LBT: set the SX128x CAD threshold at config time, not per scan

isChannelActive() looked up the AN1200.77 PNR row and wrote register 0x0942 on
every scan, but sf and bw only change in applyModemConfig(). That is an SPI
round trip between the channel scan and the transmit that follows it, on the
one path this file's own comments ask to keep short.

Move it to applyCadDetPeak(), called from reconfigure() after the sf/bw setters
it depends on, and from init() as well - init() does not route through
reconfigure(), so it needs its own call.

* ESP32: stop skipping light sleep forever on a stuck LoRa IRQ

The skip added earlier in this series returns instead of sleeping whenever
DIO1 already reads high, so the main loop can service the packet rather than
strand it for the sleep chunk. It had no bound: a line that latches high with
nothing clearing it makes every subsequent call skip too, and the node quietly
stops sleeping at all, announced only by a LOG_INFO each time.

Count consecutive skips - warn at 3, sleep through at 5, reset on any real
sleep. If the IRQ has survived five attempts the main loop is not going to
clear it, and never sleeping costs more than one stranded packet.

Also drop the LORA_DIO1_EXTENDED_IO branch in loraWakeIrqAsserted(): those
boards return from doLightSleep() before it is ever called.

* nrf52 promicro: gate the P1.06 TXEN pin, and add an optional SX1280 build

P1.06 was wired to SX126X_TXEN unconditionally earlier in this series, to drive
an external PA on a bench unit. That is not safe to ship: three envs share this
variant.h, and P1.06 is PIN_WIRE_SCL on the EasyProMicro pinout and
PIN_EINK_BUSY on the InkHUD one. The default builds go back to RADIOLIB_NC.

Put it behind -D PROMICRO_SX126X_TXEN, refused wherever the pin is already
claimed. The EasyProMicro arrangement has its own SX126X_TXEN on P1.00 in the
#else branch and never reaches this, so only the e-ink exclusion is needed.

The pin was wanted for SX1280 in the first place - every other SX128X line is
already assigned on this board and TXEN was the one missing - so add that too,
behind -D PROMICRO_SX1280 and a new nrf52_promicro_diy-sx1280 env. Both opt-ins
claim P1.06, so asking for both is an #error rather than a silent pick.

Costs about 10.7 KB: the image ends at 0xE5140, 19 KB clear of the warm region,
against 0xE2710 and 30 KB for the base env.

* LoRa LBT: fix two defects in the CAD->RX handoff found in review

Both reported by Copilot against 1fcd219af, both confirmed against the code as
it stands after the handoff was hoisted into RadioLibInterface.

rearmReceive() armed the MCU ISR before marking the interface as receiving. An
ISR firing in that window queues ISR_RX while isReceiving is still false, so
handleReceiveInterrupt() bails with "called while not in rx mode" and the packet
is dropped - the exact packet the CAD handoff existed to catch. Reversed, which
also makes this path match the order the drivers' own startReceive() has always
used: base state, then arm, then checkRxDoneIrqFlag().

noteCadHandoffToRx() stored Time::getMillis() raw, but 0 is the "no handoff
outstanding" sentinel that checkCadHandoffTimeout() tests before its elapsed
comparison. A handoff armed on the one millisecond per wrap where getMillis()
returns 0 reads as "none outstanding" and the backstop never fires. Step past
it to 1, the same hand-remap Throttle.h's own TODO records GPS.cpp using for
fixHoldEnds. One millisecond of error, once per 49.7 days.

* ESP32: wake on SX128X_DIO1, which five SX1280 boards define instead of LORA_DIO1

enableLoraInterrupt() only ever looked at LORA_DIO1 and RF95_IRQ. RadioInterface
builds an SX1280Interface from SX128X_DIO1, and several variants name the IRQ
pin only that way, or put it on a different pin from LORA_DIO1:

  tlora_v2_1_18          LORA_DIO1 undefined      SX128X_DIO1 26
  betafpv_2400_tx_micro  LORA_DIO1 undefined      SX128X_DIO1 4
  t-eth-elite            LORA_DIO1 16             SX128X_DIO1 8
  tlora_t3s3_v1          LORA_DIO1 33 // TCXO_EN? SX128X_DIO1 9
  tlora_t3s3_epaper      LORA_DIO1 33 // TCXO_EN? SX128X_DIO1 9

Every branch of the function is gated on defined(LORA_DIO1), so on the first two
it preprocessed to an empty function: esp_sleep_enable_gpio_wakeup() still
enabled GPIO as a wake class, but no pin was ever armed for the radio. On the
other three it armed the wrong pin - GPIO33 there being the line the variant's
own comment questions as TCXO_EN. Those boards could not be woken from light
sleep by an incoming packet; they waited out the 30s chunk and pollMissedIrqs()
recovered the latched RX_DONE about a second after waking. Delayed, not lost,
but deaf for the duration.

loraWakeIrqAsserted(), added earlier in this series, inherited the same blind
spot and was dead code on the two boards with no LORA_DIO1 at all - so the
skip-the-sleep fix never once fired on an SX1280.

Resolve the pin per radio in loraWakeDio1Pin() and use it for arming, polling
and teardown alike, so the three cannot drift apart again, and widen their
guards to accept either macro. Behaviour is unchanged wherever it already
worked: for any non-SX1280 radio the helper returns LORA_DIO1, including on
RF95 boards where the SOC_PM_SUPPORT_EXT_WAKEUP branch arms it regardless of
radio type. LORA_DIO1_EXTENDED_IO still short-circuits first.

Note this is a power-profile change for those five boards, not only a fix: they
gain a LoRa wake source they have never had, so on a busy channel their light
sleep will now be cut short by traffic like every other board's.

* LoRa LBT: trim three-line comments to the repo's two-line limit

copilot-instructions.md:340 says one or two lines; the P1.06 availability note
and the LR2021 DIO routing note were both three. CodeRabbit flagged both.

* LoRa LBT: trace the CAD scan, its exits and the handoff outcome

Eight short lines, all in the base class so they cost one set of strings
rather than one per chip driver. The scan logs arm/busy/free; the in-chip
GOTO_RX handoff logs started, then exactly one of pkt, empty, timeout or
void - so a dropped or double-counted handoff is visible by inspection.

void and timeout are LOG_WARN because both mean an invariant broke: a
standby discarded a live handoff, or the chip's own timer failed to fire
and the software backstop had to re-arm. The rest are LOG_DEBUG, so a node
at normal log level stays quiet unless something is actually wrong.

The two pre-existing handoff strings were 44 and 41 bytes of flash; cut to
"CAD>RX timeout" and "CAD>RX empty", they pay for most of the new set. The
shared "CAD" prefix does not dedup - only string suffixes merge - and
splitting it out behind a %s measured 8 bytes worse on Cortex-M once the
extra pointer argument per call site is counted.

Also fixes the delivered-packet trace, which sat above the TIMEOUT check
and so claimed a packet on a window that expired empty.

* LoRa LBT: restore the SX1280 CAD threshold after a chip-state recovery

The calibrated AN1200.77 PNR threshold moved from a per-scan register write to
a config-time one, so it now has to be re-applied wherever the chip's state is
rebuilt. recoverChipStateLoss() hardware-resets the chip and reprograms only
the modem params, which left a recovered SX1280 doing CAD against the vendor
default threshold until the next reconfigure() - and on a node whose config
never changes, that is the rest of its uptime.

* LoRa LBT: retire RX flags that plain RX never completes

Plain RX runs with no chip timeout (startReceiveDutyCycleAuto(16, 8)
falls back to RX_TIMEOUT_INF) and the DIO mask carries RX_DONE only, so
a PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR that never completes
stays latched until something re-arms RX. On a node with nothing to
send, nothing does. The CAD->RX handoff already has this recovery
(cadTimeout plus checkCadHandoffTimeout()); plain RX had none.

checkStaleRxFlags() is its twin on the ~1 s pollMissedIrqs() tick. It
notes when flags are first seen, and if no RX_DONE has consumed them one
max-length packet later:

- a header (valid or errored) re-arms RX. RX_DONE clears every flag in
  readData(), so a header still latched has no frame behind it. SX1280
  DS rev 3.3 section 16.2: a header error leaves RX wedged with no
  RX_DONE or timeout, and only a re-arm recovers it.
- a bare preamble is only cleared. ClearIrqStatus/ClearIrq changes the
  IRQ register alone on SX126x, SX1280, LR11x0 and LR20x0, so it never
  aborts a reception; startReceive() goes through standby and would.

HEADER_VALID is never cleared on its own: RadioLib's readData() reads
it at RX_DONE, and on LR2021 its absence reports the packet as header
damaged.

Skipped while a CAD handoff is outstanding (its own timeout owns that
RX) and while RX_DONE is pending. RF95 opts out: its carrier sense reads
modem status rather than latched flags, and SX127x maps neither
PREAMBLE_DETECTED nor HEADER_ERR.

The decision is staleRxFlagAction() in RadioInterface.h, pinned in
test_radio at both sides of the window and for each action.

* LoRa LBT: hold TX on a seen preamble instead of judging it by a deadline

receiveDetected() declared a bare PREAMBLE_DETECTED false once
2 * preambleTimeMsec passed without HEADER_VALID - about 8 ms at
SF7/BW500. Nothing reads the flags on that cadence: they are latched
status bits, never routed to DIO, and the only reader is the TX path,
once per CSMA backoff (0-56 ms at SF7/BW500). The verdict was usually
reached by a read older than its own deadline, and the queued TX went
out over the frame. Reported in #11933 by @jp-bennett, whose two-node
bench measured own TX a median 43 ms after the verdict, inside a ~102 ms
frame. This branch's longer CAD slot widens it to LONG_FAST: 0-308 ms of
backoff at CWmin against a 262 ms deadline.

A read that sees a bare preamble now clears PREAMBLE_DETECTED and holds
TX for one max-length airtime. The clear touches only the IRQ register
(ClearIrqStatus/ClearIrq on SX126x, SX1280, LR11x0, LR20x0), so it never
aborts a reception. Reads during the hold report busy, so the normal
random backoff still spreads retries, and any RX interrupt ends the hold
early. A preamble that fires again during the hold stays latched, and
the first read after it holds again: a source that keeps triggering keeps
deferring TX, one airtime at a time, while a single stale flag cannot
block it. HEADER_VALID is never cleared here - RadioLib's readData()
reads it at RX_DONE, and on LR2021 its absence reports the packet as
header-damaged - and keeps its one-airtime ageing.

The SX126x software IRQ poll routes its bare-preamble case through the
same hold instead of clearing on every tick while a TX is queued, which
would have released TX before the next read could see a refire.

That poll read the private txQueue directly, so meshnology_w10 - the
only env that defines LORA_DIO1_SOFTWARE_POLL - did not compile.
hasQueuedTx() exposes the one fact it needs.

#11937 addresses the same defect by deferring the verdict when the
previous read was stale; the two conflict in receiveDetected().

* LoRa: re-arm RX before logging the packet that just went out

After TX_DONE the chip sits in standby, deaf, until startReceive(). The ISR_TX
path used to log airtime ("Packet TX") and printPacket() the sent packet
first, which kept it deaf for ~2-5 ms more depending on where the log goes.
Measured on an nRF52 SX1262 bench node: the TX_DONE-to-RX gap drops from
~8.8 ms to ~3.7 ms with USB serial logging (~11.3 to ~6.2 ms with the log
also going to a phone over BLE).

completeSending() is split into detachSentPacket(), which clears sendingPacket
and runs packetReleased() so RX re-arms on the home config, and
finishSentPacket(), which does the airtime, counters, log and pool release.
setStandby() callers keep the combined completeSending().

* SX126x: keep the TCXO powered in standby

RadioLib leaves standbyXOSC off, so standby and the post-TX/RX fallback both
use STDBY_RC, which powers a DIO3-controlled TCXO down. Every SetRx and SetTx
after that first waits out the TCXO start-up delay programmed into the chip
(RadioLib's setTCXO() default, 5000 us). On an nRF52 SX1262 bench node the RX
start after TX_DONE took ~6.0 ms at SF7, SF9 and SF11 alike; with
setStandbyXOSC(true) it takes ~0.95 ms, and the TX_DONE-to-RX gap falls to
~1.1 ms together with the previous commit. The result held across the
periodic AGC reset.

Only boards that drive a TCXO from DIO3 change. The cost is the TCXO and XOSC
current while the chip sits in standby, which the firmware keeps brief;
duty-cycled RX still sleeps, and powers the TCXO down, between windows.

* LoRa: skip the periodic AGC reset while a packet is queued

resetAGC() ends in startReceive(), and trySetStandby() first runs
checkNotification() to drain pending events. With a packet queued that can be
TRANSMIT_DELAY_COMPLETED: CAD passes, startSend() keys up, and trySetStandby()
then puts the chip in standby over the transmission it just started.
completeSending() still reports the packet as sent and counts its airtime.
A bench node logged this twice in 20 minutes; one was a packet's last retry,
so it never went on air.

Skip the reset for this tick when a TX is queued. The queue drains within
seconds, and the reset comes round again on the next interval.

* GPS: re-enable on a LoRa config save only when the region was unset

#10386 keeps the GPS off until a region is set and turns it on when a LoRa
config save sets one. The check never looked at the old region, so a GPS the
probe had disabled ("GPS not detected; not present this boot") was
re-enabled by every LoRa config save, preset changes included, and the probe
ran again. The probe blocks the cooperative main loop: on a bench node with
no GPS fitted, TX_DONE handling stalled by 0.34-0.89 s on 4 of 9 packets
after a preset change, leaving the radio deaf in standby for that long.

* SX126x: retry a standby that reports a stale command timeout

After a CAD-to-RX handoff's bounded RX times out, the status byte that
comes back with the next SET_STANDBY still carries command status 0x3
(timeout). RadioLib reports that as RADIOLIB_ERR_SPI_CMD_TIMEOUT although
the chip took the command, so every "CAD>RX empty" was followed by a
failed standby, a failed RX start and a full chip re-init. Send
SET_STANDBY once more on that error; the second one sees a fresh status.

Measured on a CH341 host (SHORT_TURBO): chip re-inits went from 3 to 0,
and all 7 retries returned 0.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SX126x: on a CH341 host, pick a running RX back up instead of restarting it

In continuous mode the SX126x stays in RX after RX_DONE, and RadioLib's
readData() never puts it in standby. rearmReceive() restarted it anyway:
a standby and a full startReceive(), about ten commands. On a CH341 host
each command is a USB round trip, and the chip is deaf for all of them.

On a CH341 host (irqPolledOverUsb(): Lora.spidev is ch341), startReceive()
now arms continuous RX and records that in rxArmedContinuous. Any standby
clears it, including the scan's before a CAD-to-RX handoff, so the next
re-arm after one takes the full path. While it holds, rearmReceive()
resumes instead: it clears the terminal IRQ flags, re-attaches the ISR
and checks for an RX_DONE that beat the arm. PREAMBLE_DETECTED and
HEADER_VALID stay latched, since they can belong to the next frame.

Hosts with a real DIO1 interrupt are unchanged.

Measured on a CH341 host (SHORT_TURBO, 3 runs per arm): post-RX deaf
time 17.8 s -> 0.8 s per run, frame miss rate 11.4% -> 8.5% (p=0.024).

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Portduino: settable SX126x TCXO start-up delay

A new Lora.DIO3_TCXO_DELAY_US key reprograms the DIO3 TCXO start-up
delay, which begin() leaves at RadioLib's 5000 us. It applies to an
SX126x with DIO3_TCXO_VOLTAGE set, and takes 1 to 10000 us. Absent or 0
keeps 5000 us, so no existing config changes.

The delay matters after a clear CAD: the chip falls back to STDBY_RC,
which stops the TCXO, so the SET_TX that follows waits out the full
start-up before the PA ramps. That wait sits between the channel
verdict and the air, where another node can key up unheard.

Measured on a CH341 host at 1000 us (SHORT_TURBO):
the launch's BUSY wait 5 -> 1 ms, frame miss rate 6.28% -> 3.63%
(p=0.003), with no decode cost at the observer.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SX126x: on a CH341 host, write the TX payload before the scan's verdict

Between a clear CAD verdict and the air, RadioLib's TX staging sends
about ten commands: the buffer write, the buffer base, packet params,
the IQ and sensitivity register fixes, the DIO IRQ map, an IRQ clear and
SET_TX. On a CH341 host each is a USB round trip, and the channel goes
unwatched for all of them.

On a CH341 host, isChannelActive() now writes the payload of the packet
it is scanning for (scanForTx) into the chip's buffer in the scan's
standby, before SET_CAD. The CAD leaves the buffer alone. After a clear
verdict, launchTransmit() sends only SET_PACKET_PARAMS,
SET_DIO_IRQ_PARAMS, CLEAR_IRQ_STATUS and SET_TX, then waits on BUSY as
RadioLib's launchMode() does.

RadioLib's full staging still runs:
- on a busy verdict, a failed standby, or a packet that is not the one
  staged;
- for the first TX after a chip reset, sleep or AGC reset, so the
  register fixes are in place before any short launch relies on them.

beginSending() is split so that encodeRadioBuffer() can fill radioBuffer
without claiming sendingPacket. Hosts with a real DIO1 interrupt are
unchanged.

Measured on a CH341 host (SHORT_TURBO): the post-verdict staging went
6 -> 1 ms and the verdict-to-air window 15 -> 10 ms; overlapping frames
fell from 32 to 18 a run (p=0.032).

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SX126x: on a CH341 host, start the channel scan in three commands

RadioLib's scanChannel() sends six commands before the CAD starts: a
packet-type read, a standby, the DIO IRQ map, an IRQ clear, the CAD
parameters and SET_CAD. Its verdict read adds a second packet-type read.
On a CH341 host each is a USB round trip, and the chip hears nothing
from the scan's standby until the CAD runs.

On a CH341 host the scan now sends the DIO IRQ map, the IRQ clear and
SET_CAD, and the CAD parameters only when they differ from the last ones
sent (they are sent again after a chip reset, sleep or AGC reset). The
standby is already done by trySetStandby() just before. The verdict is
read from the IRQ flags alone. The parameter bytes are the ones
RadioLib's setCad() would send.

Hosts with a real DIO1 interrupt still call lora.scanChannel().

Measured on a CH341 host (SHORT_TURBO), together with the staging in RX
that follows: the deaf part of the scan went 4.87 -> 1.48 ms, and board
frames starting in it 23 -> 0 a run.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SX126x: on a CH341 host, stage the TX payload during its backoff, while RX runs

Staging in the scan's standby still leaves the payload write between
the backoff's end and the air. On a CH341 host a 250-byte write takes a
few milliseconds, and in that time another node's frame can start and
be missed, or the two nodes' key-ups can line up.

On a CH341 host the payload is now written while RX still runs:
- A backoff of 5 ms or more is brought forward: the TX timer fires at
  once, the radio thread writes the payload, then waits out the rest.
  At the scan, the packet is encoded again and compared, and a match
  skips the write.
- Otherwise the scan writes it while RX runs, and skips the scan
  (reporting busy, with no standby) if a frame is arriving or unread
  then, since the standby would abort that frame.
- The launch points the TX base at the staged payload.

SX126x continuous RX writes each frame right after the last one and
wraps at the end of the 256-byte buffer. The payload goes just behind
RX's write point, the last place RX reaches again, or at the top of the
buffer when it does not fit there. Each readout reads the frame's
offset to track the write point, and an RX start resets it to 0.

A frame long enough will still reach the payload. Two checks cover it:
- A frame that lands on a staged payload drops the stage, which is
  written again at the next backoff or at the scan.
- A frame that was arriving or unread when a write ended is checked at
  its readout. The chip writes a frame at about 0.37 ms a byte, far
  slower than our write sweeps, so any damage starts at the first byte
  the two share. If that byte is ours the frame is dropped with a WARN;
  if it is the frame's, the frame is whole and kept.

Hosts with a real DIO1 interrupt are unchanged.

Measured on a CH341 host (SHORT_TURBO, T1000-E peer, 6 v 6):
- Staging during the backoff: the server's CAD arm to air 10.23 -> 6.23
  ms, total loss 4.12% -> 2.77% (p=0.002), the peer's miss of the
  server's frames 6.00% -> 1.92%. The server's own miss rose 1.16% ->
  4.12%, from frames the stage overwrote at the top of the buffer.
- Then staging behind the write point with the byte check: total loss
  2.66% -> 1.51%, the server's miss 3.97% -> 1.16%, frames dropped for a
  stage 17.3 -> 0 a run. The byte check matched the observer's copy of
  the frame in 30 of 30 cases.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* SX126x: drop a stale stage-overlap marker when RX restarts

A TX stage written while a frame was arriving marks that frame for the
first-shared-byte check at its readout. If RX is restarted from standby
first (startReceive(), or a CAD that hands off to RX), that frame is
gone and the marker outlived it, so a later intact frame could be
checked against the old stage and dropped on a 1-in-256 byte match.
Clear it wherever the write pointer is reset.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* LoRa: time a stale RX header from when the header shows

checkStaleRxFlags() stamps rxFlagsSeenMs at the first poll that sees
PREAMBLE_DETECTED, HEADER_VALID or HEADER_ERR, and re-arms once a header
has been latched for one max packet. If a bare preamble set the stamp
and a real frame's header then arrived, the header was judged against
the older stamp: a poll more than one max packet later re-armed RX in
the middle of that frame and lost it. Restart the stamp when a header
first shows, so a header is only re-armed after it alone has been
latched for a full max packet.

Found by CodeRabbit on #12019.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* test: time a stale RX header from when it shows, through checkStaleRxFlags()

The staleRxFlagAction() cases only pin the decision. This drives
checkStaleRxFlags() with a fake IRQ source and the test clock: a header
that shows after a bare preamble restarts the window, so it is not
re-armed one max packet after the preamble.

Co-Authored-By: Jonathan Bennett <jbennett@incomsystems.biz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9

* Radio: drop a duplicated STDBY_XOSC block from the develop merge

The merge added the STDBY_XOSC-keeps-the-TCXO-powered block twice at
different offsets in reinitChip(), so every init and recovery sent
SET_STANDBY_XOSC twice and logged it twice - one extra USB round trip on a
CH341 host. Keep one copy.

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

* SX126x: keep a staged-over frame's RX_DONE across the resume

A CAD skipped because a frame was in flight set keepRxIrqsAtResume only
when RX_DONE or CRC_ERR was already up. With a header but no RX_DONE yet,
the resume cleared RX_DONE, CRC_ERR, HEADER_ERR and TIMEOUT - and on a
CH341 host the flag read in stageTxInRx() and that clear are a USB round
trip apart, so a frame completing in between lost its RX_DONE. The poll
then had no edge to deliver and the frame waited for checkStaleRxFlags()
to retire the latched HEADER_VALID, one max packet later.

Keep RX_DONE and CRC_ERR for every busy verdict, so the resume's
checkRxDoneIrqFlag() drives the readout. HEADER_ERR and TIMEOUT are still
cleared unconditionally: no readout waits on them, and a stale one costs
checkStaleRxFlags() a re-arm. Only RX_DONE is mapped to DIO1, so leaving
the other terminal flag latched holds no interrupt line.

Found by CodeRabbit on #12016; the blanket keep it proposed would have left
a stale HEADER_ERR or TIMEOUT latched instead.

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

* SX126x: record the stage over a bare preamble, and name the keep's catch

Two changes the r105 bench round asked for, both common to its arms.

noteStagedOverFrame() was gated on HEADER_VALID or a terminal flag, so a
busy verdict on PREAMBLE_DETECTED alone - which the bench observes - recorded
nothing. That frame can still grow into the staged bytes, no later look
records the check, and its readout then has nothing to compare against: a
frame our write damaged is accepted silently instead of being dropped with
the WARN. Every busy verdict now records it. The record is spent on the next
readout, so it cannot outlive the frame it was taken for, and the stage sits
behind the write point where an unrelated frame reaches it only after the
wrap.

"caught missed RX_DONE" cannot say which look found the flag: pollMissedIrqs(),
rearmReceive(), startReceive() and resumeRunningReceive() all log it, and the
first fires on a timer, so the line counts the ordinary backstop. The resume
now logs its own line when the kept RX_DONE is what it caught, which is the
only case the keep is responsible for. checkRxDoneIrqFlag() returns whether it
notified, so this costs no extra IRQ read - a USB round trip on a CH341 host.

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

* SX126x: never clear a frame's RX_DONE at the RX resume

resumeRunningReceive() cleared RX_DONE and CRC_ERR unless a busy verdict had
asked it not to, and readData() has already cleared the flags of the frame it
read. So the clear could only ever erase a *later* frame's RX_DONE - one that
finished during the readout, which on a CH341 host is several USB round trips
wide, the staged-payload overlap check among them. The poll then had no edge to
deliver and the frame waited for checkStaleRxFlags() to retire its latched
HEADER_VALID, one max packet later. 50f839191 fixed this for a frame caught at a
CAD busy verdict; the ordinary readout path had the same hole.

Clear nothing terminal here. Only two paths in handleReceiveInterrupt() return
before readData() with RX_DONE latched - a bad length readback and region-unset -
and they now retire it where they return, through clearReadIrqs(), so a frame
nobody will read is not re-notified for as long as the flag sits there. The
CAD>RX-empty exit already clears its own TIMEOUT and is only reached with RX_DONE
clear. HEADER_ERR and TIMEOUT still go unconditionally: no readout waits on them
and a stale one costs checkStaleRxFlags() a re-arm.

keepRxIrqsAtResume goes with it, since the keep is now what the resume always
does.

Found by CodeRabbit on #12016.

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

* SX126x: name the stage record taken over a bare preamble

The record is now taken on every busy verdict, but nothing in the log says
which ones that changed. A verdict with neither a header nor a terminal flag
is exactly the case that used to take this exit with no record, leaving its
readout nothing to check the staged bytes against, so count it by name
instead of inferring it from the gap to the readout line.

Portduino-only code, so no embedded flash cost.

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

---------

Co-authored-by: nomdetom <nomdetom@protonmail.com>
Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:03:10 -05:00