Commit Graph
61 Commits
Author SHA1 Message Date
Jonathan BennettandClaude Opus 5.5 a10c96f790 T-Beam 1W: support the LR1121 and LR2021 radio modules (#12204)
* T-Beam 1W: support the LR2021 radio module

LilyGo ships the T-Beam 1W with either an SX1262 or an LR2021 module on
the same pins. Build both drivers into the t-beam-1w env; initLoRa()
probes the SX1262 first and keeps whichever radio answers, so SX1262
boards are unchanged.

LR2021 settings follow LilyGo's t_beam_1w_lr2021 documentation: IRQ on
radio DIO10 (GPIO1), TCXO at 3.0 V, and PA/LNA switching on radio
DIO5-DIO8 (rfswitch.h). The 2.4 GHz PA must not see more than 8 dBm, so
LR2021_MAX_POWER_HF is 8. GPIO21 is radio DIO11 on this module; the
SX1262 probe only claims it as RXEN after a successful begin(), so it is
never driven on an LR2021 board.

TX_GAIN_LORA stays at the SX1262 module's 10 dB. The LR2021 module's
gain is about 8 dB, so it is driven slightly below its maximum.

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

* T-Beam 1W: release SX126X_RXEN before the LR2021 probe

SX126xInterface::init() calls setRfSwitchPins() before setCurrentLimit(),
setCRC() and setOutputPower(), so a begin() that succeeds before one of
those fails leaves RXEN configured as an output driven low. initLoRa()
then drops the interface and probes the LR2021, which drives the same
pin itself - GPIO21 is radio DIO11 on the T-Beam 1W's LR2021 module.

Release the pin on that path, for the boards that can carry either
radio and wire an RXEN.

* T-Beam 1W: also support the LR1121 radio module

LilyGo also ships the T-Beam 1W with an LR1121 module on the same pins.
Add it to the t-beam-1w env; initLoRa() probes SX1262, LR1121, LR2021
in that order and keeps whichever answers.

LR1121 settings follow LilyGo's t_beam_1w_lr1121 documentation and
factory example: TCXO at 3.0 V, PA/LNA switching on radio DIO6-DIO8
(sub-GHz PA on DIO8 in TX_HP, 2.4 GHz PA on DIO7, 2.4 GHz LNA on DIO6).
LilyGo warns that more than 1 dBm into the 2.4 GHz PA can damage it, so
LR1120_MAX_POWER (the LR11x0 LORA_24 clamp) is 1.

On an LR2021 board the LR1121 probe returns RADIOLIB_ERR_CHIP_NOT_FOUND
after about 3.2 s and the LR2021 then initialises normally.

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

* T-Beam 1W LR1121: IRQ on GPIO21, force the high-power PA

The LR1121 module's IRQ is radio DIO11 on GPIO21 (LilyGO utilities.h:
RADIO_DIO_IRQ_PIN = RADIO_DIO11_PIN), not GPIO1. With GPIO1 the first
listen-before-talk CAD waited forever for CAD_DONE and froze the node.

The module's sub-GHz antenna is only reachable through the high-power PA
path (external PA enabled on DIO8 in MODE_TX_HP), but RadioLib selects the
low-power PA at 14 dBm and below, where every switch is off. Add an opt-in
LR11X0_FORCE_HP_PA that calls setOutputPower(power, true), clamped to the
HP PA's -9 dBm floor, at init and in programModemParams(), and enable it
for this board.

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

* T-Beam 1W LR1121: list RF switch DIOs from DIO5

RadioLib's LR11x0::setRfSwitchTable() sets each mode's bits by the pin's
position in the array, counting from DIO5, while the enable mask uses the
DIO number. A table starting at DIO6 therefore drove every line one DIO
low: sub-GHz TX raised DIO7 (the 2.4 GHz PA) instead of DIO8, so the
sub-GHz PA and TX/RX switch never left RX and the signal only leaked out.

List DIO5 (unused, always low) first, as LilyGo's documentation and
factory firmware do. Measured at 10 dBm against an SX1262 node: peer RSSI
-100 dBm before, -29 dBm after; 20/20 each way.

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

* T-Beam 1W: also release SX126X_RXEN before the LR1121 probe

GPIO21 is the LR1121 module's IRQ (radio DIO11), so an RXEN left driven
low by a partly failed SX1262 init would mask its interrupts and hang the
first channel scan. Extend the release to boards that build the LR1121.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:35:12 +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
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 d66f416dfe Radio: 1 ms TCXO start-up delay by default, settable per variant (#12018)
* Radio: 1 ms TCXO start-up delay by default, settable per variant

begin() programs the DIO3 TCXO start-up delay at RadioLib's 5000 us. A
clear CAD leaves the chip on its RC oscillator, so the SET_TX that
follows waits out that delay before the PA ramps. That wait sits between
the channel verdict and the air, where another node can key up unheard.

After every successful begin(), SX126x (and STM32WLx) and LR11x0 now
reprogram the delay to TCXO_STARTUP_DELAY_US, 1000 us unless a variant.h
sets it. Boards without a TCXO Vref are untouched. On Portduino,
Lora.DIO3_TCXO_DELAY_US overrides the build value.

Measured at 1000 us, SHORT_TURBO, with no decode cost at the observer:
- T1000-E (LR1110): total loss 8.66% -> 6.02% (p=0.029)
- CH341 host (SX1262): miss 6.28% -> 3.63% (p=0.003)
- RAK4631 (SX1262): goodput +4.7% (p=0.012), miss n.s.

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: fold the TCXO delay call into the following err check

cppcheck reports duplicateCondition where the new call sits in its own
`if (res == RADIOLIB_ERR_NONE)` ahead of another check of the same
variable that the first branch never writes. Merge each one into the
check that follows it. No behaviour change.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:27:47 +00:00
0fc168d875 Improved LBT behaviour - CAD-to-RX, correct symbol times, better thresholds (#11137)
* 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.

* LR20x0: CAD exit to RX is 0x01 on the LR2021

RadioLib (510e00cf) defines RADIOLIB_LR2021_CAD_EXIT_MODE_RX as 0x02. The
LR2021 takes SetLoraCadParams exit modes 0x00 CAD only, 0x01 RX on
detection and 0x10 TX when clear, as the LR11x0 does, and refuses 0x02
with a processing error (-706). With the CAD>RX handoff passing 0x02,
every scan failed before the CAD ran and isChannelActive() reported the
channel clear: on a Seeed Mesh-Tracker X1, 539 scans against live
traffic gave no busy verdict, so the node transmitted with no carrier
sense.

Pass 0x01, and warn when a scan fails rather than reporting it clear in
silence.

* LoRa LBT: take out the preamble hold on a seen preamble

Revert be3bf1f38. receiveDetected() judges a preamble by its two-preamble
deadline again, and the SX126x IRQ poll clears a bare preamble directly.
hasQueuedTx() stays: periodicRadioMaintenance() still uses it.

* SX126x: read the queued-TX state through hasQueuedTx() in the IRQ poll

957fbd3f5 restored the poll's direct txQueue.empty() read along with the
preamble clear. txQueue is private to RadioLibInterface, so meshnology_w10,
the only env that defines LORA_DIO1_SOFTWARE_POLL, no longer compiled.

* 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
(cherry picked from commit 57d6a86b71)

---------

Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 21:14:40 +00:00
Jonathan BennettandClaude Opus 5.5 509c9da41a Reset the radio's AGC only when it has gone quiet, or once a day (#12072)
* Reset the radio's AGC only when it has gone quiet, or once a day

The loop reset the AGC every 60 s, whatever the radio was doing. On the
SX1262 the reset (warm sleep, Calibrate(0x7F), calibrateImage and their
waits) measures 115-127 ms on a T-Beam Supreme, during which the radio
hears nothing and the loop does nothing else.

A radio that is still decoding packets has gain that isn't stuck, so the
reset now runs only when nothing has decoded for AGC_IDLE_RESET_MS (60 s)
or none has run for AGC_FORCED_RESET_MS (24 h). A quiet node keeps the
60 s reset; a busy one pays for it once a day. resetAGC() now reports
whether it ran, so a reset skipped mid-packet is retried on the next
tick. The 60 s upkeep tick itself (noise floor, RX-offline recovery) is
unchanged.

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

* Retry an AGC reset whose calibration timed out

When Calibrate(0x7F) is still busy after 50 ms, resetAGC() restarts RX
without the image calibration or the settings it re-applies, so report
it as not done and let the next maintenance tick try again.

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

* Retry an AGC reset whose RX sensitivity patch failed to re-apply

Calibrate(0x7F) clears the 0x8B5 RX sensitivity patch. If writing it back
fails, the radio keeps receiving with reduced sensitivity, and a node that
still decodes packets would not reset again for a day. Report the reset
as incomplete so the next maintenance tick retries 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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:15:36 +00:00
Jonathan BennettandClaude Opus 5 a8120ef176 Gate verbose radio logging behind LOG_DEBUG_RADIO (RADIO_DEBUG) (#12071)
Console I/O is expensive on some platforms - on a T-Beam over HWCDC a log
line can block behind someone else's USB drain - so chatty radio logging
sits on the path it is meant to observe. Add a gate in the shape of
GPS_DEBUG/LOG_DEBUG_GPS: RADIO_DEBUG (default 0) and LOG_DEBUG_RADIO(),
which costs no flash when off. It lives in DebugConfiguration.h beside
LOG_TRACE, since every radio driver already includes it.

Moved behind it: chip bring-up detail (constructor pin lines, TCXO probing,
RF-switch and irqDioNum selection, current limit, FW version, DCDC and PA
table) and the recurring per-event chatter (standby failures, sleep entry,
AGC reset, frequency-offset reads on every RX, noise-floor reset, invalid
RSSI samples, sleep veto, cancelSending, no-LoRa and pending-TX drops, the
weighted Tx delay printed per relayable packet).

Unchanged: packet lines (printPacket "Lora RX"/"Started Tx"), init results,
region/frequency/bandwidth/power summaries, and every LOG_WARN/LOG_ERROR
anomaly. Two constructor lines that were LOG_WARN for no reason (LR11x0,
LR20x0) are now gated like their SX126x/SX128x/RF95 counterparts, and the
noise-floor reset line drops from LOG_INFO, since it fires with every 60 s
AGC upkeep.


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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-10-05 15:14:10 +00:00
Tom 2d6dad9ee9 Portduino: Fix LR2021 switch tables, power ceilings and IRQ handling (#11382)
* fix(portduino): recognise the LR2021 power ceilings in --check

loadConfig() has read Lora.LR2021_MAX_POWER and Lora.LR2021_MAX_POWER_HF
since LR2021 support landed, but neither was listed in the config checker's
schema. --check therefore reported both as "unknown key ... ignored by
meshtasticd" -- false, and actively misleading: it tells the user to delete a
key that is doing exactly what they wanted.

This breaks the contract stated above schema(), that a key taught to
loadConfig() is added there too. CI enforces that by running --check over
bin/config.d/**, but no shipped config sets either key -- or mentions lr2021
at all -- so nothing ever tripped over the omission. It could only surface
for someone hand-writing an LR2021 config.

LR20x0 is the only module with two power ceilings, one per band, selected at
runtime by region; every other family expresses the split as separate module
names and needs a single key. That is the likely reason the pair was missed
while every other *_MAX_POWER key was added.

Also adds both to valueSpecs(), so a wrong-typed value is reported rather
than silently replaced by the default.

* feat(portduino): configurable IRQ DIO and a chip-neutral RF switch table for LR20x0

Two gaps found bringing an LR2021 up under meshtasticd on a Luckfox Lyra
Zero W. Both sit in the LR20x0 support added in #11252, and they interact:
the switch table has to be written slightly wrong to pass validation, and
the interrupt lands on a pin that table is driving. Every symptom is silent,
because begin() only exercises SPI and BUSY -- the radio reports init
success and then receives nothing.

IRQ DIO could not be set on Portduino
-------------------------------------
LR20x0Interface picked the IRQ DIO purely at compile time, and neither
LR2021_IRQ_DIO_NUM nor IRQ_DIO_NUM exists for a Portduino target, so
meshtasticd always fell through to RadioLib's default of DIO5 -- which is
also the first RF switch line on carriers using the DIO5-DIO8 table. A
variant says this with a #define (the pro-micro DIY board uses DIO9); a
carrier has only the YAML, and had no way to say it.

Adds Lora.IRQ_DIO_NUM, and an ARCH_PORTDUINO branch after the two existing
#define branches, so a variant that already sets one still wins.

The switch table was parsed as LR11xx-only
------------------------------------------
Pin names resolved to RADIOLIB_LR11X0_DIOn whatever the radio, and the mode
set was the LR11xx's, so MODE_RX_HF -- a mode the LR20x0 really has -- was
rejected as an unknown key and had to be omitted. The two families are not
interchangeable: an LR11xx has no DIO9, so its fifth switch slot is DIO10,
while an LR20x0's fifth slot is DIO9 and DIO10 is its sixth. A table naming
DIO10 was therefore driving the wrong pin on an LR20x0.

The YAML layer now stores what was written -- a DIO number and a neutral
mode id -- and each interface supplies its own DIO constants and OpMode_t
map to a shared builder. Neither family's constants are assumed to coincide
with the other's.

This also fixes a round trip in the config writer, which decoded pins by
comparing against RADIOLIB_LR11X0_* and always emitted five values per mode
row: for a four-pin table it produced YAML that --check would reject for
mismatched row lengths.

--check
-------
Findings are now judged against the resolved module rather than a fixed
list, so a mode or pin the part does have can no longer be rejected, and one
it does not have is named instead of silently accepted. The claim that the
table "is only applied to LR11xx radios" was stale and is corrected, and the
missing-table warning now covers both families. "auto" is excluded
throughout: the module has not been probed yet, so absence cannot be judged.

The IRQ/switch-pin collision is reported in both directions, including the
harder case where no key is set and the radio default collides -- nothing in
the file looks wrong. Note that listing a pin is what breaks it, not driving
it: setRfSwitchTable() reassigns the DIO function for every pin in the list
whatever the levels say, so an all-LOW column is still a collision.

Seven fixtures cover these, including a false-positive guard: DIO5 as the
interrupt is normal, and must stay silent when the table is elsewhere.

* feat(portduino): let the YAML ask for a TCXO probe, across every family that has one

A variant declares "a TCXO may or may not be fitted" at compile time with
TCXO_OPTIONAL, because the board is known when the image is built. A
Portduino carrier cannot: the same meshtasticd binary runs on hardware
populated either way, so the statement has to arrive as YAML and be answered
at runtime.

Adds Lora.TCXO_OPTIONAL, and TCXO_OPTIONAL_ENABLED in RadioLibInterface.h to
unify the two, so each driver asks the question once rather than growing a
second, Portduino-shaped code path. On an embedded target it stays a
compile-time constant, so `if (TCXO_OPTIONAL_ENABLED)` folds away exactly as
the old `#if` did: the nrf52_promicro_diy_tcxo image, which defines
TCXO_OPTIONAL and so exercises the converted branches, still ends at
0xDF1D0 -- the same address as before this change.

It is defined there rather than in a header of its own because
InterfacesTemplates.cpp includes all three interface .cpp files into one
translation unit, where a per-file definition would collide.

Covers every family that has a TCXO reference to probe for: SX126x
(sx1262/sx1268/LLCC68), LR11xx and LR20x0. With no DIO3_TCXO_VOLTAGE given,
the TCXO attempt uses RadioLib's own 1.6 V default rather than being skipped
-- otherwise there is nothing to fall back FROM and the flag would silently
do nothing. This is also the FIXME that sat on the Portduino branch in
LR20x0Interface: an unset voltage now means "no TCXO" explicitly.

Two things are deliberately left alone:

Each family keeps its own probe order. LR11xx tries XTAL first, because a
TCXO-first attempt hangs RadioLib's unbounded calibration wait on a module
with no TCXO fitted, whereas XTAL fails fast and cleanly on a module that
has one; LR20x0 and SX126x try the TCXO first. A carrier therefore behaves
the same way in a Portduino build as in an embedded one, and changing an
order stays a hardware-behaviour decision rather than a tidying-up one.

The SX126x retry is Portduino-only. An embedded TCXO_OPTIONAL board already
gets this from initLoRa(), which constructs a second SX126x interface with
no Vref when the first fails; retrying inside init() as well would leave
that ladder step unreachable and change how every existing t-echo-class
board reports its oscillator. A Portduino build has no ladder to fall
through, because the module is named in YAML rather than probed.

--check learns the key, reports which Vref will actually be tried, and warns
when it is set on a radio with no TCXO reference, where it is read, stored
and inert.

* docs(portduino): condense the comments on this branch

The repo asks for one or two lines and no multi-paragraph blocks, on the
grounds that the diff and the commit message carry the rationale while the
code carries the behaviour. What landed here was well past that: 163 added
comment lines, including a 26-line block above a single macro.

Removes the rhetoric, the issue numbers and the before-and-after asides, and
the notes on where a thing used to live. No added block is longer than three
lines now.

Two facts needed stating and are stated once each rather than repeated at
every use: the slot/DIO divergence between the families, in PortduinoGlue.h,
and the per-family TCXO probe order, in RadioLibInterface.h. The longest
surviving explanation is why an all-LOW switch column still collides with the
interrupt, which sits in the fixtures README because without it that pair of
fixtures reads as contradictory.

Comments only; no functional change.

* address CodeRabbit review on #11382

- SX126xInterface: distinguish an explicit DIO3_TCXO_VOLTAGE from the
  TCXO_OPTIONAL probing default in the debug log instead of always
  claiming the config field was set.
- ConfigCheck: modesFor() now reports an unresolved use_autoconf against
  the union of both radio families' modes, not the LR11xx subset - fixes
  a false "not a mode this part has" warning for valid LR2021-only modes
  (e.g. RFSW_RX_HF) before autodetection resolves the module.
- PortduinoGlue loadConfig: build rfswitch_mode_high[m] as a fresh
  per-row bitmask instead of OR-accumulating onto a stale value, so a
  config re-parse can clear a slot back to LOW.
- PortduinoGlue YAML serialization: gate rfswitch_table emission on
  has_rfswitch_table rather than rfswitch_dio_num[0] >= 0 (missed sparse
  pin lists), and track each emitted pin's original slot so row values
  line up correctly instead of shifting when a low slot is absent.
- config-dist.yaml: document the per-family TCXO/XTAL probe order
  (SX126x/LR20x0 TCXO-first, LR11x0 XTAL-first).
- Trim three overlong comments per the coding-guideline nitpicks.

Left the SX126x XTAL-retry-on-oscillator-failure nitpick alone -
RadioLib's begin() already does its own XOSC_START_ERR recovery
internally, and narrowing our wrapper's retry condition on top of that
needs hardware to verify it doesn't regress a real failure path.

Verified: bin/test-config-check.sh GREEN 69/69 against an isolated
native build; pio test -e native -f test_rtc PASSED.

* fix CI: cppcheck duplicateValueTernary, harden kRfSwitchModes init

LR11x0Interface::init(): work around cppcheck's duplicateValueTernary
on `TCXO_OPTIONAL_ENABLED ? 0 : tcxoVoltage` (both branches fold to 0
on a board with no ARCH_PORTDUINO, no TCXO_OPTIONAL, and no explicit
Vref, since tcxoVoltage already reduces to 0 via the same macro chain)
by splitting it into a plain assignment + if, rather than suppressing
the warning. The other TCXO_OPTIONAL_ENABLED ternaries in this PR
(LR11x0Interface.cpp:75, SX126xInterface.cpp:76, LR20x0Interface.cpp:
86,240) pick between TCXO_OPTIONAL_DEFAULT_VOLTAGE (1.6f) and 0, which
can never coincide, so they're unaffected and left as-is.

ConfigCheck.cpp: kRfSwitchModes was a namespace-scope global with
dynamic initialization (a lambda IIFE) reading kRfSwitchModeNames,
which is defined in a different translation unit (PortduinoGlue.cpp).
Currently safe only because kRfSwitchModeNames's initializer is
constant-expression-only (string literals + enum constants), which
the standard guarantees completes before any TU's dynamic
initializers - but that safety is silent and would break if
PortduinoGlue.cpp's array initializer ever stopped being a constant
expression, with nothing to warn a future editor. Converted to a
function-local static (Meyers' singleton), which is correct by
construction regardless of the other TU's initializer, updating all
4 call sites (definition + 3 uses) from kRfSwitchModes to
kRfSwitchModes().

Verified: pio test -e native -f test_radio PASSED; bin/test-config-
check.sh GREEN 69/69 against an isolated native build.

* refactor: simplify TCXO voltage handling across interfaces and improve comments

* fix rfswitch_table cross-file merge; drop now-stale checker warning

Three CodeRabbit findings on 09e0d390c, addressed together since #2
and #3 are the same root cause:

1. PortduinoGlue.cpp: require an exact "DIO<n>" match when parsing
   rfswitch_table.pins. sscanf's %d stops at the first non-digit, so
   "DIO5invalid" silently parsed as DIO5 at runtime even though
   ConfigCheck.cpp's static validator (exact match against
   kRfSwitchPins) already rejected it - checker and loader disagreed.

2. PortduinoGlue.cpp: reset all 5 pin slots and all 8 mode rows before
   applying a table, rather than only overwriting what the new table
   mentions. A later config.d file that omitted a mode a prior file
   had set (e.g. only redefining MODE_TX) let the earlier file's
   MODE_RX leak through, contradicting "last file wins" - the rule
   every other Lora: key already follows.

3. ConfigCheck.cpp: with #2 fixed, rfswitch_table behaves like any
   other cross-file key, so removed the special-cased ERROR in
   checkCrossFileOverlap ("These do NOT override each other... OR of
   every table") - it described the pre-fix OR-accumulation bug and
   is no longer accurate. Falls through to the generic "last file
   wins" INFO now. Renamed/repurposed the rfswitch-sticky fixture to
   rfswitch-last-wins and updated its assertion (was rc=1 asserting
   the old error text, now rc=0 asserting the generic info) and the
   fixtures README.

Verified: bin/test-config-check.sh GREEN 69/69 against an isolated
native build, including the renamed assertion.

* Assert the effective rfswitch table, not just the overlap diagnostic

The "last one wins" case checked that the cross-file info fires and that the
result is clean. Neither observes the table the loader actually ended up with,
so the merge bug ee9b5b81e fixed - a later table leaving an earlier file's pins
and mode rows as carryover - would still have passed it. Raised by CodeRabbit.

Asserting the value needs two things the existing case cannot supply.

The winner has to be deterministic. Both files in rfswitch-last-wins/ sit in
config.d/, which is walked with a bare directory_iterator and no sort, so which
one lands last is up to the filesystem - the point configd-conflict/ exists to
make, and the reason the checker warns rather than assuming alphabetical order.
rfswitch-replace/ puts the losing table in config.yaml instead, which is always
loaded before config.d/. The loser is the wider of the two, four pins and three
all-HIGH mode rows against the winner's two pins and one all-LOW row, so
carryover shows up as a surviving pin, a surviving mode row, or a HIGH that
should be LOW.

The table has to be observable. The check report says no more than "RF switch
table   : set", and check-yaml cannot help: it is --check --output-yaml, and
--check wins and exits before the dump - which the case just below it asserts.
emit_yaml() does serialise the effective table, so the assert helper grows a
yaml mode that passes --output-yaml alone.

Confirmed non-vacuous: with the reset loop in loadConfig() removed, the new
assertion fails and the old one still passes.

Config-check suite GREEN 70/70. Native suite GREEN 44/44, 968 cases.

* Say nothing about rows the radio will never read

Two of the RF-switch diagnostics judged a table against a family's mode
list without first asking whether the module reads a table at all.

modesFor() treated every module that was not an LR20x0 as LR11xx-like, so
an sx1262 carrying a table was told which of its rows were "not a mode
sx1262 has" and which modes it had omitted - alongside the correct warning
that the whole table is inert. pinsFor() already returned an empty set for
these parts and its caller already guarded on that; the mode path now
matches.

The missing-mode advice is dropped under "auto" as well. The module has
not been probed, so the union of both families is all there is to compare
against, and naming its absent modes would advise adding MODE_TX_HP,
MODE_GNSS and MODE_WIFI rows to what may turn out to be an LR20x0.

Fixtures for both silences, and an assert() needle prefixed with '!' to
hold them: a line that is merely absent today is otherwise nobody's
regression.

Also corrects the kLr11x0SwitchDios/kLr20x0SwitchDios comments. They
describe a slot mapping, but buildRfSwitchTable() searches them by value
to find the parallel pin constant - and with 7 DIOs against 5 YAML pin
slots, the LR20x0 array could not be positional.

* Warn when the two TCXO keys ask for opposite things

DIO3_TCXO_VOLTAGE written out as false or 0 asks for DIO3 to be left
alone, and stores identically to the key being absent - so TCXO_OPTIONAL
then probes DIO3 at the radio default anyway. Both keys behave exactly as
documented; only together are they wrong, which is what makes the outcome
surprising. loadConfig() now keeps the distinction that the store loses,
and --check reports the contradiction and which key to drop.

The flag is diagnostic only and is not serialized: an explicit false and
an absent key both round-trip as absent, as they did before.

Also fixes the Portduino TCXO log lines, which named the variant define
SX126X_DIO3_TCXO_VOLTAGE on a path where the knob is the YAML key, and
adds a TODO over the SX126x XTAL retry. RadioLib has autocorrected that
case itself since 7.5.0 - SX126x::modSetup() retries config() on the XTAL
when begin() fails with SPI_CMD_FAILED and XOSC_START_ERR - so the
ordinary case never reaches our retry and what does is mostly invalid
settings, logged as a TCXO fault.

* feat(portduino): bound Lora.IRQ_DIO_NUM, accept the older spelling, document both

An LR20x0 raises its interrupt on DIO5 through DIO11. Anything else was read
straight out of the YAML and programmed into RadioLib, where it routes the IRQ
nowhere: begin() touches only SPI and BUSY, so the radio reports init success
and then never receives a packet - the same failure the switch-pin collision
check already covers, reached by a typo instead.

Refuse it in loadConfig() and again in the driver before it reaches RadioLib,
warning both times. Because loadConfig() discards the value, the merged config
cannot tell a rejected number from an absent key, so --check judges the range in
its per-file pass where the offending line is still known.

LR2021_IRQ_DIO_NUM, the spelling carried by the two earlier LR2021 branches, is
read when IRQ_DIO_NUM is absent and reported as shadowed when it is not. Both
keys, the DIO range and the collision that makes the setting matter are now
described in config-dist.yaml.

* fix(portduino): a rejected IRQ_DIO_NUM returns to the radio default

loadConfig() runs once per file - the main config, then each file in
config.d/ - and they all write the same portduino_config. An out-of-range
Lora.IRQ_DIO_NUM warned that it was falling back to the radio default but
left any valid value an earlier file had set, so LR20x0Interface went on
programming that stale DIO.

Reset it to -1, the unset sentinel every other reader already tests for.

Pinned by a new fixture: DIO9 in the main config, out of range in
config.d/. The main config is always read first, so the ordering is
deterministic, unlike two files in config.d/ (see rfswitch-last-wins).
The assertion requires the summary to name the radio default and not DIO9;
with the reset removed and rebuilt, it is the only assertion that fails.
2026-09-16 10:44:06 +00:00
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
Ben Meadors 7dffd66c59 fix(radio): recover a chip that lost its state instead of assert-crashing in reconfigure() (#11676)
* fix(lr11x0): recover a chip that lost its state instead of assert-crashing in reconfigure()

* fix(radio): extend chip-state-loss recovery to SX126x, SX128x, RF95, and LR20x0
2026-08-31 16:47:04 +00:00
Thomas Göttgens 80d16234e5 fix(lora): skip DIO detach when no ISR is attached (#11386)
* fix(lora): skip DIO detach when no ISR is attached

Fixes #11371

* fix(lora): latch the ISR-armed flag instead of tracking attach state

The flag is now written once from task context and only read from ISR context.
2026-08-10 12:39:25 +00:00
cd716fe384 logging: audit log strings for terseness, reclaiming ~6.8 KB of string data (#11374)
* logging: strip redundant punctuation, level prefixes, and 'successfully' from log strings

The logger already appends a newline and prints the level tag, so
trailing '.', '!', '...', literal \n, and 'Error:'/'Warning:' prefixes
inside format strings are wasted flash bytes. Same for 'successfully'
(the affirmative form already implies it).

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

* logging: tighten verbose log strings in modules, radio, platform, and system code

Rewrite wordy log messages to terser equivalents - drop filler words
(articles, 'attempting', 'due to', 'please'), use 'Can't X'/'X failed'
phrasing, and abbreviate where the codebase already does (config, init,
msg, BT). Format specifiers and argument lists are unchanged; distinctive
greppable tokens are preserved.

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

* logging: tighten verbose log strings in telemetry sensors and GPS

Same terseness pass: drop filler, 'Can't X'/'X failed' phrasing, common
abbreviations (temp, msg). Specifiers, arguments, and sensor-name
prefixes unchanged.

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

* logging: tighten verbose log strings in mesh core

Same terseness pass over NodeDB, Router, MeshService, PhoneAPI,
RadioInterface, NextHopRouter, and PacketHistory: 'X failed'/'Can't X'
phrasing, imperative verbs, dropped filler. Specifiers and arguments
unchanged; duplicate literals kept identical to preserve linker string
dedup.

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

* logging: 'Unable to/Could not/Cannot' -> "Can't" in log strings

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

* logging: clang-format rewrap after string shortening

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

* logging: restore boot-logo trailing newline and progress-dot strings

The terseness pass over-trimmed: the Meshtastic ASCII boot logo kept its
blank line via a trailing \n, and three bare "." progress ticks were
reduced to empty strings.

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

* Update src/mesh/wifi/WiFiAPClient.cpp

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* logging: address review feedback on the terseness audit

- Node/packet IDs use the repo's 0x%08x convention in NextHopRouter,
  NodeDB, AdminModule and CannedMessageModule. The sibling log in each
  if/else pair is converted too, so a pair isn't split across two formats.
  next_hop stays 0x%x - it's the last-byte relay hint, not a NodeNum.
- RTC: the read-path and set-path "not found" warnings were byte-identical,
  so the linker deduped them and the log couldn't say which one fired.
  Split into "RTC read:" / "RTC set:". (The four sites live in mutually
  exclusive #ifdef branches, so the RTC family was never ambiguous.)
- SCD4X getAmbientPressure()/setAmbientPressure() logged "altitude", and
  SCD30 getASC() logged "Can't send command" for a read. Both now name the
  operation they actually perform.
- LOG_ERROR already carries the level: ". Error: %u" -> ", rc=%u" (matching
  the existing rc=%d house style) and "Error executing X()" -> "X() failed".
- Typos and wording: "OTA partiton.  (Reason" -> "OTA partition (reason",
  "CST3530 not response ~" -> "CST3530 no response", "Packet received with
  to: of 0" -> "to=0", HostMetrics "Error decoding" -> "Can't decode", and
  the dangling ": " on the NextHopRouter retransmission line.

Printf specifier sequences are byte-identical on all 37 touched lines apart
from the 6 deliberate %x/%u -> %08x node-ID widenings, all on uint32_t args.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-08-10 01:02:19 +00:00
e78b121d9f Lr1121 tcxo optional tries xtal first, and get all my yamls in a row (#11215)
* LR11x0: try XTAL before TCXO when oscillator type is uncertain

On boards with TCXO_OPTIONAL, a TCXO-first attempt either hangs RadioLib's
calibration wait forever on a bare/non-TCXO module (unpatched upstream), or
costs a slow failed attempt before falling back even once that's fixed with
a timeout. Measured on hardware: XTAL succeeds immediately on a bare module
(~350ms) and fails fast and cleanly on a genuine TCXO module (~300ms,
RADIOLIB_ERR_SPI_CMD_FAILED), so trying XTAL first is a strict improvement
for hang-avoidance regardless of which oscillator is actually present.

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

* compacted

* fix review comment

* fix femtofox switches

* correct the correction

* 13

* 3s timeout

* Treat SPI_CMD_TIMEOUT as an LR11x0 init failure

The BUSY watchdog breaks RadioLib's wait, so the next bounded transfer
returns SPI_CMD_TIMEOUT rather than SPI_CMD_FAILED. Only the latter was
checked, so a watchdog-triggered failure fell through to getVersionInfo(),
setRfSwitchTable() and startReceive() against an unresponsive chip.

Also use Throttle::isWithinTimespanMs() for the watchdog's elapsed-time
check instead of raw millis() arithmetic.

* Drop the BUSY watchdog and probe XTAL before TCXO

The watchdog bounded RadioLib's unbounded BUSY wait in LR11x0::config() by
having LockingArduinoHal::digitalRead() report a stuck pin low exactly once.
That let a TCXO-first attempt fail cleanly rather than hang, but it meant
lying to RadioLib about a GPIO from a HAL shared by every radio driver.

Ordering the attempts XTAL-first avoids the hang outright instead: attempt 1
configures no DIO3 Vref, so there is no calibration wait to get stuck in, and
the TCXO fallback is only reached on a module that answered and refused XTAL.

Attempts are now XTAL, then TCXO, then a settling retry on whichever
oscillator was settled on - after a fallback that is a second TCXO attempt.
Only TCXO_OPTIONAL builds probe XTAL; a variant that declares a Vref
unconditionally still goes straight to it and never probes XTAL at all.

SPI_CMD_TIMEOUT stays a failure alongside SPI_CMD_FAILED: a bounded
per-command BUSY wait in Module::SPItransferStream() reports it in its own
right, independently of the removed watchdog.

* Drop a stray tab from the promicro TCXO readme

trunk fmt: prettier flags the whitespace-only line inside the <summary>
block, which was the only failing check on the PR.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2026-08-05 08:29:23 +00:00
Jonathan Bennett 7302db1672 Update LoRa firmware on Thinknode M7 (#11337) 2026-08-04 13:03:30 +00:00
Thomas Göttgens 047c4e9feb add LR 2021 to portduino, and allow Framebuffer devices to rotate the screen from config (#11252)
* add LR 2021 to portduino, and allow Framebuffer devices to rotate the screen from config. Requires https://github.com/meshtastic/device-ui/pull/355 and supersedes https://github.com/meshtastic/firmware/pull/10567 and https://github.com/meshtastic/firmware/pull/11138

Many thanks to the original authors https://github.com/a-li3n and https://github.com/jessm33

* Build LR2021Interface.cpp in the wasm env

initLoRa() constructs LR2021Interface for Lora.Module: lr2021, so excluding
the file from the native-wasm source filter left the constructor undefined at
link time. LR20x0Interface.cpp stays excluded; it is template-only and comes in
via the InterfacesTemplates.cpp amalgamation.

Also replace the non-UTF-8 degree signs in the framebuffer rotation comment and
correct the rfswitch alias cleanup comment.

* Trim comments

* Report setenv failure for the framebuffer rotation
2026-07-30 10:44:52 +00:00
Tom 2c57a17124 Phantom node fix perhaps (#11271)
* trying to fix phantom nodes

* fix comment spam

* oops - missed one
2026-07-28 18:32:18 +00:00
Tom 3becaf2d95 emdashes begone (#10847) 2026-07-01 19:01:27 -05:00
Ben MeadorsandAustin 0baf8b1db6 Portduino WASM hello world (still experimental) (#10793)
* Add ARCH_PORTDUINO_WASM build: meshtasticd in WebAssembly over WebUSB

Compile the full portduino firmware to WebAssembly (Emscripten) so a real node runs in a browser tab or headless Node, driving a LoRa radio over WebUSB through a CH341 — the desktop Ch341Hal path with its libusb backend swapped for a WebUSB one. The native/desktop portduino build is unchanged.

New build env under src/platform/portduino/wasm/ (excluded from the native build_src_filter): WebUSB libpinedio backend, config/FS/region/MAC/PhoneAPI glue, wasm setup/loop, JS WebUSB runtime, and build stubs. bin/build-portduino-wasm.sh runs a standalone cached emcc build to build/wasm/meshnode.{mjs,wasm}.

Six firmware sources gain #ifdef ARCH_PORTDUINO_WASM guards (single-threaded cooperative emscripten_sleep loop, continuous RX, US region default, std RNG, no-popen exec); none affect non-wasm builds.

* PortDuino WASM: CI build job, cross-platform libdeps, configurable adapter

bin/build-portduino-wasm.sh: auto-detect native-macos (macOS) vs native (Linux/CI) libdeps with a NATIVE_ENV override, and use the SAME env's Crypto with an XEdDSA-present guard. Drops the heltec-v3/Crypto borrow — the meshtastic/Crypto pin already ships XEdDSA; the native libdeps cache was just stale. No env pin change.

Add .github/workflows/build_portduino_wasm.yml: build the ARCH_PORTDUINO_WASM target in CI (ubuntu + emsdk, native libdeps) so it can't silently bit-rot; asserts build/wasm/meshnode.{mjs,wasm}.

src/platform/portduino/wasm: add wasm_set_lora_* setters so the JS host can configure any CH341 LoRa adapter (module, USB ids, DIO/TCXO, SPI speed, pins); wasm_config_apply falls back to the MeshToad defaults when unset.

* WASM: build as a first-class [env:wasm] via platform-wasm (retire emcc script)

Replace the standalone emcc build (bin/build-portduino-wasm.sh) with a normal
PlatformIO env, `pio run -e wasm`, using the new meshtastic/platform-wasm
platform (emcc/em++ + Asyncify, the WASM sibling of platform-native). The
portduino WebAssembly node is now built the same way as every other target.

- variants/native/portduino/platformio.ini: add [env:wasm] (platform pinned to
  platform-wasm, board wasm). Translates the script's source set into a curated
  build_src_filter, the ~30 EXCLUDE_* defines, and the lib set; defines
  ARCH_PORTDUINO_WASM in-repo so correctness doesn't hinge on the platform's
  board.json.
- extra_scripts/wasm_link_flags.py: the firmware-specific emcc *link* settings
  (EXPORT_NAME, EXPORTED_RUNTIME_METHODS, EXPORTED_FUNCTIONS, ASYNCIFY_IMPORTS).
  PlatformIO feeds build_flags to compile only, so these must ride LINKFLAGS;
  without it the WebUSB Asyncify seam and the JS host's runtime methods are
  dropped (the _wasm_* exports survive only via EMSCRIPTEN_KEEPALIVE).
- PortduinoGlue.{h,cpp}: guard the yaml-cpp dependency out of the WASM build
  (#ifndef ARCH_PORTDUINO_WASM around the include, emit_yaml/loadConfig/
  readGPIOFromYaml). The browser node configures via the wasm_set_lora_* setters
  and dead-strips the YAML path; this drops the host yaml-cpp build dependency
  entirely. Native is unchanged (guards are inert there).
- portduino_glue_wasm.cpp / portduino_main_wasm.cpp: repair EM_ASM JS that a
  formatter had mangled (!== -> != =, regex split) in the prior landing; the
  emcc link succeeds regardless, so CI now runs `node --check meshnode.mjs`.
- .github/workflows/build_portduino_wasm.yml: build via `pio run -e wasm`
  (artifacts under .pio/build/wasm/), trigger on the shared inputs the env
  inherits (root platformio.ini, bin/platformio-*.py).
- NodeDB.cpp: drop the dead ARCH_PORTDUINO_WASM region-default branch (region
  now defaults the same as native).
- Crypto renovate pins: add the missing gitBranch so they track upstream.

Output: .pio/build/wasm/meshnode.{mjs,wasm} (ES module, factory createMeshNode).
Verified: pio run -e wasm (against the published platform archive), node --check,
module instantiates in Node with all exports; native-macos + Docker native unit
tests (450/450) still pass.

* Fix name on the Piggystick

* wasm: pin platform-wasm at the GPL-3.0-relicensed commit

platform-wasm's LICENSE was always GPLv3 (matching this firmware), but its
platform.json/README still declared Apache-2.0 (mis-copied from platform-native).
That's fixed upstream in b83fa5b; bump the [env:wasm] pin to it. Build output is
unchanged (license metadata only). Verified: pio run -e wasm against b83fa5b.

* wasm: make reboot() actually restart the node (was a no-op)

In wasm the reboot path is live (main.cpp -> Power::powerCommandsCheck ->
Power::reboot), but Power::reboot's ARCH_PORTDUINO arm tore down SPI/Wire/Serial
and then called the no-op ::reboot() stub — leaving the node running with a dead
radio until the tab was manually reloaded. Triggers include an admin/phone
reboot, factory reset, the "reconfigure failed" path, and the 60 s stuck-TX
hardware watchdog (RadioLibInterface).

- Power::reboot(): add an ARCH_PORTDUINO_WASM arm (before ARCH_PORTDUINO, since
  the wasm build defines both) that skips the host teardown and just calls
  ::reboot(). notifyReboot already let modules persist.
- ::reboot() (glue): hand off to the JS host — browser reloads the tab (NodeDB
  state survives via IDBFS, same identity returns); headless calls Module.onReboot
  if provided, else logs. Loose !=/== so clang-format doesn't mangle the EM_ASM JS.
- README: document the reboot handoff + the Module.onReboot hook.

Verified: pio run -e wasm + node --check (EM_ASM intact); native-macos unaffected.

* wasm: rename env to native-wasm and run it in the main CI matrix

Rename [env:wasm] -> [env:native-wasm] for consistency with the portduino
native family (native, native-macos, native-tft). The build dir follows to
.pio/build/native-wasm/ (artifact is still meshnode.{mjs,wasm}); the PIOENV
guard in extra_scripts/wasm_link_flags.py, the README, and the companion wrapper
move with it. The board stays `wasm`.

Also wire the build into normal CI: build_portduino_wasm.yml becomes a reusable
workflow (workflow_call) invoked as the `build-wasm` job of main_matrix.yml, so
the WebAssembly node is built like every other platform instead of on a separate
path trigger.

* native-wasm: auto-locate the Emscripten SDK (pre-build script)

`pio run -e native-wasm` failed with "emcc not found" whenever it was invoked
from a shell that hadn't sourced emsdk_env.sh — a VS Code task, an IDE build
button, a bare terminal. Add a pre: extra script that probes the usual emsdk
locations ($EMSDK_ENV, $EMSDK, ~/emsdk, ./.emsdk, the sibling companion
checkout), sources emsdk_env.sh, and imports the resulting environment so the
platform builder and emcc see PATH/EMSDK/EM_CONFIG. No-op when emcc is already
reachable (CI), silent when no SDK is found (the platform emits its own error).

* wasm: address PR review feedback

- js/bridge.js: import CH341 from "./ch341.js" (sibling in this layout), not
  "../src/ch341.js" which doesn't resolve here.
- js/ch341.js: a zero-length transferIn while MISO bytes are still outstanding
  now throws instead of breaking out with a partially-filled buffer — silent SPI
  corruption becomes a loud error, matching the comment above it.
- libpinedio_webusb.c: webusb_set_auto_cs honors the AUTO_CS option (? 1 : 0)
  instead of the always-on ? 1 : 1. Runtime behavior is unchanged — Ch341Hal sets
  AUTO_CS=0 right after pinedio_init (RadioLib drives the active-low NSS); the
  option just isn't set yet at init, so this now correctly defaults off.
- SX126xInterface.cpp: the RX-start error log now names the method actually
  called (startReceive vs startReceiveDutyCycleAuto) instead of hardcoding the
  duty-cycle name in the WASM branch.

* native-wasm: drop the emsdk bootstrap shim (now in platform-wasm)

The Emscripten SDK auto-location moved into the platform-wasm builder, so the
firmware no longer needs its own pre: extra script. Remove
extra_scripts/wasm_emsdk_env.py and bump the platform pin to the build that
carries the bootstrap. The wasm_link_flags.py post script stays — those exported
fns / runtime methods / Asyncify import seam are firmware-app-specific.

* wasm: use the canonical companion name (meshtasticd-wasm-node)

The companion repo was renamed meshtastic-web-node -> meshtasticd-wasm-node; fix
the stale name in the wasm README and bump the platform pin to the build that
promotes the canonical name in its emsdk auto-location.

* wasm: re-entrancy guard for the API/region entry points + flaky-open retry

Two robustness fixes for the browser node:

- Re-entrancy guard. The node is single-threaded + Asyncify: while setup()/loop()
  is suspended inside a WebUSB transfer, the JS event loop is free, so a stray
  DOM/timer callback that re-enters a wasm_* entry point starts a second Asyncify
  unwind ("async operation already in flight" abort) or clobbers shared PhoneAPI
  state (observed as a "PhoneAPI::available unexpected state" flood). Add a
  g_wasm_in_firmware flag set around setup()/loop() (portduino_main_wasm.cpp); the
  wasm_set_region / wasm_api_to_radio / wasm_api_from_radio / wasm_api_available
  entry points now reject a mid-tick call (return busy) instead of corrupting or
  aborting. The host must still call them between ticks — this is the safety net
  the design lacked, not a substitute for the JS queue.

- CH341 open retry (js/bridge.js). First-connect WebUSB is flaky — the interface
  is briefly unclaimable right after the grant, or held by a prior session,
  giving a transient "Could not open SPI: -1". Retry the open with a short
  backoff, closing the device between attempts so claimInterface starts clean.

* wasm: exclude emscripten-only sources from cppcheck

The `check` board matrix runs `pio check` (cppcheck) over all of src/,
including src/platform/portduino/wasm/. cppcheck can't parse the EM_ASYNC_JS/
EM_JS macros (Syntax Error: AST broken at libpinedio_webusb.c:39,
internalAstError) and these sources are not part of any checked board build
([env:native-wasm] is board_level=extra, compiled by the build-wasm CI job).
Suppress the wasm dir in suppressions.txt, the same way generated/ and .pio/
are already excluded.

* wasm: coalesce FS.syncfs so two never run at once

IDBFS syncfs is async; the explicit wasm_fs_sync (5s timer + post-save +
beforeunload) could overlap a prior in-flight sync, warning "2 FS.syncfs
operations in flight at once". Serialize: if a sync is running, mark a pending
re-sync and let the in-flight one chain it on completion — at most one in flight,
trailing writes still flushed. (Companion drops IDBFS autoPersist so this is the
single persistence path.)

* wasm: silence false-positive SAST on the emscripten glue

- extra_scripts/wasm_link_flags.py: restore the trunk-ignore-all(ruff/F821,
  flake8/F821) header every other SCons extra_script carries; Import/env are
  SConscript-injected globals, so ruff/flake8 flag them as undefined.
- .semgrepignore: exclude src/platform/portduino/wasm/js/ (browser WebUSB glue,
  not part of the firmware binary). The unsafe-formatstring rule false-positives
  on its benign retry/diagnostic console logs.

* Update .github/workflows/build_portduino_wasm.yml

Co-authored-by: Austin <vidplace7@gmail.com>

---------

Co-authored-by: Austin <vidplace7@gmail.com>
2026-06-28 06:50:35 -05:00
982440d21d Noise floor (#9347)
* add noise floor

* Sliding window noise floor

* Add getCurrentRSSI() to SimRadio for noise floor support

* Remove sendLocalStatsToPhone call from runOnce

* Change noise floor to int32_t type

* Use int32_t for RSSI sample storage in noise floor

* Remove float cast from noise floor assignment

* Fix Copilot review issues: fix noise floor logic, types, and null pointer

- Use robust busyTx/busyRx checks instead of simple isReceiving check
- Initialize noiseFloorSamples to NOISE_FLOOR_MIN instead of 0
- Move noise_floor assignment inside null check to prevent potential crash
- Change getNoiseFloor() and getAverageNoiseFloor() to return int32_t
- Fix RSSI validation to check for positive values (rssi > 0)
- Fix format specifier from %.1f to %d for int32_t
- Update comments to accurately reflect the sampling logic

* Fix RSSI condition to include zero value

* Change noise floor initialization to zero

* Disable noise floor for LR11x0 chips: getRSSI(bool) unsupported

* Remove updateNoiseFloor call from onNotify to avoid radio queue overflow

Per PR review feedback, calling updateNoiseFloor() in onNotify() for every
ISR event (ISR_TX, ISR_RX, TRANSMIT_DELAY_COMPLETED) can cause the LoRa
radio queue to get full. The noise floor sampling still happens in
startReceive() and after transmitting.

* fix lr11x0 current rssi

* Address noise floor review comments

* Address Copilot SimRadio noise floor comments

* Fix RadioLibInterface formatting

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
2026-05-28 19:39:41 -05:00
5e2ca8aed4 LR2021 radio on NRF_Promicro (#10401)
* LR2021 radio on NRF_Promicro

Co-authored-by: Copilot <copilot@github.com>

* Refactor LR2021 interface includes and conditional compilation for improved clarity

Co-authored-by: Copilot <copilot@github.com>

* Refactor LR20x0 interface: remove unused includes and update comments for clarity

* Fix LR2021 max power definitions and add radio type detection tests

* remove potato radio type detection tests

* Include placeholder for DCDC - currently requires godmode

* Added godmode features - not enabled by default

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-05-07 20:24:39 -05:00
Tom b11d29ff31 fix(mesh): update reconfigure methods to return true instead of RADIOLIB_ERR_NONE (#10407) 2026-05-07 05:57:34 -05:00
Tom a3b49b9028 Add powerlimits to reconfigured radio settings as well as init settings. (#10025)
* Add powerlimits to reconfigured radio settings as well as init settings.

* Refactor preamble length handling for wide LoRa configurations

* Moved the preamble setting to the main class and made the references static
2026-04-09 16:18:05 -05:00
8384659608 fix: apply all LoRa config changes live without rebooting (#9962)
* fix: apply all LoRa config changes live without rebooting

All LoRa radio settings (SF, BW, CR, frequency, power, preset,
sx126x_rx_boosted_gain) now apply immediately via reconfigure()
without requiring a node reboot.

- AdminModule: requiresReboot = false for all LoRa config changes;
  LoRa changes were already handled by the configChanged observer
  calling reconfigure() but the reboot flag was set unnecessarily
- AdminModule: validate LORA_24 region against radio hardware at
  config time; reject with BAD_REQUEST if hardware lacks 2.4 GHz
  capability (wideLora() returns false or no radio instance)
- SX126xInterface/LR11x0Interface: apply sx126x_rx_boosted_gain in
  reconfigure(); register 0x08AC is writable in STDBY mode (SX1261/2
  datasheet §9.6); retention registers written so setting survives
  warm-sleep cycles; log warning on setter failure
- DebugRenderer: show BW/SF/CR on debug screen when custom modem is
  active instead of the preset name
- DisplayFormatters: clarify comment on getModemPresetDisplayName

* fix: remove redundant reboot after LoRa config changes in on-device menus

Region, frequency slot, and radio preset pickers in MenuHandler all
called reloadConfig() then immediately set rebootAtMsec. reloadConfig()
already fires the configChanged observer which calls reconfigure(), so
the forced reboot was unnecessary — same rationale as the parent commit.

* fix: guard LORA_24 region selection against hardware capability in on-device menu

Without a reboot, reconfigure() now applies region changes directly.
Previously getRadio() caught the LORA_24-on-sub-GHz mismatch post-reboot
and reverted to UNSET — that safety net is gone. Add an explicit wideLora()
check in LoraRegionPicker so sub-GHz-only hardware silently ignores LORA_24
selection instead of attempting a live reconfigure with an invalid frequency.

---------

Co-authored-by: elwimen <elwimen@users.noreply.github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-03-22 09:39:41 -05:00
Wessel 02d42f87d3 Implement 'agc' reset for SX126x & LR11x0 chip families (#9705)
* Implement 'agc' reset for SX126x chip family

There's no actual agc on SX126x chips but you can reset the analog
registers by doing a warm sleep & running calibration.

* Address PR comments & implement for LR11x0 too

* calibrate for configured frequency band

* Gate LR11X0_AGC_RESET
2026-02-24 08:03:25 -06:00
Mike RobbinsandBen Meadors 8feb34e7a8 RadioLib edge-triggered interrupts robustness (#9658)
* Fix potential race condition (read, ISR write, clear) in NotifiedWorkerThread

* Check for missed edge-triggered interrupts race condition between startReceive and enableInterrupt

* Occasionally poll to catch missed RX_DONE interrupts. (RadioLibInterface::pollMissedIrqs)

* Simplify RadioLibInterface::checkRxDoneIrqFlag()

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-02-21 06:14:04 -06:00
JorropoandBen Meadors 8af9e7fbdc enable long interleaving mode for LR11x0 and SX128x (#9399)
Using long interleaving is not a breaking change, the receiver node is able
to use the lora header to know if LI encoding is used or not and will
decode LI packets correctly.

However the problem is SX127x and other first generation LoRa IP which do not
support LI at all, for theses it is a breaking change.

HOWEVER due to the sync word bug the LR11x0 already can't talk with SX127x,
so if we enable LI on theses no one would be able to tell.
Same for SX128x altho this is because it works on 2.4Ghz which is incompatible with SX127x.

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2026-01-29 06:44:17 -06:00
Ben Meadors b6a1020fc5 Add error handling for SPI command failures in LR11x0, RF95, and SX128x interfaces (#9447) 2026-01-27 13:06:50 -06:00
Ben Meadors d4045dff2c Remove INTERRUPT_ATTR from disableInterrupt methods on interfaces 2026-01-11 19:37:20 -06:00
Jonathan Bennett b18794e98d Log error if startReceive fails in LR11x0Interface (#8718) 2025-11-22 13:54:24 -06:00
Jonathan Bennett 4e0a4cc45f Log the lora frequency error when receiving a packet. (#8343) 2025-10-15 06:34:28 -05:00
Chloe BethelandBen Meadors 18058ef507 Fix 2.4GHz reconfiguration on LR11xx (#8102)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-09-25 04:50:56 -05:00
Jonathan Bennett 1c1c0cc791 Portduino config refactor (#7796)
* Start portduino_config refactor

* refactor GPIOs to new portduino_config

* More portduino_config work

* More conversion to portduino_config

* Finish portduino_config transition

* trunk

* yaml output work

* Simplify the GPIO config

* Trunk
2025-09-03 17:50:26 -05:00
Jonathan Bennett 95200e8f6b Adds rfswitch on Portduino (#7663)
* Initial attempt to get rfswitch working on Portduino

* Make portduino_config global
2025-08-18 16:33:52 -05:00
MaxandBen Meadors 3ea96bb6e1 Log TX power after limits applyng and store it in config (#7065)
* Log and save in config lora tx_power after limits applyng

* Log and save in config lora tx_power after limits applyng

* Trunk fmt

* Remove duplicate logic

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-07-01 08:47:42 -05:00
Andrew YongandBen Meadors e29588d2e2 feat(RadioInterface): Tx power gain calculation rework (#6796)
- Rename REGULATORY_GAIN_LORA to TX_GAIN_LORA
- Move gain-based Tx power clamping from RadioInterface::applyModemConfig() to RadioInterface::limitPower()
  - User-configured Tx power now matches the Tx power out of the device connector
- Re-order [LoRa Chip]Interface.cpp limitPower() to take place before the final Tx power clamping so we clamp based on the pre-PA Tx power rather than user-configured Tx power

Tested on XIAO BLE variant.

Signed-off-by: Andrew Yong <me@ndoo.sg>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-05-25 06:26:31 -05:00
GUVWAF b1e35cd8b3 Fix preamble detected IRQ flag (#6653) 2025-04-22 14:21:29 -05:00
+9 99d3e5eb70 2.6 changes (#5806)
* 2.6 protos

* [create-pull-request] automated change (#5789)

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

* Hello world support for UDP broadcasts over the LAN on ESP32 (#5779)

* UDP local area network meshing on ESP32

* Logs

* Comment

* Update UdpMulticastThread.h

* Changes

* Only use router->send

* Make NodeDatabase (and file) independent of DeviceState (#5813)

* Make NodeDatabase (and file) independent of DeviceState

* 70

* Remove logging statement no longer needed

* Explicitly set CAD symbols, improve slot time calculation and adjust CW size accordingly (#5772)

* File system persistence fixes

* [create-pull-request] automated change (#6000)

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

* Update ref

* Back to 80

* [create-pull-request] automated change (#6002)

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

* 2.6 <- Next hop router (#6005)

* Initial version of NextHopRouter

* Set original hop limit in header flags

* Short-circuit to FloodingRouter for broadcasts

* If packet traveled 1 hop, set `relay_node` as `next_hop` for the original transmitter

* Set last byte to 0xFF if it ended at 0x00
As per an idea of @S5NC

* Also update next-hop based on received DM for us

* temp

* Add 1 retransmission for intermediate hops when using NextHopRouter

* Add next_hop and relayed_by in PacketHistory for setting next-hop and handle flooding fallback

* Update protos, store multiple relayers

* Remove next-hop update logic from NeighborInfoModule

* Fix retransmissions

* Improve ACKs for repeated packets and responses

* Stop retransmission even if there's not relay node

* Revert perhapsRebroadcast()

* Remove relayer if we cancel a transmission

* Better checking for fallback to flooding

* Fix newlines in traceroute print logs

* Stop retransmission for original packet

* Use relayID

* Also when want_ack is set, we should try to retransmit

* Fix cppcheck error

* Fix 'router' not in scope error

* Fix another cppcheck error

* Check for hop_limit and also update next hop when `hop_start == hop_limit` on ACK
Also check for broadcast in `getNextHop()`

* Formatting and correct NUM_RETRANSMISSIONS

* Update protos

* Start retransmissions in NextHopRouter if ReliableRouter didn't do it

* Handle repeated/fallback to flooding packets properly
First check if it's not still in the TxQueue

* Guard against clients setting `next_hop`/`relay_node`

* Don't cancel relay if we were the assigned next-hop

* Replies (e.g. tapback emoji) are also a valid confirmation of receipt

---------

Co-authored-by: GUVWAF <thijs@havinga.eu>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
Co-authored-by: GUVWAF <78759985+GUVWAF@users.noreply.github.com>

* fix "native" compiler errors/warnings NodeDB.h

* fancy T-Deck / SenseCAP Indicator / unPhone / PICOmputer-S3 TFT screen (#3259)

* lib update: light theme

* fix merge issue

* lib update: home buttons + button try-fix

* lib update: icon color fix

* lib update: fix instability/crash on notification

* update lib: timezone

* timezone label

* lib update: fix set owner

* fix spiLock in RadioLibInterface

* add picomputer tft build

* picomputer build

* fix compiler error std::find()

* fix merge

* lib update: theme runtime config

* lib update: packet logger + T-Deck Plus

* lib update: mesh detector

* lib update: fix brightness & trackball crash

* try-fix less paranoia

* sensecap indicator updates

* lib update: indicator fix

* lib update: statistic & some fixes

* lib-update: other T-Deck touch driver

* use custom touch driver for Indicator

* lower tft task prio

* prepare LVGL ST7789 driver

* lib update: try-fix audio

* Drop received packets from self

* Additional decoded packet ignores

* Honor flip & color for Heltec T114 and T190 (#4786)

* Honor TFT_MESH color if defined for Heltec T114 or T190

* Temporary: point lib_deps at fork of Heltec's ST7789 library
For demo only, until ST7789 is merged

* Update lib_deps; tidy preprocessor logic

* Download debian files after firmware zip

* set title for protobufs bump PR (#4792)

* set title for version bump PR (#4791)

* Enable Dependabot

* chore: trunk fmt

* fix dependabot syntax (#4795)

* fix dependabot syntax

* Update dependabot.yml

* Update dependabot.yml

* Bump peter-evans/create-pull-request from 6 to 7 in /.github/workflows (#4797)

* Bump docker/build-push-action from 5 to 6 in /.github/workflows (#4800)

* Actions: Semgrep Images have moved from returntocorp to semgrep (#4774)

https://hub.docker.com/r/returntocorp/semgrep notes: "We've moved!
 Official Docker images for Semgrep now available at semgrep/semgrep."

Patch updates our CI workflow for these images.

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

* Bump meshtestic from `31ee3d9` to `37245b3` (#4799)

Bumps [meshtestic](https://github.com/meshtastic/meshTestic) from `31ee3d9` to `37245b3`.
- [Commits](https://github.com/meshtastic/meshTestic/compare/31ee3d90c8bef61e835c3271be2c7cda8c4a5cc2...37245b3d612a9272f546bbb092837bafdad46bc2)

---
updated-dependencies:
- dependency-name: meshtestic
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* [create-pull-request] automated change (#4789)

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

* Bump pnpm/action-setup from 2 to 4 in /.github/workflows (#4798)

Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2 to 4.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v2...v4)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Raspberry Pico2 - needs protos

* Re-order doDeepSleep (#4802)

Make sure PMU sleep takes place before I2C ends

* [create-pull-request] automated change

* heltec-wireless-bridge
requires Proto PR first

* feat: trigger class update when protobufs are changed

* meshtastic/ is a test suite; protobufs/ contains protobufs;

* Update platform-native to pick up portduino crash fix (#4807)

* Hopefully extract and commit to meshtastic.github.io

* CI fixes

* [Board] DIY "t-energy-s3_e22" (#4782)

* New variant "t-energy-s3_e22"

- Lilygo T-Energy-S3
- NanoVHF "Mesh-v1.06-TTGO-T18" board
- Ebyte E22 Series

* add board_level = extra

* Update variant.h

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>

* Consolidate variant build steps (#4806)

* poc: consolidate variant build steps

* use build-variant action

* only checkout once and clean up after run

* Revert "Consolidate variant build steps (#4806)" (#4816)

This reverts commit 9f8d86cb25.

* Make Ublox code more readable (#4727)

* Simplify Ublox code

Ublox comes in a myriad of versions and settings. Presently our
configuration code does a lot of branching based on versions being
or not being present.

This patch adds version detection earlier in the piece and branches
on the set gnssModel instead to create separate setup methods for Ublox 6,
Ublox 7/8/9, and Ublox10.

Additionally, adds a macro to make the code much shorter and more
readable.

* Make trunk happy

* Make trunk happy

---------

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

* Consider the LoRa header when checking packet length

* Minor fix (#4666)

* Minor fixes

It turns out setting a map value with the index notation causes
an lookup that can be avoided with emplace. Apply this to one line in
the StoreForward module.

Fix also Cppcheck-determined highly minor performance increase by
passing gpiochipname as a const reference :)

The amount of cycles used on this laptop while learning about these
callouts from cppcheck is unlikely to ever be more than the cycles
saved by the fixes ;)

* Update PortduinoGlue.cpp

* Revert "Update classes on protobufs update" (#4824)

* Revert "Update classes on protobufs update"

* remove quotes to fix trunk.

---------

Co-authored-by: Tom Fifield <tom@tomfifield.net>

* Implement optional second I2C bus for NRF52840
Enabled at compile-time if WIRE_INFERFACES_COUNT defined as 2

* Add I2C bus to Heltec T114 header pins
SDA: P0.13
SCL: P0.16

Uses bus 1, leaving bus 0 routed to the unpopulated footprint for the RTC (general future-proofing)

* Tidier macros

* Swap SDA and SCL
SDA=P0.16, SCL=P0.13

* Refactor and consolidate time window logic (#4826)

* Refactor and consolidate windowing logic

* Trunk

* Fixes

* More

* Fix braces and remove unused now variables.

There was a brace in src/mesh/RadioLibInterface.cpp that was breaking
compile on some architectures.

Additionally, there were some brace errors in
src/modules/Telemetry/AirQualityTelemetry.cpp
src/modules/Telemetry/EnvironmentTelemetry.cpp
src/mesh/wifi/WiFiAPClient.cpp

Move throttle include in WifiAPClient.cpp to top.

Add Default.h to sleep.cpp

rest of files just remove unused now variables.

* Remove a couple more meows

---------

Co-authored-by: Tom Fifield <tom@tomfifield.net>

* Rename message length headers and set payload max to 255 (#4827)

* Rename message length headers and set payload max to 255

* Add MESHTASTIC_PKC_OVERHEAD

* compare to MESHTASTIC_HEADER_LENGTH

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>

* Check for null before printing debug (#4835)

* fix merge

* try-fix crash

* lib update: fix neighbors

* fix GPIO0 mode after I2S audio

* lib update: audio fix

* lib update: fixes and improvements

* extra

* added ILI9342 (from master)

* device-ui persistency

* review update

* fix request, add handled

* fix merge issue

* fix merge issue

* remove newline

* remove newlines from debug log

* playing with locks; but needs more testing

* diy mesh-tab initial files

* board definition for mesh-tab (not yet used)

* use DISPLAY_SET_RESOLUTION to avoid hw dependency in code

* no telemetry for Indicator

* 16MB partition for Indicator

* 8MB partition for Indicator

* stability: add SPI lock before saving via littleFS

* dummy for config transfer (#5154)

* update indicator (due to compile and linker errors)

* remove faulty partition line

* fix missing include

* update indicator board

* update mesh-tab ILI9143 TFT

* fix naming

* mesh-tab targets

* try: disable duplicate locks

* fix nodeDB erase loop when free mem returns invalid value (0, -1).

* upgrade toolchain for nrf52 to gcc 9.3.1

* try-fix (workaround) T-Deck audio crash

* update mesh-tab tft configs

* set T-Deck audio to unused 48 (mem mclk)

* swap mclk to gpio 21

* update meshtab voltage divider

* update mesh-tab ini

* Fixed the issue that indicator device uploads via rp2040 serial port in some cases.

* Fixed the issue that the touch I2C address definition was not effective.

* Fixed the issue that the wifi configuration saved to RAM did not take effect.

* rotation fix; added ST7789 3.2" display

* dreamcatcher: assign GPIO44 to audio mclk

* mesh-tab touch updates

* add mesh-tab powersave as default

* fix DIO1 wakeup

* mesh-tab: enable alert message menu

* Streamline board definitions for first tech preview. (#5390)

* Streamline board definitions for first tech preview. TBD: Indicator Support

* add point-of-checkin

* use board/unphone.json

---------

Co-authored-by: mverch67 <manuel.verch@gmx.de>

* fix native targets

* add RadioLib debugging options for (T-Deck)

* fix T-Deck build

* fix native tft targets for rpi

* remove wrong debug defines

* t-deck-tft button is handled in device-ui

* disable default lightsleep for indicator

* Windows Support - Trunk and Platformio (#5397)

* Add support for GPG

* Add usb device support

* Add trunk.io to devcontainer

* Trunk things

* trunk fmt

* formatting

* fix trivy/DS002, checkov/CKV_DOCKER_3

* hide docker extension popup

* fix trivy/DS026, checkov/CKV_DOCKER_2

* fix radioLib warnings for T-Deck target

* wake screen with button only

* use custom touch driver

* define wake button for unphone

* use board definition for mesh-tab

* mesh-tab rotation upside-down

* update platform native

* use MESH_TAB hardware model definition

* radioLib update (fix crash/assert)

* reference seeed indicator fix commit arduino-esp32

* Remove unneeded file change :)

* disable serial module and tcp socket api for standalone devices (#5591)

* disable serial module and tcp socket api for standalone devices
* just disable webserver, leave wifi available
* disable socket api

* mesh-tab: lower I2C touch frequency

* log error when packet queue is full

* add more locking for shared SPI devices (#5595)

* add more locking for shared SPI devices
* call initSPI before the lock is used
* remove old one
* don't double lock
* Add missing unlock
* More missing unlocks
* Add locks to SafeFile, remove from `readcb`, introduce some LockGuards
* fix lock in setupSDCard()
* pull radiolib trunk with SPI-CS fixes
* change ContentHandler to Constructor type locks, where applicable

---------

Co-authored-by: mverch67 <manuel.verch@gmx.de>
Co-authored-by: GUVWAF <thijs@havinga.eu>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>

* T-Deck: revert back to lovyanGFX touch driver

* T-Deck: increase allocated PSRAM by 50%

* mesh-tab: streamline target definitions

* update RadioLib 7.1.2

* mesh-tab: fix touch rotation 4.0 inch display

* Mesh-Tab platformio: 4.0inch: increase SPI frequency to max

* mesh-tab: fix rotation for 3.5 IPS capacitive display

* mesh-tab: fix rotation for 3.2 IPS capacitive display

* restructure device-ui library into sub-directories

* preparations for generic DisplayDriverFactory

* T-Deck: increase LVGL memory size

* update lib

* trunk fmt

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: todd-herbert <herbert.todd@gmail.com>
Co-authored-by: Jason Murray <15822260+scruplelesswizard@users.noreply.github.com>
Co-authored-by: Jason Murray <jason@chaosaffe.io>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: thebentern <9000580+thebentern@users.noreply.github.com>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Austin <vidplace7@gmail.com>
Co-authored-by: virgil <virgil.wang.cj@gmail.com>
Co-authored-by: Mark Trevor Birss <markbirss@gmail.com>
Co-authored-by: Kalle Lilja <15094562+ThatKalle@users.noreply.github.com>
Co-authored-by: GUVWAF <thijs@havinga.eu>

* Version this

* Update platformio.ini (#6006)

* tested higher speed and it works

* Un-extra

* Add -tft environments to the ci matrix

* Exclude unphone tft for now. Something is wonky

* fixed Indicator touch issue (causing IO expander issues), added more RAM

* update lib

* fixed Indicator touch issue (causing IO expander issues), added more RAM (#6013)

* increase T-Deck PSRAM to avoid too early out-of-memory when messages fill up the storage

* update device-ui lib

* Fix T-Deck SD card detection (#6023)

* increase T-Deck PSRAM to avoid too early out-of-memory when messages fill up the storage

* fix SDCard for T-Deck; allow SPI frequency config

* meshtasticd: Add X11 480x480 preset (#6020)

* Littlefs per device

* 2.6 update

* [create-pull-request] automated change (#6037)

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

* InkHUD UI for E-Ink (#6034)

* Decouple ButtonThread from sleep.cpp
Reorganize sleep observables. Don't call ButtonThread methods inside doLightSleep. Instead, handle in class with new lightsleep Observables.

* InkHUD: initial commit (WIP)
Publicly discloses the current work in progress. Not ready for use.

* feat: battery icon

* chore: implement meshtastic/firmware #5454
Clean up some inline functions

* feat: menu & settings for "jump to applet"

* Remove the beforeRender pattern
It hugely complicates things. If we can achieve acceptable performance without it, so much the better.

* Remove previous Map Applet
Needs re-implementation to work without the beforeRender pattern

* refactor: reimplement map applet
Doesn't require own position
Doesn't require the beforeRender pattern to precalculate; now all-at-once in render
Lays groundwork for fixed-size map with custom background image

* feat: autoshow
Allow user to select which applets (if any) should be automatically brought to foreground when they have new data to display

* refactor: tidy-up applet constructors
misc. jobs including:
- consistent naming
- move initializer-list-only constructors to header
- give derived applets unique identifiers for MeshModule and OSThread logging

* hotfix: autoshow always uses FAST update
In future, it *will* often use FAST, but this will be controlled by a WindowManager component which has not yet been written.
Hotfixed, in case anybody is attempting to use this development version on their deployed devices.

* refactor: bringToForeground no longer requests FAST update
In situations where an applet has moved to foreground because of user input, requestUpdate can be manually called, to upgrade to FAST refresh.
More permanent solution for #23e1dfc

* refactor: extract string storage from ThreadedMessageApplet
Separates the code responsible for storing the limited message history, which was previously part of the ThreadedMessageApplet.
We're now also using this code to store the "most recent message". Previously, this was stored in the `InkHUD::settings` struct, which was much less space-efficient.
We're also now storing the latest DM, laying the foundation for an applet to display only DMs, which will complement the threaded message applet.

* fix: text wrapping
Attempts to fix a disparity between `Applet::printWrapped` and `Applet::getWrappedTextHeight`, which would occasionally cause a ThreadedMessageApplet message to render "too short", overlapping other text.

* fix: purge old constructor
This one slipped through the last commit..

* feat: DM Applet
Useful in combination with the ThreadedMessageApplets, which don't show DMs

* fix: applets shouldn't handle events while deactivated
Only one or two applets were actually doing this, but I'm making a habit of having all applets return early from their event handling methods (as good practice), even if those methods are disabled elsewhere (e.g. not observing observable, return false from wantPacket)

* refactor: allow requesting update without requesting autoshow
Some applets may want to redraw, if they are displayed, but not feel the information is worth being brought to foreground for. Example: ActiveNodesApplet, when purging old nodes from list.

* feat: custom "Recently Active" duration
Allows users to tailor how long nodes will appear in the "Recents" applets, to suit the activity level of their mesh.

* refactor: rename some applets

* fix: autoshow

* fix: getWrappedTextHeight
Remove the "simulate" option from printWrapped; too hard to keep inline with genuine printing (because of AdafruitGFX Fonts' xAdvance, mabye?). Instead of simulating, we printWrapped as normal, and discard pixel output by setting crop. Both methods are similarly inefficient, apparently.

* fix: text wrapping in ThreadedMessageApplet
Wrong arguments were passed to Applet::printWrapped

* feat: notifications for text messages
Only shown if current applet does not already display the same info. Autoshow takes priority over notifications, if both would be used to display the same info.

* feat: optimize FAST vs FULL updates
New UpdateMediator class counts the number of each update type, and suggets which one to use, if the code doesn't already have an explicit prefence. Also performs "maintenance refreshes" unprovoked if display is not given an opportunity to before a FULL refresh through organic use.

* chore: update todo list

* fix: rare lock-up of buttons

* refactor: backlight
Replaces the initial proof-of-concept frontlight code for T-Echo
Presses less than 5 seconds momentarily illuminate the display
Presses longer than 5 seconds latch the light, requiring another tap to disable
If user has previously removed the T-Echo's capacitive touch button (some DIY projects), the light is controlled by the on-screen menu. This fallback is used by all T-Echo devices, until a press of the capacitive touch button is detected.

* feat: change tile with aux button
Applied to VM-E290.
Working as is, but a refactor of WindowManager::render is expected shortly, which will also tidy code from this push.

* fix: specify out-of-the-box tile assignments
Prevents placeholder applet showing on initial boot, for devices which use a mult-tile layout by default (VM-E290)

* fix: verify settings version when loading

* fix: wrong settings version

* refactor: remove unimplemented argument from requestUpdate
Specified whether or not to update "async", however the implementation was slightly broken, Applet::requestUpdate is only handled next time WindowManager::runOnce is called. This didn't allow code to actually await an update, which was misleading.

* refactor: renaming
Applet::render becomes Applet::onRender.
Tile::displayedApplet becomes Tile::assignedApplet.
New onRender method name allows us to move some of the pre and post render code from WindowManager into new Applet::render method, which will call onRender for us.

* refactor: rendering
Bit of a tidy-up. No intended change in behavior.

* fix: optimize refresh times
Shorter wait between retrying update if display was previously busy.
Set anticipated update durations closer to observed values. No signifacant performance increase, but does decrease the amount of polling required.

* feat: blocking update for E-Ink
Option to wait for display update to complete before proceeding. Important when shutting down the device.

* refactor: allow system applets to lock rendering
Temporarily prevents other applets from rendering.

* feat: boot and shutdown screens

* feat: BluetoothStatus
Adds a meshtastic::Status object which exposes the state of the Bluetooth connection. Intends to allow decoupling of UI code.

* feat: Bluetooth pairing screen

* fix: InkHUD defaults not honored

* fix: random Bluetooth pin for NicheGraphics UIs

* chore: button interrupts tested

* fix: emoji reactions show as blank messages

* fix: autoshow and notification triggered by outgoing message

* feat: save InkHUD data before reboot
Implemented with a new Observable. Previously, config and a few recent messages were saved on shutdown. These were lost if the device rebooted, for example when firmware settings were changed by a client. Now, the InkHUD config and recent messages saved on reboot, the same as during an intentional shutdown.

* feat: imperial distances
Controlled by the config.display.units setting

* fix: hide features which are not yet implemented

* refactor: faster rendering
Previously, only tiles which requested update were re-rendered. Affected tiles had their region blanked before render, pixel by pixel. Benchmarking revealed that it is significantly faster to memset the framebuffer and redraw all tiles.

* refactor: tile ownership
Tiles and Applets now maintain a reciprocal link, which is enforced by asserts. Less confusing than the old situation, where an applet and a tile may disagree on their relationship. Empty tiles are now identified by a nullptr *Applet, instead of by having the placeholderApplet assigned.

* fix: notifications and battery when menu open
Do render notifications in front of menu; don't render battery icon in front of menu.

* fix: simpler defaults
Don't expose new users to multiplexed applets straight away: make them enable the feature for themselves.

* fix: Inputs::TwoButton interrupts, when only one button in use

* fix: ensure display update is complete when ESP32 enters light sleep
Many panels power down automatically, but some require active intervention from us. If light sleep (ESP32) occurs during a display update, these panels could potentially remain powered on, applying voltage the pixels for an extended period of time, and potentially damaging the display.

* fix: honor per-variant user tile limit
Set as the default value for InkHUD::settings.userTiles.maxCount in nicheGraphics.h

* feat: initial InkHUD support for Wireless Paper v1.1 and VM-E213

* refactor: Heard and Recents Applets
Tidier code, significant speed boost. Possibly no noticable change in responsiveness, but rendering now spends much less time blocking execution, which is important for correction functioning of the other firmware components.

* refactor: use a common pio base config
Easier to make any future PlatformIO config changes

* feat: tips
Show information that we think the user might find helpful. Some info shown first boot only. Other info shown when / if relevant.

* fix: text wrapping for '\n'
Previously, the newline was honored, but the adojining word was not printed.

* Decouple ButtonThread from sleep.cpp
Reorganize sleep observables. Don't call ButtonThread methods inside doLightSleep. Instead, handle in class with new lightsleep Observables.

* feat: BluetoothStatus
Adds a meshtastic::Status object which exposes the state of the Bluetooth connection. Intends to allow decoupling of UI code.

* feat: observable for reboot

* refactor: Heltec VM-E290 installDefaultConfig

* fix: random Bluetooth pin for NicheGraphics UIs

* update device-ui: fix touch/crash issue while light sleep

* Collect inkhud

* fix: InkHUD shouldn't nag about timezone (#6040)

* Guard eink drivers w/ MESHTASTIC_INCLUDE_NICHE_GRAPHICS

* Case sensitive perhaps?

* More case-sensitivity instances

* Moar

* RTC

* Yet another case issue!

* Sigh...

* MUI: BT programming mode (#6046)

* allow BT connection with disabled MUI

* Update device-ui

---------

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

* MUI: fix nag timeout, disable BT programming mode for native (#6052)

* allow BT connection with disabled MUI

* Update device-ui

* MUI: fix nag timeout default and remove programming mode for native

---------

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

* remove debuglog leftover

* Wireless Paper: remove stray board_level = extra (#6060)

Makes sure the InkHUD version gets build into the release zip

* Fixed persistence stragglers from NodeDB / Device State divorce (#6059)

* Increase `MAX_THREADS` for InkHUD variants with WiFi (#6064)

* Licensed usage compliance (#6047)

* Prevent psk and legacy admin channel on licensed mode

* Move it

* Consolidate warning strings

* More holes

* Device UI submodule bump

* Prevent licensed users from rebroadcasting unlicensed traffic (#6068)

* Prevent licensed users from rebroadcasting unlicensed traffic

* Added method and enum to make user license status more clear

* MUI: move UI initialization out of main.cpp and adding lightsleep observer + mutex (#6078)

* added device-ui to lightSleep observers for handling graceful sleep; refactoring main.cpp

* bump lib version

* Update device-ui

* unPhone TFT: include into build, enable SD card, increase PSRAM (#6082)

* unPhone-tft: include into build, enable SD card, increase assigned PSRAM

* lib update

* Backup / migrate pub private keys when upgrading to new files in 2.6 (#6096)

* Save a backup of pub/private keys before factory reset

* Fix licensed mode warning

* Unlock spi on else file doesn't exist

* Update device-ui

* Update protos and device-ui

* [create-pull-request] automated change (#6129)

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

* Proto

* [create-pull-request] automated change (#6131)

* Proto update for backup

* [create-pull-request] automated change (#6133)

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

* Update protobufs

* Space

* [create-pull-request] automated change (#6144)

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

* Protos

* [create-pull-request] automated change (#6152)

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

* Updeet

* device-ui lib update

* fix channel OK button

* device-lib update: fix settings panel -> no scrolling

* device-ui lib: last minute update

* defined(SENSECAP_INDICATOR)

* MUI hot-fix pub/priv keys

* MUI hot-fix username dialog

* MUI: BT programming mode button

* Update protobufs

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GUVWAF <78759985+GUVWAF@users.noreply.github.com>
Co-authored-by: GUVWAF <thijs@havinga.eu>
Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
Co-authored-by: mverch67 <manuel.verch@gmx.de>
Co-authored-by: Manuel <71137295+mverch67@users.noreply.github.com>
Co-authored-by: todd-herbert <herbert.todd@gmail.com>
Co-authored-by: Jason Murray <15822260+scruplelesswizard@users.noreply.github.com>
Co-authored-by: Jason Murray <jason@chaosaffe.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Austin <vidplace7@gmail.com>
Co-authored-by: virgil <virgil.wang.cj@gmail.com>
Co-authored-by: Mark Trevor Birss <markbirss@gmail.com>
Co-authored-by: Kalle Lilja <15094562+ThatKalle@users.noreply.github.com>
Co-authored-by: rcarteraz <robert.l.carter2@gmail.com>
2025-03-01 06:18:33 -06:00
Austin 78da8f6fc4 Portduino: Allow limiting TX Power from yaml (#5954) 2025-01-29 12:51:26 +01:00
Ben Meadors 4fcf7fe027 Revert "Explicitly set CAD symbols, improve slot time calculation and adjust …" (#5765)
This reverts commit 9cc79b1d1e.
2025-01-05 18:55:55 -06:00
GUVWAFandBen Meadors 9cc79b1d1e Explicitly set CAD symbols, improve slot time calculation and adjust CW size accordingly (#5749)
Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2025-01-05 18:55:20 -06:00
Jonathan Bennett 92225eb6c3 DIO3_TCXO_VOLTAGE in config.yaml can now take an exact voltage (#5558) 2024-12-13 11:48:27 -06:00
Ben Meadors 50dac38a1b Pass#2: Lots more savings in logs and string reduction surgery (#5251)
* Pass#2: Lots more savings in logs and string reduction surgery

* Don't need Thread suffix either

* Warn
2024-11-04 12:16:25 -06:00
Tom Fifield 448c754882 LR1110 - remove old comment referring to non-existent function. (#5233)
It seems like there was no setrxgain function in RadioLib. Since
we're unlikely to uncomment and enable this non-existent feature,
remove this code.
2024-11-03 14:14:06 +01:00
Thomas Göttgens 18ca5b4449 Wide_Lora uses 12 symbols to be compatible with SX1280 2024-10-20 21:54:07 +02:00
Thomas GöttgensandBen Meadors 05e4a639a1 remove newline from logging statements. (#5022)
remove newline from logging statements in code. The LOG_* functions will now magically add it at the end.

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
2024-10-14 15:11:43 +11:00
4f8f96ab29 preliminary Othernet Dreamcatcher Support (#4933)
* preliminary Othernet Dreamcatcher 2206 Support
Need to adapt to 2301 final version

* second target for latest revision

* preliminary Othernet Dreamcatcher 2206 Support
Need to adapt to 2301 final version

* second target for latest revision

* preliminary Othernet Dreamcatcher 2206 Support
Need to adapt to 2301 final version

* second target for latest revision

* address comments

---------

Co-authored-by: Ben Meadors <benmmeadors@gmail.com>
Co-authored-by: Tom Fifield <tom@tomfifield.net>
2024-10-08 07:05:13 -05:00
Ben Meadors 8ad89ba724 Allow for better target level Radiolib exclude plumbing (#4906)
* WIP

* LR11x0

* Anothern

* =1
2024-09-30 05:14:22 -05:00
Thomas Göttgens 7289f36600 Merge branch 'master' into t3s3-1121 2024-09-28 17:51:21 +02:00
GUVWAFandThomas Göttgens 12efedec42 Potential fix for bad Rx performance on T1000-E (#4885)
* Potential fix for bad Rx performance on T1000-E

* validate and refactor RF switching, eliminate godmode

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
2024-09-28 09:24:13 -05:00
Thomas Göttgens 5c77e29d66 Merge branch 'master' into t3s3-1121 2024-09-26 00:17:41 +02:00