Files
firmware/src
Ben MeadorsandTom df34ef1081 fix(radio): recover from chip state loss in the RX/TX hot paths too (#11678)
* fix(radio): recover from chip state loss in the RX/TX hot paths too

* fix(radio): address CodeRabbit findings on the hot-path recovery PR (#11680)

* fix(radio): address CodeRabbit findings on the hot-path recovery PR

SX128x: startReceive() still called the old asserting setStandby() before
the new trySetStandby(). The assert fired first, so the recovery path added
below it could never run - the exact chip-state-loss crash this PR exists to
fix was still live on SX128x. Remove the stale call.

LR11x0: resolvedTcxoVoltage was set once after the primary begin() attempts,
but two later paths - firmware recovery and the one-shot firmware update -
call begin() again with tcxoVoltage and never updated it. On a TCXO_OPTIONAL
board that only came up via one of those paths, reinitChip() would recover
with the wrong oscillator setting. Update resolvedTcxoVoltage after each of
those begin() calls too.

LR20x0: reconfigure() discarded RadioLibInterface::reconfigure()'s result -
the band-hop path always returned true regardless, and the same-band path
reused the same flag for chip-programming errors, so a base-class failure
could both mask itself as success and wrongly trigger a full re-init. Track
the base-class result (reconfigureSuccess) separately from the chip result
(standbySuccess), and return the former.

Also shortens the recovery-rationale comments in RadioLibInterface.h and
SX126xInterface.cpp to 1-2 lines per the repo's comment convention, the
rationale now covered once in the base class.

* fix(radio): finish the recovery ladder and stop recovery from rebooting

Follow-up to the CodeRabbit findings, plus two gaps found auditing the
branch against its own intent (never reboot on chip state loss; recover in
place).

RX left off was unrecoverable on an idle node. Every startReceive() call
site is event-driven - RX/TX ISR, the CAD-busy branch, startSend()'s failure
path, init(), reconfigure() - and a radio with RX off cannot raise an RX
interrupt, so nothing re-arms it unless the node happens to transmit or the
user changes config. A listen-only or quiet node stayed deaf for good, which
is worse than the reboot this replaced. main.cpp's existing 60 s AGC tick now
calls periodicRadioMaintenance(), which re-arms RX when rxOffline is set and
otherwise does the AGC reset as before.

In-place repair now gives up rather than retrying forever. After
MAX_CHIP_RECOVERY_FAILURES consecutive failures - a throttle window apart, so
minutes of a provably dead chip - schedule rebootAtMsec, the same deliberate
reboot Portduino already uses for LoRa_in_error. A reboot re-runs init(),
which redoes the power-enable GPIOs, settle delays and TCXO probing that
begin() alone skips. Both counters reset in RadioLibInterface::startReceive(),
the one point every driver reaches only once the chip accepts the RX start.

SX128x: reconfigure()'s recovery reached reinitChip()'s region-mismatch
branch, which rewrites config.lora.region, saves, and calls ESP.restart() /
NVIC_SystemReset(). A runtime recovery must never reboot - that is the crash
this path exists to prevent, and it would fire with a config save pending.
Gated to the boot-time call via a fromInit parameter.

LR20x0: a rejected setRxBoostedGainMode cleared the success flag and so
forced a full fullBegin() chip reset. It is a warn-level cosmetic setting,
treated as warn-only in LR11x0's equivalent, and not a lost-state signature.

Also logs suppressed recovery attempts at debug level; previously a chip that
stayed dead recorded one critical error and then went completely silent.

* fix(radio): count RX re-arms, not re-inits, in the recovery ladder

LR20x0's recoverChipStateLoss() is fullBegin(), which re-arms RX itself but
reports success on begin() alone. A re-init that came back with RX still dead
therefore reset chipRecoveryFailures, so a chip that could be re-inited forever
while never receiving again held the ladder at zero and never reached the
reboot. The other drivers had the same hole from the other side: the caller's
retry startReceive() runs after the reset, so a retry that failed again left the
count cleared.

RadioLibInterface::startReceive() is now the only place the ladder clears, and
it only runs once the chip actually accepted RX. The threshold is judged at the
top of the next attempt - a throttle window later, after that attempt's retry
(the caller's, or fullBegin's own) has had its chance to clear it. That also
drops the old false positive where the reboot was armed before the retry that
would have succeeded.

RF95Interface::startReceive() set isReceiving directly instead of calling the
base, so on RF95 nothing ever cleared rxOffline or the ladder: the first failed
RX start left periodicRadioMaintenance() re-initing forever, and with the count
now advancing it would have rebooted a working radio.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>

---------

Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
2026-09-01 16:08:33 +00:00
..
2026-08-20 12:28:57 +00:00
2026-08-20 12:28:57 +00:00
2026-01-29 05:56:19 -06:00
2026-02-19 07:16:33 -06:00
2026-05-11 11:46:13 -05:00
2026-07-01 19:01:27 -05:00
2026-07-16 18:57:50 -05:00