Files
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
..