From 0fc168d87587dedd3486f5d3cb6b00d8e821b1b3 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:14:40 +0000 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01REkPVFh6kvG4AZJ5A8AtM9 (cherry picked from commit 57d6a86b71de9465014a70a9d92c4d7436388b1e) --------- Co-authored-by: Jonathan Bennett Co-authored-by: Claude Opus 5.5 --- src/mesh/LR11x0Interface.cpp | 72 ++++++- src/mesh/LR11x0Interface.h | 5 + src/mesh/LR20x0Interface.cpp | 57 +++++- src/mesh/LR20x0Interface.h | 5 + src/mesh/RF95Interface.h | 7 + src/mesh/RadioInterface.cpp | 16 +- src/mesh/RadioInterface.h | 34 +++- src/mesh/RadioLibInterface.cpp | 183 +++++++++++++++--- src/mesh/RadioLibInterface.h | 43 +++- src/mesh/SX126xInterface.cpp | 56 ++++-- src/mesh/SX126xInterface.h | 3 + src/mesh/SX128xInterface.cpp | 49 ++++- src/mesh/SX128xInterface.h | 14 +- src/modules/AdminModule.cpp | 8 +- src/sleep.cpp | 104 ++++++++-- test/test_radio/test_main.cpp | 39 ++++ .../nrf52_promicro_diy_tcxo/platformio.ini | 14 ++ .../diy/nrf52_promicro_diy_tcxo/variant.h | 34 +++- 18 files changed, 651 insertions(+), 92 deletions(-) diff --git a/src/mesh/LR11x0Interface.cpp b/src/mesh/LR11x0Interface.cpp index 57dfb18c92..018ced693f 100644 --- a/src/mesh/LR11x0Interface.cpp +++ b/src/mesh/LR11x0Interface.cpp @@ -505,19 +505,75 @@ template void LR11x0Interface::startReceive() /** Is the channel currently active? */ template bool LR11x0Interface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, - .detPeak = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, + // check if we can detect a LoRa preamble on the current channel. + // symNum is SetCadParams SymbolNum - a plain count - so take it straight from getCadSymbolCount(), + // which follows the band (8 on 2.4 GHz, as SX1280 scans) and is what sizes the CW slot. + const uint8_t symNum = getCadSymbolCount(); + // detPeak: Semtech SWSD003 lr11xx/apps/cad/main_cad.c optimized_parameters[symbols][BW][SF5..SF12], + // its measured best CAD detection rates. RadioLib's default is this table's [2 symbols][BW250] row. + // 50 = SWSD003's CAD_DETECT_PEAK fallback, used where it has no measured value. + static constexpr uint8_t CAD_DET_PEAK[4][4][8] = { + // Each block is one symbol count. Within a block the 4 rows are BW 62.5 / 125 / 250 / 500 kHz, + // and the 8 columns are SF5..SF12. + // 2 symbols: + {{39, 45, 47, 53, 59, 61, 64, 63}, + {44, 51, 49, 55, 56, 60, 62, 68}, + {48, 48, 50, 55, 55, 59, 61, 65}, + {76, 80, 71, 77, 69, 50, 50, 50}}, // SF10-12: SWSD003 has no measurement, 50 is its fallback + // 4 symbols: + {{43, 45, 45, 50, 53, 57, 59, 63}, + {44, 46, 49, 53, 53, 55, 57, 62}, + {45, 47, 47, 51, 51, 56, 59, 62}, + {58, 66, 58, 65, 62, 55, 60, 57}}, + // 8 symbols: + {{43, 44, 46, 48, 51, 53, 56, 59}, + {45, 43, 44, 50, 52, 55, 56, 61}, + {42, 44, 45, 48, 50, 53, 55, 60}, + {49, 52, 50, 59, 56, 57, 57, 60}}, + // 16 symbols: + {{41, 44, 43, 46, 49, 52, 54, 60}, + {42, 42, 43, 48, 49, 53, 55, 59}, + {41, 42, 43, 48, 48, 53, 54, 58}, + {44, 47, 45, 53, 52, 53, 57, 62}}}; + // SWSD003 characterises symbol counts 2/4/8/16 against the sub-GHz LoRa modem's four bandwidths. + // Use exact matches to avoid mixing widelora (406.25/812.5/1625 kHz) + // Anything unmatched uses RadioLib default. + // Whole sub-GHz set today; a narrower BW or a symbol count outside 2/4/8/16 needs a row adding. + static constexpr float TABLE_BW_KHZ[4] = {62.5f, 125.0f, 250.0f, 500.0f}; + const int symIdx = symNum == 2 ? 0 : symNum == 4 ? 1 : symNum == 8 ? 2 : symNum == 16 ? 3 : -1; + int bwIdx = -1; + for (int i = 0; i < 4; i++) { + if (bw > TABLE_BW_KHZ[i] - 1.0f && bw < TABLE_BW_KHZ[i] + 1.0f) + bwIdx = i; + } + const uint8_t detPeak = (symIdx < 0 || bwIdx < 0) ? (uint8_t)RADIOLIB_LR11X0_CAD_PARAM_DEFAULT + : CAD_DET_PEAK[symIdx][bwIdx][(sf >= 5 && sf <= 12) ? sf - 5 : 6]; + // Keep preamble/header off the pin - they would fire the ISR mid-frame. Same set the normal RX path + // already programs (stageMode sends irqFlags & irqMask), so isActivelyReceiving() sees no change. + const uint32_t cadIrqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR); + // UM Table 8-8: the timeout bounds the RX that follows a detection, so use one max-length airtime. + // RadioLib scales it by 30.52 us against a real 31.25, landing ~2% long - not worth pre-compensating. + const RadioLibTime_t cadRxTimeoutUsec = + (RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000; + ChannelScanConfig_t cfg = {.cad = {.symNum = symNum, + .detPeak = detPeak, + // PARAM_DEFAULT lands on 10 in RadioLib, which is SWSD003's CAD_DETECT_MIN .detMin = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, - .exitMode = RADIOLIB_LR11X0_CAD_PARAM_DEFAULT, - .timeout = 0, - .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, - .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + .exitMode = RADIOLIB_LR11X0_CAD_EXIT_MODE_RX, + .timeout = cadRxTimeoutUsec, + .irqFlags = cadIrqFlags, + .irqMask = cadIrqFlags}}; // ignored: startChannelScan() sends irqFlags twice int16_t result = trySetStandby(); if (result == RADIOLIB_ERR_NONE) { result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) + if (result == RADIOLIB_LORA_DETECTED) { + // The chip auto-entered RX. Drop the latched CAD verdict so the pin releases and the coming + // RX_DONE is a clean edge. + lora.clearIrqFlags(RADIOLIB_LR11X0_IRQ_CAD_DONE | RADIOLIB_LR11X0_IRQ_CAD_DETECTED); + noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it return true; + } if (result != RADIOLIB_ERR_WRONG_MODEM) return false; } diff --git a/src/mesh/LR11x0Interface.h b/src/mesh/LR11x0Interface.h index 95c6cb1b1f..2042bbd11f 100644 --- a/src/mesh/LR11x0Interface.h +++ b/src/mesh/LR11x0Interface.h @@ -79,6 +79,11 @@ template class LR11x0Interface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // LR1120/LR1121 work in both bands. 4 sub-GHz is the SWSD003 table's row we use; 8 on 2.4 GHz + // matches SX1280, the other part sharing that band, so one mesh keeps one CW slot. + uint8_t getCadSymbolCountSubGhz() const override { return 4; } + uint8_t getCadSymbolCountWideLora() const override { return 8; } + private: /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ int16_t programModemParams(); diff --git a/src/mesh/LR20x0Interface.cpp b/src/mesh/LR20x0Interface.cpp index 2fef848d07..8c11866a54 100644 --- a/src/mesh/LR20x0Interface.cpp +++ b/src/mesh/LR20x0Interface.cpp @@ -636,19 +636,60 @@ template void LR20x0Interface::startReceive() /** Is the channel currently active? */ template bool LR20x0Interface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, - .detPeak = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, + // check if we can detect a LoRa preamble on the current channel. + // symNum is SetLoraCadParams nb_symbols - a plain count - so take it straight from + // getCadSymbolCount(), which follows the band (8 on 2.4 GHz, as SX1280 scans) and sizes the CW slot. + // det_peak is the peak-to-average ratio threshold: a higher value demands a stronger correlation + // peak and so detects less readily, which is why the recommended value falls as the scan lengthens. + // Indexed by that same count, so the threshold tracks the scan instead of drifting out of step. + // Table 6-19 stops at 4 symbols, so a longer scan reuses that row - the last measured point, and + // erring high, i.e. slightly less sensitive than a true 8-symbol value would be. + static constexpr uint8_t CAD_DET_PEAK[4][8] = { + // LR20xx datasheet rev 2.2, Table 6-19 - recommended det_peak, SF5..SF12 + {60, 60, 60, 64, 64, 66, 70, 74}, // 1 symbol + {56, 56, 56, 58, 58, 60, 64, 68}, // 2 symbols (RadioLib's default row) + {51, 51, 52, 54, 56, 60, 60, 65}, // 3 symbols + {51, 51, 51, 54, 56, 60, 60, 64}, // 4 symbols + }; + const uint8_t symNum = getCadSymbolCount(); + const uint8_t detPeak = CAD_DET_PEAK[(symNum >= 1 && symNum <= 4) ? symNum - 1 : 3] + [(sf >= 5 && sf <= 12) ? sf - 5 : 6]; // out of range: 4 symbols, SF11 + // Times the RX that follows a detection. lr20xx_driver calls this field PLL steps of 31.25 us (the + // datasheet's "32 MHz periods" disagrees); RadioLib scales it as 30.52, so we land ~2% long. + const RadioLibTime_t cadRxTimeoutUsec = + (RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000; + // SetLoraCadParams exit mode: 0x00 CAD only, 0x01 RX on detection, 0x10 TX when clear, as on LR11x0. RadioLib's + // RADIOLIB_LR2021_CAD_EXIT_MODE_RX is 0x02, which the chip refuses with a processing error (-706), so no scan runs. + static constexpr uint8_t CAD_EXIT_MODE_RX = 0x01; + ChannelScanConfig_t cfg = {.cad = {.symNum = symNum, + .detPeak = detPeak, + // ignored: SetLoraCadParams has no det_min - that byte carries + // pnr_delta, which scanChannel() takes from lora.fastCad below .detMin = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, - .exitMode = RADIOLIB_LR2021_CAD_PARAM_DEFAULT, - .timeout = 0, - .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, - .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + .exitMode = CAD_EXIT_MODE_RX, + .timeout = cadRxTimeoutUsec, + // DS rev 2.2 6.8.3: only routes IRQs to a pin, so keep + // preamble/header off it - they would fire the ISR mid-frame + .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_RX_DONE) | + (1UL << RADIOLIB_IRQ_TIMEOUT) | (1UL << RADIOLIB_IRQ_CRC_ERR) | + (1UL << RADIOLIB_IRQ_HEADER_ERR), + .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; // ignored: startChannelScan() drops it + // fastCad is not in ChannelScanConfig_t - scanChannel() reads it off the radio object - so pin it. + // false is pnr_delta 0: the scan runs the full nb_symbols, which is what the slot time assumes. + lora.fastCad = false; + int16_t result = trySetStandby(); if (result == RADIOLIB_ERR_NONE) { result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) + if (result == RADIOLIB_LORA_DETECTED) { + // The chip auto-entered RX. Drop the latched CAD verdict so the pin releases and the coming + // RX_DONE is a clean edge. + lora.clearIrqFlags(RADIOLIB_LR2021_IRQ_CAD_DONE | RADIOLIB_LR2021_IRQ_CAD_DETECTED); + noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it return true; + } + if (result != RADIOLIB_CHANNEL_FREE && result != RADIOLIB_ERR_WRONG_MODEM) + LOG_WARN("LR20x0 channel scan failed %s%d, reported clear", radioLibErr, result); if (result != RADIOLIB_ERR_WRONG_MODEM) return false; } diff --git a/src/mesh/LR20x0Interface.h b/src/mesh/LR20x0Interface.h index 1372e47426..5b5802eea1 100644 --- a/src/mesh/LR20x0Interface.h +++ b/src/mesh/LR20x0Interface.h @@ -83,6 +83,11 @@ template class LR20x0Interface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // LR2021 works in both bands. 4 sub-GHz is Table 6-19's row we use; 8 on 2.4 GHz matches SX1280, + // the other part sharing that band, so one mesh keeps one CW slot. + uint8_t getCadSymbolCountSubGhz() const override { return 4; } + uint8_t getCadSymbolCountWideLora() const override { return 8; } + private: /** Chip-side re-init shared by the band-hop and recovery paths: front-end GPIOs, begin(), CRC, RF switch, RX gain */ bool fullBegin(float freq); diff --git a/src/mesh/RF95Interface.h b/src/mesh/RF95Interface.h index 4536dbd502..57df6e8554 100644 --- a/src/mesh/RF95Interface.h +++ b/src/mesh/RF95Interface.h @@ -53,9 +53,16 @@ class RF95Interface : public RadioLibInterface /** can we detect a LoRa preamble on the current channel? */ virtual bool isChannelActive() override; + // Sub-GHz only, CAD fixed in hardware at ~2.25 symbols; the legacy scanChannel() takes no symNum. + // 2 puts the slot on its 2.5-symbol floor, which is the closest the shared formula gets. + uint8_t getCadSymbolCountSubGhz() const override { return 2; } + /** are we actively receiving a packet (only called during receiving state) */ virtual bool isActivelyReceiving() override; + // Carrier sense here reads modem status, not latched flags, and SX127x maps no PREAMBLE or HEADER_ERR. + void checkStaleRxFlags() override {} + /** * Start waiting to receive a message */ diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 4ce58f4f5f..e840bcc229 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1425,17 +1425,25 @@ void RadioInterface::applyModemConfig() - roundtrip air propagation time (assuming max. 30km between nodes); - Tx/Rx turnaround time (maximum of SX126x and SX127x); - MAC processing time (measured on T-beam) */ +uint8_t RadioInterface::getCadSymbolCount() const +{ + return myRegion->wideLora ? getCadSymbolCountWideLora() : getCadSymbolCountSubGhz(); +} + uint32_t RadioInterface::computeSlotTimeMsec() { float sumPropagationTurnaroundMACTime = 0.2 + 0.4 + 7; // in milliseconds float symbolTime = pow_of_2(sf) / bw; // in milliseconds if (myRegion->wideLora) { - // CAD duration derived from AN1200.22 of SX1280 - return (NUM_SYM_CAD_24GHZ + (2 * sf + 3) / 32) * symbolTime + sumPropagationTurnaroundMACTime; + // SX1280 datasheet rev 3.3: CAD duration = (cadSymbolNum + (2*SF + 3) / 32) * Ts, the trailing + // term being the post-scan processing window. Float division: as ints it truncates to 0 for + // every legal SF. SX1280, LR1120 and LR2021 all run here, so take the count from the driver. + return (getCadSymbolCount() + (2.0f * sf + 3) / 32) * symbolTime + sumPropagationTurnaroundMACTime; } else { - // CAD duration for SX127x is max. 2.25 symbols, for SX126x it is number of symbols + 0.5 symbol - return max(2.25, NUM_SYM_CAD + 0.5) * symbolTime + sumPropagationTurnaroundMACTime; + // CAD duration for SX127x is max. 2.25 symbols, for SX126x it is number of symbols + 0.5 symbol. + // getCadSymbolCount() reports the symbols the scan really runs. + return max(2.25, getCadSymbolCount() + 0.5) * symbolTime + sumPropagationTurnaroundMACTime; } } diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 67870d0ad4..14e016b9ea 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -98,10 +98,15 @@ class RadioInterface uint8_t sf = 9; uint8_t cr = 5; - static constexpr uint8_t NUM_SYM_CAD = - 2; // Number of symbols used for CAD, 2 is the default since RadioLib 6.3.0 as per AN1200.48 - static constexpr uint8_t NUM_SYM_CAD_24GHZ = - 4; // Number of symbols used for CAD in 2.4 GHz, 4 is recommended in AN1200.22 of SX1280 + // Fallback per-band counts for a driver that states nothing of its own. Every driver in the tree + // states its own, and they do NOT all agree: the slot has to match the scan each part really runs, + // so a mesh mixing RF95 (2.25 symbols, fixed in hardware) with SX126x/LR (4) runs two slot lengths. + // Uncited on purpose - no single vendor note covers every part they cover. AN1200.77 backs the + // 2.4 GHz figure for SX1280: a longer window moves P(Detection) away from P(False Detection). + static constexpr uint8_t NUM_SYM_CAD = 4; + static constexpr uint8_t NUM_SYM_CAD_24GHZ = 8; + // Runs during RadioInterface's own construction, so it uses the fallbacks above, never a driver + // override. applyModemConfig() recomputes it before anything reads it, and is authoritative. uint32_t slotTimeMsec = computeSlotTimeMsec(); uint16_t preambleLength = 16; // 8 is default, but we use longer to increase the amount of sleep time when receiving static constexpr uint16_t preambleLengthDefault = @@ -118,6 +123,15 @@ class RadioInterface uint32_t computeSlotTimeMsec(); + // Symbols this part scans, declared per band: a part that works in both need not scan the same + // length in each, and the parts sharing a band do not all agree. Each driver states its own, and + // must pass a matching symNum - these are what size the CW slot, so a mismatch mis-scales backoff. + virtual uint8_t getCadSymbolCountSubGhz() const { return NUM_SYM_CAD; } + virtual uint8_t getCadSymbolCountWideLora() const { return NUM_SYM_CAD_24GHZ; } + + /** Whichever of the two applies to the band actually in use. */ + uint8_t getCadSymbolCount() const; + /** * A temporary buffer used for sending/receiving packets, sized to hold the biggest buffer we might need * */ @@ -341,5 +355,17 @@ class RadioInterface std::unique_ptr initLoRa(); +// What the plain-RX poll does with PREAMBLE/HEADER flags that no RX_DONE has consumed. +enum class StaleRxFlagAction : uint8_t { Keep, ClearPreamble, Rearm }; + +// RX_DONE clears every flag, so one still latched a max packet after first sight has no frame behind it. +// A header may mean a wedged demodulator (SX1280 DS 16.2), so re-arm; a bare preamble is only cleared. +constexpr StaleRxFlagAction staleRxFlagAction(bool headerSeen, uint32_t sinceFirstSeenMsec, uint32_t maxPacketTimeMsec) +{ + return sinceFirstSeenMsec < maxPacketTimeMsec ? StaleRxFlagAction::Keep + : headerSeen ? StaleRxFlagAction::Rearm + : StaleRxFlagAction::ClearPreamble; +} + /// Debug printing for packets void printPacket(const char *prefix, const meshtastic_MeshPacket *p); diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 83fa533d24..13211ab7bd 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -416,18 +416,23 @@ void RadioLibInterface::onNotify(uint32_t notification) { switch (notification) { - case ISR_TX: - handleTransmitInterrupt(); // completeSending() already restored the radio to the home config + case ISR_TX: { + // The chip is deaf in standby until startReceive(), so the airtime log and printPacket() wait until after it. + meshtastic_MeshPacket *sent = handleTransmitInterrupt(); // radio already back on the home config // Let the hooks pre-stage the radio for the NEXT queued packet. Not required for correctness - // TRANSMIT_DELAY_COMPLETED asks again before the scan, which is where the answer is acted on - // but it keeps the post-TX listen window on the channel we are about to transmit on. (void)RadioTxHooks::beforeTransmit(this, txQueue.getFront()); startReceive(); setTransmitDelay(); + finishSentPacket(sent); break; + } case ISR_RX: handleReceiveInterrupt(); - startReceive(); + // Re-arm for the next packet. rearmReceive() avoids a standby where the chip is already in RX, + // so a second packet that is already arriving is not aborted. + rearmReceive(); setTransmitDelay(); break; case ISR_POLL_TICK: @@ -461,12 +466,16 @@ void RadioLibInterface::onNotify(uint32_t notification) } else if (action == RadioTxHook::PRETX_DEFER) { setTransmitDelay(); // the radio config moved, so re-run the delay and scan on it } else { - if (isChannelActive()) { // check if there is currently a LoRa packet on the channel - if (!RadioTxHooks::holdsRadio(txp)) { - startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again - } + // Listen-before-talk: a CAD preamble scan immediately before we key up. + LOG_DEBUG("CAD arm"); + if (isChannelActive()) { // currently traffic on the channel? + LOG_DEBUG("CAD busy"); + // Beacon target or not: reconfigureForBeaconTX() already left RX running on that + // config, so skipping this only ever left the node deaf in standby. + rearmReceive(); setTransmitDelay(); } else { + LOG_DEBUG("CAD free"); // Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and // actual transmission as short as possible txp = txQueue.dequeue(); @@ -578,47 +587,58 @@ bool RadioLibInterface::removePendingTXPacket(NodeNum from, PacketId id, uint32_ return false; } -void RadioLibInterface::handleTransmitInterrupt() +meshtastic_MeshPacket *RadioLibInterface::handleTransmitInterrupt() { - // This can be null if we forced the device to enter standby mode. In that case - // ignore the transmit interrupt - if (sendingPacket) - completeSending(); + // Null if we forced the device into standby, which already completed the send. + meshtastic_MeshPacket *sent = detachSentPacket(); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); // But our transmitter is definitely off now + return sent; } void RadioLibInterface::completeSending() { - // We are careful to clear sending packet before calling printPacket because - // that can take a long time + finishSentPacket(detachSentPacket()); +} + +meshtastic_MeshPacket *RadioLibInterface::detachSentPacket() +{ + // Cleared first: printPacket() in finishSentPacket() can take a long time. auto p = sendingPacket; sendingPacket = NULL; #ifdef LED_LORA digitalWrite(LED_LORA, LED_STATE_OFF); #endif - - if (p) { - // Packet has been sent, count it toward our TX airtime utilization. - uint32_t xmitMsec = getPacketTime(p); - airTime->logAirtime(TX_LOG, xmitMsec); - - txGood++; - if (!isFromUs(p)) - txRelay++; - printPacket("Completed sending", p); - // Keep this inside `if (p)`: completeSending() also runs on every setStandby(), where a hook - // undoing its own pre-TX switch would recurse back through reconfigure(). + // Keep this behind `if (p)`: completeSending() also runs on every setStandby(), where a hook + // undoing its own pre-TX switch would recurse back through reconfigure(). + if (p) RadioTxHooks::packetReleased(this, p); + return p; +} - // We are done sending that packet, release it - packetPool.release(p); - } +void RadioLibInterface::finishSentPacket(meshtastic_MeshPacket *p) +{ + if (!p) + return; + // Packet has been sent, count it toward our TX airtime utilization. + uint32_t xmitMsec = getPacketTime(p); + airTime->logAirtime(TX_LOG, xmitMsec); + + txGood++; + if (!isFromUs(p)) + txRelay++; + printPacket("Completed sending", p); + + // We are done sending that packet, release it + packetPool.release(p); } void RadioLibInterface::handleReceiveInterrupt() { // when this is called, we should be in receive mode - if we are not, just jump out instead of bombing. Possible Race // Condition? + const bool wasCadHandoff = cadHandoffRxStart != 0; + cadHandoffRxStart = 0; // this RX ends the wait either way; the outcome is logged below + if (!isReceiving) { LOG_ERROR("handleReceiveInterrupt called while not in rx mode"); return; @@ -626,6 +646,17 @@ void RadioLibInterface::handleReceiveInterrupt() isReceiving = false; + // A CAD handoff's RX window expired with nothing on air. There is no packet to read, so don't count + // it as a bad one - the caller's rearmReceive() puts the radio back to listening. + if (iface->checkIrq(RADIOLIB_IRQ_RX_DONE) != 1 && iface->checkIrq(RADIOLIB_IRQ_TIMEOUT) == 1) { + LOG_DEBUG("CAD>RX empty"); + iface->clearIrq(1UL << RADIOLIB_IRQ_TIMEOUT); + return; + } + + if (wasCadHandoff) + LOG_DEBUG("CAD>RX pkt"); + // read the number of actually received bytes size_t length = iface->getPacketLength(); @@ -731,6 +762,7 @@ void RadioLibInterface::startReceive() // This is the sole place the recovery ladder is cleared - nothing short of an armed RX counts as fixed. rxOffline = false; chipRecoveryFailures = 0; + rxFlagsSeenMs = 0; powerMon->setState(meshtastic_PowerMon_State_Lora_RXOn); } @@ -739,6 +771,8 @@ void RadioLibInterface::pollMissedIrqs() // RadioLibInterface::enableInterrupt uses EDGE-TRIGGERED interrupts. Poll as a backup to catch missed edges. if (isReceiving) { checkRxDoneIrqFlag(); + checkCadHandoffTimeout(); + checkStaleRxFlags(); } if (sendingPacket) { checkTxDoneIrqFlag(); @@ -751,6 +785,82 @@ bool RadioLibInterface::resetAGC() return false; } +void RadioLibInterface::noteCadHandoffToRx() +{ + LOG_DEBUG("CAD>RX started"); + cadHandedToRx = true; + // Same clock Throttle compares against, so a native test can drive both across the wrap. 0 is the + // "none outstanding" sentinel and getMillis() does land on it once per wrap, so step past it. + const uint32_t now = Time::getMillis(); + cadHandoffRxStart = now ? now : 1; +} + +void RadioLibInterface::rearmReceive() +{ + // The flag is spent here, so every later call takes the full path - including RX_DONE after a + // handoff, whose bounded RX has already dropped the chip to standby. + if (!cadHandedToRx) { + startReceive(); + return; + } + cadHandedToRx = false; + // Same order the drivers' own startReceive() uses: mark receiving BEFORE arming, or an ISR that + // fires in between reaches handleReceiveInterrupt() while isReceiving is still false and is dropped. + RadioLibInterface::startReceive(); + enableInterrupt(isrRxLevel0); + // The line is not known-low here, and the ISR is rising-edge: catch an RX_DONE that beat the arm. + checkRxDoneIrqFlag(); +} + +void RadioLibInterface::checkCadHandoffTimeout() +{ + // Backstop to the chip's own cadTimeout, which is the primary bound. Doubled so the hardware always + // expires first; this only catches an RX whose timer stopped on a header that never completed. + const uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader)); + if (cadHandoffRxStart && Throttle::hasElapsed(cadHandoffRxStart, 2 * maxPacketTimeMsec)) { + LOG_WARN("CAD>RX timeout"); + cadHandoffRxStart = 0; + startReceive(); + } +} + +void RadioLibInterface::checkStaleRxFlags() +{ + // A handoff RX has its own timeout, and a pending RX_DONE is about to clear every flag itself. + if (cadHandoffRxStart) + return; + const uint32_t irq = iface->getIrqFlags(); + if (irq & iface->getIrqMapped(1UL << RADIOLIB_IRQ_RX_DONE)) + return; + // HEADER_ERR counts: on SX1280 it leaves RX wedged with no RX_DONE or TIMEOUT to follow. + const bool headerSeen = irq & iface->getIrqMapped((1UL << RADIOLIB_IRQ_HEADER_VALID) | (1UL << RADIOLIB_IRQ_HEADER_ERR)); + const bool preambleSeen = irq & iface->getIrqMapped(1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + if (!headerSeen && !preambleSeen) { + rxFlagsSeenMs = 0; + return; + } + if (!rxFlagsSeenMs) { + rxFlagsSeenMs = Time::skipZero(Time::getMillis()); + return; + } + + const uint32_t maxPacketTimeMsec = getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader)); + switch (staleRxFlagAction(headerSeen, Time::getMillis() - rxFlagsSeenMs, maxPacketTimeMsec)) { + case StaleRxFlagAction::Keep: + break; + case StaleRxFlagAction::Rearm: + LOG_DEBUG("RX header stale, re-arm"); + startReceive(); // clears rxFlagsSeenMs + break; + case StaleRxFlagAction::ClearPreamble: + // A clear never aborts a reception, unlike the standby inside startReceive(). + LOG_DEBUG("RX preamble stale, cleared"); + iface->clearIrq(1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + rxFlagsSeenMs = 0; + break; + } +} + void RadioLibInterface::periodicRadioMaintenance() { // Every startReceive() call site is event-driven (RX/TX ISR, the CAD-busy branch, reconfigure), and a @@ -762,6 +872,9 @@ void RadioLibInterface::periodicRadioMaintenance() startReceive(); return; // a chip just re-inited (or still dead) has no use for an AGC reset this tick } + // resetAGC() ends in startReceive(), whose standby would run a queued packet's TX delay, start it and cut it off. + if (hasQueuedTx()) + return; // A radio that is still decoding packets has gain that isn't stuck const bool hearing = lastRxGoodMs && Throttle::isWithinTimespanMs(lastRxGoodMs, AGC_IDLE_RESET_MS); @@ -824,6 +937,13 @@ void RadioLibInterface::configHardwareForSend() void RadioLibInterface::setStandby() { + // Any handoff is void once the chip leaves RX. Left set, the flag would make the next rearmReceive() + // a no-op on a standby chip - deaf with no recovery - and the window would re-arm over a live packet. + if (cadHandedToRx) // standby between the handoff and the re-arm that adopts it: should not happen + LOG_WARN("CAD>RX void"); + cadHandedToRx = false; + cadHandoffRxStart = 0; + // neither sending nor receiving powerMon->clearState(meshtastic_PowerMon_State_Lora_RXOn); powerMon->clearState(meshtastic_PowerMon_State_Lora_TXOn); @@ -832,6 +952,8 @@ void RadioLibInterface::setStandby() /** start an immediate transmit */ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) { + cadHandoffRxStart = 0; // TX ends any handoff wait; completeSending() re-arms RX itself + /* NOTE: Minimize the actions before startTransmit() to keep the time between channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { @@ -839,6 +961,9 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) // Never reaches completeSending(), so any per-packet radio state has to be released here. RadioTxHooks::packetReleased(this, txp); packetPool.release(txp); + // We got here through isChannelActive(), which left the chip in standby for a transmit that is + // no longer happening. Without this the node stays deaf until something else re-arms it. + startReceive(); return false; } else { configHardwareForSend(); // must be after setStandby diff --git a/src/mesh/RadioLibInterface.h b/src/mesh/RadioLibInterface.h index 53153e369f..1daff47dd9 100644 --- a/src/mesh/RadioLibInterface.h +++ b/src/mesh/RadioLibInterface.h @@ -194,6 +194,26 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ void pollMissedIrqs(); + // Time::getMillis() at which a CAD->RX handoff left the chip listening without us arming it, or 0 + // if none is outstanding. 0 is a sentinel, so it must be tested before any elapsed comparison. + uint32_t cadHandoffRxStart = 0; + + // True between the handoff and the rearmReceive() that consumes it: the chip is already in RX, so + // that one re-arm must not standby. Both fields are cleared by setStandby(). + bool cadHandedToRx = false; + + /** Record that CAD left the chip in RX: arms both the flag and the no-show window below. */ + void noteCadHandoffToRx(); + + /** Re-arm if a CAD->RX handoff has produced no packet well past one max-length airtime. */ + void checkCadHandoffTimeout(); + + // Time::getMillis() when plain RX was first seen holding PREAMBLE/HEADER flags, or 0 if none. + uint32_t rxFlagsSeenMs = 0; + + /** Plain-RX twin of checkCadHandoffTimeout(): retire flags no RX_DONE consumed within a max packet. */ + virtual void checkStaleRxFlags(); + /** * Reset AGC by power-cycling the analog frontend. * Subclasses override with chip-specific calibration sequences. @@ -249,7 +269,16 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ virtual void startReceive(); - /** can we detect a LoRa preamble on the current channel? */ + /** + * Re-arm RX after a busy-channel CAD detect or after servicing an RX_DONE. Normally a full + * startReceive(); after a CAD->RX handoff the chip is already listening, so that one re-arm + * re-attaches the MCU ISR only - a startReceive() there would standby over the packet CAD found. + */ + void rearmReceive(); + + /** can we detect a LoRa preamble on the current channel? + * A true return means the chip may have been handed to RX in place, so the caller MUST follow it + * with rearmReceive() before anything else touches the radio. */ virtual bool isChannelActive() = 0; /** are we actively receiving a packet (only called during receiving state) @@ -318,7 +347,8 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified */ void startTransmitTimerRebroadcast(meshtastic_MeshPacket *p); - void handleTransmitInterrupt(); + /** Detach the sent packet and undo its pre-TX switch; the caller re-arms RX, then finishSentPacket(). */ + meshtastic_MeshPacket *handleTransmitInterrupt(); void handleReceiveInterrupt(); static void timerCallback(void *p1, uint32_t p2); @@ -336,6 +366,9 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified protected: uint32_t activeReceiveStart = 0; + /** Whether a packet is waiting to transmit; txQueue itself stays private. */ + bool hasQueuedTx() { return !txQueue.empty(); } + bool receiveDetected(uint16_t irq, unsigned long syncWordHeaderValidFlag, unsigned long preambleDetectedFlag); /** Do any hardware setup needed on entry into send configuration for the radio. @@ -358,6 +391,12 @@ class RadioLibInterface : public RadioInterface, protected concurrency::Notified * If a send was in progress finish it and return the buffer to the pool */ void completeSending(); + /** Clear sendingPacket and release its per-packet radio state; returns the packet, or null. */ + meshtastic_MeshPacket *detachSentPacket(); + + /** Airtime, counters, log and pool release for a packet detachSentPacket() returned. */ + void finishSentPacket(meshtastic_MeshPacket *p); + /** * Add SNR data to received messages */ diff --git a/src/mesh/SX126xInterface.cpp b/src/mesh/SX126xInterface.cpp index 3ee23e850b..2d0af23073 100644 --- a/src/mesh/SX126xInterface.cpp +++ b/src/mesh/SX126xInterface.cpp @@ -184,6 +184,13 @@ template bool SX126xInterface::reinitChip() LOG_DEBUG_RADIO("Set DIO2 as %sRF switch, result: %d", dio2AsRfSwitch ? "" : "not ", res); } + if (res == RADIOLIB_ERR_NONE && tcxoVoltage > 0) { + // Standby and TX/RX fallback on STDBY_XOSC keep the TCXO powered: from STDBY_RC every SetRx and SetTx + // first waits out the TCXO start-up delay (5 ms by RadioLib's default). + const int16_t xoscRes = lora.setStandbyXOSC(true); + LOG_DEBUG("Keep TCXO on in standby, result: %d", xoscRes); + } + // If a pin isn't defined, we set it to RADIOLIB_NC, it is safe to always do external RF switching with RADIOLIB_NC as it has // no effect #if ARCH_PORTDUINO @@ -372,11 +379,11 @@ template void SX126xInterface::handleSoftwareLoraIrqPoll() RADIOLIB_SX126X_IRQ_RX_DONE | RADIOLIB_SX126X_IRQ_TIMEOUT | RADIOLIB_SX126X_IRQ_CRC_ERR | RADIOLIB_SX126X_IRQ_HEADER_ERR; const uint16_t noisyRxMask = RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED | RADIOLIB_SX126X_IRQ_HEADER_VALID; - // Do NOT treat a preamble/header-only IRQ as a full RX event: noisy preamble detections would - // repeatedly trigger readData() and starve TX scheduling. Clear these non-terminal bits, or the - // poll loop spins at high rate while they stay latched. - if (!pollTxMode && (irq & noisyRxMask) && ((irq & ~noisyRxMask) == 0U)) { - lora.clearIrqFlags(noisyRxMask); + // A bare PREAMBLE is mid-reception, not an RX event. With a TX queued clear only it, so a noise preamble + // can't block TX; keep HEADER_VALID latched, as it marks a real frame. + const bool preambleOnly = (irq & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED) && !(irq & RADIOLIB_SX126X_IRQ_HEADER_VALID); + if (!pollTxMode && hasQueuedTx() && preambleOnly && ((irq & ~noisyRxMask) == 0U)) { + lora.clearIrqFlags(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED); scheduleIrqPollTick(); return; } @@ -400,6 +407,12 @@ template int16_t SX126xInterface::trySetStandby() checkNotification(); // handle any pending interrupts before we force standby int16_t err = lora.standby(); + if (err == RADIOLIB_ERR_SPI_CMD_TIMEOUT) { + // After a bounded RX times out, the status byte returned with SET_STANDBY still carries that timeout, and + // RadioLib reports it as a failed command although the chip took it. The next command sees a fresh status. + err = lora.standby(); + LOG_DEBUG("SX126x standby reported a stale command timeout, retry %s%d", radioLibErr, err); + } if (err != RADIOLIB_ERR_NONE) LOG_DEBUG_RADIO("SX126x standby %s%d", radioLibErr, err); @@ -504,20 +517,39 @@ template void SX126xInterface::startReceive() /** Is the channel currently active? */ template bool SX126xInterface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD, + // check if we can detect a LoRa preamble on the current channel. + // NOTE: symNum is the *encoded* SET_CAD_PARAMS value, not a raw count - RADIOLIB_SX126X_CAD_ON_4_SYMB + // (== raw 2) runs the 4-symbol scan getCadSymbolCountSubGhz() reports; keep the two in step. + // Exit CAD straight into RX on detection (GOTO_RX) with the RX IRQs already mapped, so the chip's + // own CAD->RX transition delivers RX_DONE with no library call in between. irqFlags is + // the status-enable set (CAD verdict + full RX set incl. PREAMBLE/HEADER_VALID for isActivelyReceiving); + // irqMask is the DIO-trigger set - CAD verdict + terminal RX events only, NOT PREAMBLE/HEADER_VALID, + // which would fire the ISR mid-frame and run readData() on an incomplete packet. + const uint32_t cadIrqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS | MESHTASTIC_RADIOLIB_IRQ_RX_FLAGS; + const uint32_t cadIrqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK | (1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) | + (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR); + // cadTimeout bounds the RX the chip enters on detection: one max-length airtime, in microseconds. A + // detection that delivers nothing then expires to standby and raises TIMEOUT, which re-arms us. + const RadioLibTime_t cadRxTimeoutUsec = + (RadioLibTime_t)getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN + sizeof(PacketHeader), false) * 1000; + ChannelScanConfig_t cfg = {.cad = {.symNum = RADIOLIB_SX126X_CAD_ON_4_SYMB, .detPeak = RADIOLIB_SX126X_CAD_PARAM_DEFAULT, .detMin = RADIOLIB_SX126X_CAD_PARAM_DEFAULT, - .exitMode = RADIOLIB_SX126X_CAD_PARAM_DEFAULT, - .timeout = 0, - .irqFlags = RADIOLIB_IRQ_CAD_DEFAULT_FLAGS, - .irqMask = RADIOLIB_IRQ_CAD_DEFAULT_MASK}}; + .exitMode = RADIOLIB_SX126X_CAD_GOTO_RX, + .timeout = cadRxTimeoutUsec, + .irqFlags = cadIrqFlags, + .irqMask = cadIrqMask}}; setTransmitEnable(false); int16_t result = trySetStandby(); if (result == RADIOLIB_ERR_NONE) { result = lora.scanChannel(cfg); - if (result == RADIOLIB_LORA_DETECTED) + if (result == RADIOLIB_LORA_DETECTED) { + // The chip auto-entered RX (GOTO_RX). Drop the latched CAD verdict so the pin releases and the + // coming RX_DONE is a clean edge. + lora.clearIrqFlags(RADIOLIB_SX126X_IRQ_CAD_DONE | RADIOLIB_SX126X_IRQ_CAD_DETECTED); + noteCadHandoffToRx(); // nothing below arms the radio; the caller's rearmReceive() adopts it return true; + } if (result != RADIOLIB_CHANNEL_FREE) LOG_ERROR("SX126X scanChannel %s%d", radioLibErr, result); if (result != RADIOLIB_ERR_WRONG_MODEM) diff --git a/src/mesh/SX126xInterface.h b/src/mesh/SX126xInterface.h index cdfc986ff5..c5eab14d10 100644 --- a/src/mesh/SX126xInterface.h +++ b/src/mesh/SX126xInterface.h @@ -83,6 +83,9 @@ template class SX126xInterface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // Sub-GHz only. isChannelActive() passes CAD_ON_4_SYMB; keep the two in step. + uint8_t getCadSymbolCountSubGhz() const override { return 4; } + private: #ifdef LORA_DIO1_SOFTWARE_POLL bool irqPollingActive = false; diff --git a/src/mesh/SX128xInterface.cpp b/src/mesh/SX128xInterface.cpp index 246c09dc70..948e6f83c2 100644 --- a/src/mesh/SX128xInterface.cpp +++ b/src/mesh/SX128xInterface.cpp @@ -10,6 +10,10 @@ #include "PortduinoGlue.h" #endif +// Peak-to-noise decision threshold for the CAD, the only CAD sensitivity control on this part. +// Reset value 0x32, well above every threshold AN1200.77 recommends. +#define SX1280_REG_CAD_DET_PEAK 0x0942 + // Particular boards might define a different max power based on what their hardware can do #if ARCH_PORTDUINO #define SX128X_MAX_POWER portduino_config.sx128x_max_power @@ -66,7 +70,8 @@ template bool SX128xInterface::init() if (!reinitChip(/*fromInit=*/true)) return false; - startReceive(); // start receiving + applyCadDetPeak(); // init() does not route through reconfigure(), so set it here too + startReceive(); // start receiving return true; } @@ -205,6 +210,8 @@ template bool SX128xInterface::reconfigure() LOG_INFO("SX128x recovered after re-init"); } + applyCadDetPeak(); // depends on the sf/bw just set + startReceive(); // restart receiving return true; @@ -215,6 +222,34 @@ template void SX128xInterface::clearRadioIsr() lora.clearDio1Action(); } +/** Set the CAD peak-to-noise threshold for the current sf/bw. */ +template void SX128xInterface::applyCadDetPeak() +{ + // AN1200.77 Table 1 calibrated PNR thresholds, rows BW 200/400/800/1600 kHz, columns SF5..SF12. + // The note measures these for a 4-symbol window but states they apply to any window duration. + // A new bandwidth or SF outside these needs a row adding; unmatched leaves the chip's own default. + static constexpr uint8_t CAD_DET_PEAK[4][8] = { + {22, 22, 23, 25, 25, 28, 32, 36}, // 203.125 kHz + {20, 21, 22, 23, 25, 25, 29, 32}, // 406.25 kHz + {20, 21, 21, 23, 25, 26, 27, 29}, // 812.5 kHz + {19, 21, 21, 23, 24, 24, 27, 28}, // 1625 kHz + }; + static constexpr float TABLE_BW_KHZ[4] = {203.125f, 406.25f, 812.5f, 1625.0f}; + int bwIdx = -1; + for (int i = 0; i < 4; i++) { + if (bw > TABLE_BW_KHZ[i] - 1.0f && bw < TABLE_BW_KHZ[i] + 1.0f) + bwIdx = i; + } + // Written through our own Module rather than lora.writeRegister(), which RadioLib keeps protected + // unless RADIOLIB_LOW_LEVEL unlocks raw register access across every driver. Same SPI transaction: + // SX128x::writeRegister() is a straight SPIwriteRegisterBurst, and begin() has already pointed + // spiConfig at the 0x18 WriteRegister opcode by the time reconfigure() runs. + if (bwIdx >= 0 && sf >= 5 && sf <= 12) { + const uint8_t detPeak = CAD_DET_PEAK[bwIdx][sf - 5]; + module.SPIwriteRegisterBurst(SX1280_REG_CAD_DET_PEAK, &detPeak, 1); + } +} + template bool SX128xInterface::wideLora() { return true; @@ -347,8 +382,16 @@ template void SX128xInterface::startReceive() /** Is the channel currently active? */ template bool SX128xInterface::isChannelActive() { - // check if we can detect a LoRa preamble on the current channel - ChannelScanConfig_t cfg = {.cad = {.symNum = NUM_SYM_CAD_24GHZ, + // check if we can detect a LoRa preamble on the current channel. + // symNum is the encoded SET_CAD_PARAMS value (symbol count in bits 7:5), not a raw count - pass the + // CAD_ON_n_SYMB constant matching what getCadSymbolCount() reports, or the slot stops matching. + // The PNR threshold is written once per config by applyCadDetPeak(), not here: nothing between the + // scan and the transmit that follows it should cost an extra SPI round trip. + + // detPeak/detMin/exitMode below are ignored: SetCadParams (0x88) carries only cadSymbolNum, and + // RadioLib's setCad() writes just that one byte. CAD always exits to STDBY_RC, so a busy channel + // needs the caller's full re-arm; there is no in-chip CAD->RX handoff on this part. + ChannelScanConfig_t cfg = {.cad = {.symNum = RADIOLIB_SX128X_CAD_ON_8_SYMB, .detPeak = 0, .detMin = 0, .exitMode = 0, diff --git a/src/mesh/SX128xInterface.h b/src/mesh/SX128xInterface.h index 967142c49a..13c222bcf1 100644 --- a/src/mesh/SX128xInterface.h +++ b/src/mesh/SX128xInterface.h @@ -75,6 +75,12 @@ template class SX128xInterface : public RadioLibInterface uint32_t getPacketTime(uint32_t pl, bool received) override { return computePacketTime(lora, pl, received); } + // 2.4 GHz only. isChannelActive() passes CAD_ON_8_SYMB; keep the two in step. + uint8_t getCadSymbolCountWideLora() const override { return 8; } + + /** Set the CAD peak-to-noise threshold for the current sf/bw. Call after either changes. */ + void applyCadDetPeak(); + private: /** Program all modem parameters into the chip; returns the first RadioLib error, or RADIOLIB_ERR_NONE */ int16_t programModemParams(); @@ -88,5 +94,11 @@ template class SX128xInterface : public RadioLibInterface int16_t trySetStandby(); /** Recover a chip that lost its runtime state: hardware-reset via begin() and reprogram */ - bool recoverChipStateLoss() override { return reinitChip() && programModemParams() == RADIOLIB_ERR_NONE; } + bool recoverChipStateLoss() override + { + if (!reinitChip() || programModemParams() != RADIOLIB_ERR_NONE) + return false; + applyCadDetPeak(); // the reset took the CAD threshold with the rest of the chip state + return true; + } }; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 450951214a..7e10a3ed78 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1148,9 +1148,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers) #endif #if !MESHTASTIC_EXCLUDE_GPS - // Enable gps if it was previously disabled due to region not being set - if (!requiresReboot && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && - !gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { + // Enable gps if it was previously disabled due to region not being set. Only then: a probe that + // gave up also leaves it disabled, and re-enabling on every LoRa save re-runs the blocking probe. + if (!requiresReboot && oldLoraConfig.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET && + config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr && !gps->isEnabled() && + config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) { gps->enable(); } #endif diff --git a/src/sleep.cpp b/src/sleep.cpp index 713d539f7a..e2838c36fa 100644 --- a/src/sleep.cpp +++ b/src/sleep.cpp @@ -398,6 +398,56 @@ void doDeepSleep(uint32_t msecToWake, bool skipPreflight = false, bool skipSaveN } #ifdef ARCH_ESP32 +/** + * The GPIO an SX126x/SX128x/LR part raises its IRQ on, or -1 if there is no usable pin. + * + * Nearly every variant spells it LORA_DIO1, but an SX1280 board may define SX128X_DIO1 on a different + * pin (t-eth-elite, tlora_t3s3_v1) or as the only one of the two (tlora_v2_1_18, betafpv_2400_tx_micro), + * because that is the name RadioInterface hands to the driver. Resolve it per radio rather than per + * macro, so arming, polling and teardown below cannot pick different pins. RF95 keeps RF95_IRQ. + */ +static int loraWakeDio1Pin() +{ +#if defined(SX128X_DIO1) && (SX128X_DIO1 != RADIOLIB_NC) + if (radioType == SX1280_RADIO) + return (int)SX128X_DIO1; +#endif +#if defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) + return (int)LORA_DIO1; +#endif + return -1; +} + +/** + * Is the LoRa IRQ line already asserted right now? + * + * DIO1 (SX126x/SX128x) and RF95_IRQ are active-high, so a HIGH level means the radio has already + * latched an interrupt (typically an RX_DONE). If that edge lands in the small window between + * enableLoraInterrupt() and esp_light_sleep_start(), the DIO ISR fires and detachInterrupt() clears + * the per-pin GPIO wake enable as a side effect - the node would then sleep with the packet latched + * but the wake source disarmed, stranding it for the rest of the sleep chunk (up to 30s). Reading the + * raw line just before sleeping lets us skip that sleep and let the main loop service the packet. + * Boards where DIO1 is a virtual/expander pin never reach here - doLightSleep() returns early on + * LORA_DIO1_EXTENDED_IO before this is called. + */ +static bool loraWakeIrqAsserted() +{ +#if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) + if (radioType == RF95_RADIO) + return gpio_get_level((gpio_num_t)RF95_IRQ) == 1; +#endif + const int dio1 = loraWakeDio1Pin(); + if (dio1 >= 0 && radioType != RF95_RADIO) + return gpio_get_level((gpio_num_t)dio1) == 1; + return false; +} + +// Consecutive sleeps skipped because the LoRa IRQ was already asserted. A line stuck high would +// otherwise stop the node sleeping for good, so give up on skipping once it stops looking transient. +static uint8_t loraIrqSkippedSleeps = 0; +#define LORA_IRQ_SKIP_SLEEP_WARN 3 +#define LORA_IRQ_SKIP_SLEEP_MAX 5 + /** * enter light sleep (preserves ram but stops everything about CPU). * @@ -510,7 +560,21 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r assert(res == ESP_OK); console->flush(); - res = esp_light_sleep_start(); + const bool loraIrqPending = loraWakeIrqAsserted() && loraIrqSkippedSleeps < LORA_IRQ_SKIP_SLEEP_MAX; + if (loraIrqPending) { + // A LoRa IRQ is already pending (see loraWakeIrqAsserted). Don't sleep on top of it - return so + // the main loop services the packet now instead of stranding it until the next wake. + if (++loraIrqSkippedSleeps >= LORA_IRQ_SKIP_SLEEP_WARN) + LOG_WARN("LoRa IRQ still pending after %u skipped sleeps", loraIrqSkippedSleeps); + else + LOG_INFO("LoRa IRQ pending, skip light sleep to service packet"); + res = ESP_OK; + } else { + // Either nothing pending, or the line has stayed high past LORA_IRQ_SKIP_SLEEP_MAX and the main + // loop is clearly not going to clear it. Never sleeping costs more than one stranded packet. + loraIrqSkippedSleeps = 0; + res = esp_light_sleep_start(); + } if (res != ESP_OK) { LOG_ERROR("esp_light_sleep_start result %d", res); } @@ -541,9 +605,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r // Unconditional: the config can have changed while we were asleep. gpio_wakeup_disable((gpio_num_t)MOTION_WAKE_INT_PIN); #endif -#if !defined(SOC_PM_SUPPORT_EXT_WAKEUP) && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) - if (radioType != RF95_RADIO) { - gpio_wakeup_disable((gpio_num_t)LORA_DIO1); +#if !defined(SOC_PM_SUPPORT_EXT_WAKEUP) && (defined(LORA_DIO1) || defined(SX128X_DIO1)) + const int wokeDio1 = loraWakeDio1Pin(); // must match what enableLoraInterrupt() armed + if (wokeDio1 >= 0 && radioType != RF95_RADIO) { + gpio_wakeup_disable((gpio_num_t)wokeDio1); } #endif #if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) @@ -552,7 +617,10 @@ esp_sleep_wakeup_cause_t doLightSleep(uint64_t sleepMsec) // FIXME, use a more r } #endif - esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); + // When we skipped the sleep because a LoRa IRQ was already pending, esp_sleep_get_wakeup_cause() + // would report the previous real sleep's cause (e.g. TIMER); synthesize a GPIO cause so the + // handling below (and PowerFSM) takes the packet-service path instead of a stale timer path. + esp_sleep_wakeup_cause_t cause = loraIrqPending ? ESP_SLEEP_WAKEUP_GPIO : esp_sleep_get_wakeup_cause(); notifyLightSleepEnd.notifyObservers(cause); // Button interrupts are reattached here if (cause == ESP_SLEEP_WAKEUP_GPIO) { @@ -612,11 +680,14 @@ void enableLoraInterrupt() { #if defined(LORA_DIO1_EXTENDED_IO) // DIO1 is a virtual pin on an I/O expander - it cannot be a GPIO wakeup source -#elif SOC_PM_SUPPORT_EXT_WAKEUP && defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) +#elif SOC_PM_SUPPORT_EXT_WAKEUP && (defined(LORA_DIO1) || defined(SX128X_DIO1)) + const int loraDio1 = loraWakeDio1Pin(); esp_err_t res; - res = gpio_pulldown_en((gpio_num_t)LORA_DIO1); - if (res != ESP_OK) { - LOG_ERROR("gpio_pulldown_en(LORA_DIO1) result %d", res); + if (loraDio1 >= 0) { + res = gpio_pulldown_en((gpio_num_t)loraDio1); + if (res != ESP_OK) { + LOG_ERROR("gpio_pulldown_en(lora dio1) result %d", res); + } } #if defined(LORA_RESET) && (LORA_RESET != RADIOLIB_NC) res = gpio_pullup_en((gpio_num_t)LORA_RESET); @@ -632,13 +703,16 @@ void enableLoraInterrupt() loraFEMInterface.setRxModeEnableWhenMCUSleep(); #endif - LOG_INFO("Wake on LORA_DIO1 (GPIO%02d) gpio interrupt", LORA_DIO1); - gpio_wakeup_enable((gpio_num_t)LORA_DIO1, GPIO_INTR_HIGH_LEVEL); + if (loraDio1 >= 0) { + LOG_INFO("Wake on LoRa DIO1 (GPIO%02d) gpio interrupt", loraDio1); + gpio_wakeup_enable((gpio_num_t)loraDio1, GPIO_INTR_HIGH_LEVEL); + } -#elif defined(LORA_DIO1) && (LORA_DIO1 != RADIOLIB_NC) - if (radioType != RF95_RADIO) { - LOG_INFO("Wake on LORA_DIO1 (GPIO%02d) gpio interrupt", LORA_DIO1); - gpio_wakeup_enable((gpio_num_t)LORA_DIO1, GPIO_INTR_HIGH_LEVEL); // SX126x/SX128x interrupt, active high +#elif defined(LORA_DIO1) || defined(SX128X_DIO1) + const int loraDio1 = loraWakeDio1Pin(); + if (loraDio1 >= 0 && radioType != RF95_RADIO) { + LOG_INFO("Wake on LoRa DIO1 (GPIO%02d) gpio interrupt", loraDio1); + gpio_wakeup_enable((gpio_num_t)loraDio1, GPIO_INTR_HIGH_LEVEL); // SX126x/SX128x interrupt, active high } #endif #if defined(RF95_IRQ) && (RF95_IRQ != RADIOLIB_NC) diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 543d3f92ae..423c1248e0 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -648,6 +648,41 @@ void tearDown(void) mockMeshService = nullptr; } +// staleRxFlagAction() in src/mesh/RadioInterface.h: the decision behind RadioLibInterface::checkStaleRxFlags(), +// the plain-RX twin of checkCadHandoffTimeout(). Plain RX runs with no chip timeout, 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 an idle node, never. RX_DONE clears every flag in readData(), so a flag still +// latched one max packet after it was first seen has no frame behind it. +// +// Pinned: nothing happens inside that window (a frame may still be arriving); after it, a bare preamble is +// only cleared, because a clear never aborts a reception; a header re-arms, because SX1280 DS rev 3.3 16.2 +// leaves RX wedged after a header error with no RX_DONE or timeout to follow, and a clear does not unwedge it. +// Regressions guarded: re-arming on a preamble (startReceive() goes through standby, which aborts a frame +// that is in fact arriving), acting before the window closes, or never acting at all. + +static void test_staleRxFlagAction_keepsFlagsInsideTheWindow() +{ + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Keep), static_cast(staleRxFlagAction(false, 0, 99))); + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Keep), static_cast(staleRxFlagAction(true, 98, 99))); +} + +static void test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket() +{ + // Inclusive at the boundary, matching Throttle::hasElapsed(). + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::ClearPreamble), static_cast(staleRxFlagAction(false, 99, 99))); + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Rearm), static_cast(staleRxFlagAction(true, 99, 99))); +} + +static void test_staleRxFlagAction_barePreambleIsOnlyCleared() +{ + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::ClearPreamble), static_cast(staleRxFlagAction(false, 2200, 2115))); +} + +static void test_staleRxFlagAction_staleHeaderIsRearmed() +{ + TEST_ASSERT_EQUAL(static_cast(StaleRxFlagAction::Rearm), static_cast(staleRxFlagAction(true, 60000, 2115))); +} + void setup() { delay(10); @@ -685,6 +720,10 @@ void setup() RUN_TEST(test_computePacketTime_reportsNoAirtimeWhenNothingCanBeComputed); RUN_TEST(test_computePacketTime_rxUsesHeaderInfoAndIsGuarded); RUN_TEST(test_isRadioLibTimeError_separatesCodesFromDurations); + RUN_TEST(test_staleRxFlagAction_keepsFlagsInsideTheWindow); + RUN_TEST(test_staleRxFlagAction_windowEndsAtExactlyOneMaxPacket); + RUN_TEST(test_staleRxFlagAction_barePreambleIsOnlyCleared); + RUN_TEST(test_staleRxFlagAction_staleHeaderIsRearmed); exit(UNITY_END()); } diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini index 6ac17146a4..fd2f7a70f1 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/platformio.ini @@ -43,6 +43,20 @@ extra_scripts = variants/nrf52840/diy/nrf52_promicro_diy_tcxo/custom_build_tasks.py ; Add to PIO's Project Tasks pane: preset builds for common displays +; NRF52 ProMicro w/ 2.4 GHz SX1280 module (e.g. Ebyte E28) - TXEN wired to P1.06 +[env:nrf52_promicro_diy-sx1280] +board_level = extra +extends = nrf52840_base +board = promicro-nrf52840 +build_flags = + ${nrf52840_base.build_flags} + -I variants/nrf52840/diy/nrf52_promicro_diy_tcxo + -D NRF52_PROMICRO_DIY + -D PROMICRO_SX1280 ; 2.4 GHz module fitted; claims P1.06 as TXEN + -D EXCLUDE_EMOJI ; as the base env - one more radio driver family leaves even less room for emote bitmaps +build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/diy/nrf52_promicro_diy_tcxo> +debug_tool = jlink + ; NRF52 ProMicro w/ EasyProMicro pinout [env:nrf52_promicro_diy-easypromicro] board_level = extra diff --git a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h index 5a6f0074e5..cb16a13500 100644 --- a/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h +++ b/variants/nrf52840/diy/nrf52_promicro_diy_tcxo/variant.h @@ -36,7 +36,7 @@ NRF52 PRO MICRO PIN ASSIGNMENT | P1.00 | BUTTON_PIN |   | P1.13   | CS           | CS   | | P0.11 | SCL         |   | P1.11   | SCK         | SCK | | P1.04 | SDA         |   | P0.10   | DIO1/IRQ     | DIO1 | -| P1.06 | Free pin   |   | P0.09   | RESET       | RST | +| P1.06 | TXEN (opt) | | P0.09 | RESET | RST | |       |             |   |         |             | | |       | Mid board   |   |         | Internal     | | | P1.01 | Free pin   |   | 0.15     | LED         | | @@ -181,15 +181,30 @@ https://github.com/brad112358/easy_E22 #define RF95_RXEN (0 + 17) // P0.17 #define RF95_TXEN RADIOLIB_NC // Assuming that DIO2 is connected to TXEN pin. If not, TXEN must be connected. +// P1.06 is the only free pin left for a TXEN line, but it is also the e-ink panel's BUSY line. +// EasyProMicro, where it is SCL, takes the #else branch below and has its own TXEN on P1.00. +#if !defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS) && !defined(USE_EINK) +#define PROMICRO_P106_FREE_FOR_TXEN +#endif + +#if defined(PROMICRO_SX126X_TXEN) && defined(PROMICRO_SX1280) +#error "PROMICRO_SX126X_TXEN and PROMICRO_SX1280 both claim P1.06 as TXEN - pick one" +#endif + // SX126X CONFIG -#define SX126X_CS (32 + 13) // P1.13 FIXME - we really should define LORA_CS instead +#define SX126X_CS LORA_CS #define SX126X_DIO1 (0 + 10) // P0.10 IRQ #define SX126X_DIO2_AS_RF_SWITCH // Note for E22 modules: DIO2 is not attached internally to TXEN for automatic TX/RX switching, // so it needs connecting externally if it is used in this way #define SX126X_BUSY (0 + 29) // P0.29 #define SX126X_RESET (0 + 9) // P0.09 #define SX126X_RXEN (0 + 17) // P0.17 -#define SX126X_TXEN RADIOLIB_NC // Assuming that DIO2 is connected to TXEN pin. If not, TXEN must be connected. +// Opt in with -D PROMICRO_SX126X_TXEN when P1.06 is wired to an external PA's TXEN. +#if defined(PROMICRO_SX126X_TXEN) && defined(PROMICRO_P106_FREE_FOR_TXEN) +#define SX126X_TXEN (32 + 6) // P1.06 +#else +#define SX126X_TXEN RADIOLIB_NC // Assuming that DIO2 is connected to TXEN pin. If not, TXEN must be connected. +#endif // LR1121 #ifdef USE_LR1121 @@ -215,6 +230,19 @@ https://github.com/brad112358/easy_E22 #define LR2021_IRQ_DIO_NUM 9 // DIO9 → P0.10 #endif +// SX128X CONFIG - 2.4 GHz only, so off by default: it is a different module on the same footprint, +// and an E28 needs P1.06 for TXEN. Opt in with -D PROMICRO_SX1280 (env nrf52_promicro_diy-sx1280). +#if defined(PROMICRO_SX1280) && defined(PROMICRO_P106_FREE_FOR_TXEN) +#define USE_SX1280 +#define SX128X_CS LORA_CS // P1.13 +#define SX128X_DIO1 (0 + 10) // P0.10 IRQ +#define SX128X_BUSY (0 + 29) // P0.29 +#define SX128X_RESET (0 + 9) // P0.09 +#define SX128X_RXEN (0 + 17) // P0.17 +#define SX128X_TXEN (32 + 6) // P1.06 +// #define SX128X_MAX_POWER 3 // set this if the module has an external PA, e.g. E28-2G4M20S +#endif + #else // Easy E22 Promicro arrangement #define USE_SSD1306 // #define USE_SH1106