From ba33cebf6afd14ee94c407f94153fac5b1eeb52c Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:12:47 +0200 Subject: [PATCH] cad improvements --- docs/ADAPTIVE_CAD.md | 6 +- docs/Repeater_CLI_commands.md | 2 +- .../adapters/datastore/ZephyrDataStore.cpp | 15 +- zephcore/adapters/radio/LR1110Radio.cpp | 15 + zephcore/adapters/radio/LR1110Radio.h | 3 + zephcore/adapters/radio/LR2021Radio.cpp | 5 + zephcore/adapters/radio/LR2021Radio.h | 1 + zephcore/adapters/radio/LoRaRadioBase.cpp | 345 ++++++++--- zephcore/adapters/radio/LoRaRadioBase.h | 63 +- zephcore/adapters/radio/SX126xRadio.cpp | 10 + zephcore/adapters/radio/SX126xRadio.h | 2 + zephcore/adapters/radio/radio_common.h | 31 +- zephcore/app/RepeaterDataStore.cpp | 8 + zephcore/app/RepeaterMesh.h | 5 +- zephcore/app/RoomServerMesh.h | 5 +- zephcore/helpers/CommonCLI.cpp | 6 + zephcore/helpers/NodePrefs.h | 24 +- zephcore/include/mesh/Radio.h | 15 +- .../drivers/lora/lr11xx/lr11xx_lora.c | 537 ++++++++++++++++-- .../drivers/lora/lr11xx/lr11xx_lora.h | 28 + .../drivers/lora/lr20xx/lr20xx_lora.c | 278 +++++++-- .../drivers/lora/lr20xx/lr20xx_lora.h | 11 + .../drivers/lora/native/sx126x/sx126x_ext.h | 37 +- .../zephyr/0003-lora-sx126x-native.patch | 355 +++++++++--- zephcore/src/main_companion.cpp | 18 +- zephcore/src/main_repeater.cpp | 13 +- zephcore/src/main_room_server.cpp | 13 +- 27 files changed, 1559 insertions(+), 292 deletions(-) diff --git a/docs/ADAPTIVE_CAD.md b/docs/ADAPTIVE_CAD.md index a732eec..78a6dc1 100644 --- a/docs/ADAPTIVE_CAD.md +++ b/docs/ADAPTIVE_CAD.md @@ -92,7 +92,7 @@ says "keep raising detPeak until fewer than *N*% of quiet moments trip on faint signals." A **lower** cap rejects faint traffic more aggressively (ignore echoes and stragglers); a higher cap tolerates more of it. On a saturated hilltop that should react only to strong contention, set the cap -low (e.g. `10`); the default `25` is a moderate setting suited to mixed +low (e.g. `10`); the default `15` is a moderate setting suited to mixed and leaf nodes. This is the same mechanism as "airtime protection" below — two names for one lever, because the airtime a busy node wastes *is* the airtime spent deferring for faint traffic. @@ -167,7 +167,7 @@ neighbour a quarter) so the staircase can read the local curve *slope*: - **Airtime protection** (highest priority): step up when the operating level's *total* busy rate — real traffic plus false positives — exceeds - the busy cap (`set cad.busycap`, percent; default **25%**, 0 = off). The + the busy cap (`set cad.busycap`, percent; default **15%**, 0 = off). The knee logic only minimises *false* busy, but on a congested hilltop most busy verdicts are real distant traffic we'd win on capture anyway; deferring for all of it starves the node's own airtime. Self-targeting — @@ -323,7 +323,7 @@ a knee to resolve clearly, longer to capture day/night variation. | `set cad.auto ` | **on** | Staircase controller acts on the stats. Off = observe/hand-tune. | | `set cad.offset ` | 0 | Operating offset, −8…12. Negative = more sensitive. Applied live. | | `set probe.interval ` | 15 | Shared cadence for the noise-floor sample and the CAD probe that consumes it; 0 disables probing (and freezes auto), 10–255 otherwise. | -| `set cad.busycap ` | 25 | Faint-tolerance / airtime cap: raise detPeak once more than this % of (quiet-moment) probes trip on faint signals. Lower = reject faint/echo harder (busy backbones); 0 = off. 10–90 otherwise. | +| `set cad.busycap ` | 15 | Faint-tolerance / airtime cap: raise detPeak once more than this % of (quiet-moment) probes trip on faint signals. Lower = reject faint/echo harder (busy backbones); 0 = off. 10–90 otherwise. | | `set cad.reset` | | Clear accumulated statistics (RAM only). | All settings persist in prefs and apply to every role — repeater, room diff --git a/docs/Repeater_CLI_commands.md b/docs/Repeater_CLI_commands.md index f876349..fe8d2bb 100644 --- a/docs/Repeater_CLI_commands.md +++ b/docs/Repeater_CLI_commands.md @@ -349,7 +349,7 @@ four radio parameters together, since they are one interop-critical set. | `set cad.auto ` | default **on** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. On by default (repeaters and companions); at the default 15 s probe interval it responds to environment change in ~1–2 h. Turn off to observe/hand-tune via `get cad.stats` + `set cad.offset`. See `ADAPTIVE_CAD.md`. | | `set cad.offset ` | −8 to 12, default 0 | Operating detPeak offset from the chip family's base for the current SF, bandwidth and CAD symbol count (Semtech LoRa Basics Modem reference tables; SX126x ~18–34, LR11xx ~50–85, LR20xx its own symbol-indexed table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Wide range so dense hilltops / quiet valleys can settle far from base. The per-family absolute clamp in the driver (SX126x 12–48, LR11xx 40–100, LR20xx 48–90) is a firmware guardrail against a CAD that never/always fires, not a chip limit (`cadDetPeak` is a full `uint8_t`); the driver reports it so the controller narrows this range to match rather than exploring offsets that collapse onto one peak. Applied live; the auto staircase may move it later if `cad.auto` is on. | | `set probe.interval ` | 0 (off) or 10–255, default **15** | Seconds between periodic radio measurements. ONE reading serves both: the noise-floor RSSI sample (median of 8) and the CAD calibration probe, which consumes that same reading rather than measuring separately — so this is also the noise-floor sampling rate, and it sets how often an idle repeater wakes. Default 15 s → ~1–2 h CAD staircase response; the floor EMA warms up over 8 samples (~2 min) and its unguarded bypass runs every 16th (~4 min). Longer = fewer wakes, slower to track a changing RF environment. 0 disables CAD probing entirely (also freezes auto adaptation); the floor sampler then falls back to its build-time default. | -| `set cad.busycap ` | 0 (off) or 10–90, default **25** | Airtime-protection cap: the max percentage of TX attempts the node will let CAD defer before the staircase backs off to a less sensitive detPeak — counting **real** traffic, not just false positives. On a congested hilltop most busy verdicts are distant traffic won on capture anyway, so deferring for all of it starves the node's own airtime. Self-targeting: a quiet node's busy rate never reaches the cap. Shown as `bc:` in `get cad.stats`. 0 disables the cap (pure knee-seeking). | +| `set cad.busycap ` | 0 (off) or 10–90, default **15** | Faint-tolerance / airtime cap: the max percentage of **quiet-moment probes** that may trip on a faint signal before the staircase backs off to a less sensitive detPeak. Not a percentage of TX attempts — probes are prefiltered to quiet moments, so strong traffic never enters the statistic. On a congested hilltop most busy verdicts are distant traffic won on capture anyway, so deferring for all of it starves the node's own airtime. Self-targeting: a quiet node's busy rate never reaches the cap. Shown as `bc:` in `get cad.stats`. 0 disables the cap (pure knee-seeking). | | `set cad.reset` | | Full CAD reset: clears the accumulated per-level probe statistics **and** returns the operating detPeak offset to the family base (`cad.offset` = 0), applying it to the radio live and persisting it. Use after a change to the base tables, or to undo a staircase that has walked somewhere unhelpful. Before 1.17.4 this cleared only the statistics, leaving the node to re-converge *from* the walked offset with no evidence for why it was there. | | `set extra.sf [sf] [sf]` | up to 3 SFs, `0`/`off` clears | **LR2021 only** (`Error: unsupported` elsewhere) — LoRa *side detectors*: demodulate up to three extra spreading factors concurrently with `sf`, on the same bandwidth, so one repeater can serve several SF communities. Which SF a packet arrived on is a chip-side readout, not a guess. Chip constraints, enforced in the driver and reported as `Error: unsupported or invalid extra SF config`: every extra SF must be **greater** than `sf`, all distinct, highest−lowest ≤ 4, and at BW ≥ 500 kHz at most 2 (only 1 when `sf` ≥ 10). **Receive only, and the bridge it creates is one-way.** TX always uses the single configured `sf`, and all detectors share one bandwidth, so this is multi-SF, not multi-channel. A node with `sf 7` + `extra.sf 8` hears SF8 traffic and *does* forward it — but the forward goes out at SF7, so traffic moves SF8 -> SF7 only and nothing comes back. An SF8 node's direct messages are delivered while its ACKs never arrive, so it retries to its limit every time; adverts and one-way flood traffic propagate fine. Because every extra SF must be **greater** than `sf`, the main SF is always the lowest in the set and TX always uses it — so the bridge direction is fixed at high-SF-in / low-SF-out and **cannot be reversed**. Two nodes back to back both point the same way; there is no configuration that carries SF7 -> SF8. Treat it as a collector for slower-SF stragglers, not as a link between two SF islands. Applied live and restored on every RX entry. **Interaction with CAD:** the chip's SF constraint for CAD is the inverse of the one for RX, so the driver switches side detectors off for each LBT CAD and back on when RX re-arms — two extra SPI commands per TX, no configuration required. Persisted; a set that no longer fits after an `sf`/`bw` change is refused at boot and logged. | | `set prv.key ` | **128-char hex** (64-byte expanded Ed25519 key) | Replace private key; derive new identity *(reboot to apply)*. The length must be exact — `fromHex` rejects anything else with `Error, bad key`. `get prv.key` returns the same 128-char form. Not USB-gated. | diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 50b8164..350ab22 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -834,6 +834,16 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) prefs.input_rotate = buf[off++]; } + /* Offset 170: cad_base (ZephCore extension). Absent in pre-existing + * files → stays 0, which setCadParams() reads as "no base recorded" and + * leaves the stored offset exactly where it is. That is the right + * migration: a node upgrading across the table change has no way to know + * which base its offset was learned against, so re-anchoring it would be + * guessing, and the staircase re-converges on its own either way. */ + if (off < len) { + prefs.cad_base = buf[off++]; + } + sanitizeNodePrefs(&prefs); } @@ -939,7 +949,10 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 168-169: display_rotate / input_rotate (ZephCore extension) */ buf[off++] = prefs.display_rotate; buf[off++] = prefs.input_rotate; - /* Total: 170 bytes */ + /* Offset 170: cad_base (ZephCore extension, family base detPeak the + * cad_offset above was learned against) */ + buf[off++] = prefs.cad_base; + /* Total: 171 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index 1fab63c..52a5628 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -69,6 +69,11 @@ int16_t LR1110Radio::hwGetCurrentRSSI() return lr11xx_get_rssi_inst(_dev); } +int LR1110Radio::hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us) +{ + return lr11xx_get_rssi_burst(_dev, out, n, spacing_us); +} + bool LR1110Radio::hwIsReceiving() { /* MUST be non-destructive: never clear IRQ bits from this path. @@ -113,6 +118,16 @@ int LR1110Radio::hwCadProbe(int8_t level) return lr11xx_cad_probe(_dev, level); } +int LR1110Radio::hwCadRxOutcome() +{ + return lr11xx_cad_rx_outcome(_dev); +} + +uint32_t LR1110Radio::hwCadRxTimeoutMs() +{ + return lr11xx_cad_rx_timeout_ms(_dev); +} + void LR1110Radio::hwCadSetPeakOffset(int8_t offset) { lr11xx_cad_set_peak_offset(_dev, offset); diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 4a8ffb4..88d5401 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -32,12 +32,15 @@ protected: int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) override; int16_t hwGetCurrentRSSI() override; + int hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us) override; bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; void hwRecalibrate() override; bool hwHasDriftRecal() override; uint32_t hwWakeupTimeUs() override; int hwCadProbe(int8_t level) override; + int hwCadRxOutcome() override; + uint32_t hwCadRxTimeoutMs() override; void hwCadSetPeakOffset(int8_t offset) override; uint8_t hwCadBasePeak() override; uint8_t hwCadPeakMin() override; diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index deb93d3..fe72103 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -108,6 +108,11 @@ int16_t LR2021Radio::hwGetCurrentRSSI() return lr20xx_get_rssi_inst(_dev); } +int LR2021Radio::hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us) +{ + return lr20xx_get_rssi_burst(_dev, out, n, spacing_us); +} + /* Deliberately no hwIsChipBusy() override, matching the LR11xx. * * It backs LoRaRadioBase::isRadioReady(), which gates TX in startSendRaw() as diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index a0703ab..27cc968 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -44,6 +44,7 @@ protected: int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) override; int16_t hwGetCurrentRSSI() override; + int hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us) override; bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; int hwCadProbe(int8_t level) override; diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 6844cbe..66465fd 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -54,7 +54,8 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _rx_entry_cyc(0), _rssi_bursts(0), _rssi_spread_sum(0), _rssi_degenerate(0), _cad_auto(false), _cad_offset(0), _probe_interval_s(0), - _cad_busycap_pct(0), + _cad_busycap_pct(0), _cad_pending_level(INT8_MIN), + _cad_pending_deadline_ms(0), _cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_probe_rr(0), _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), @@ -63,6 +64,7 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _agc_rx_count_shadow(0), _agc_last_activity_ms(0), _agc_rssi_last(0), _agc_rssi_frozen(0), _rssi_reads_ok(0), _rssi_reads_busy(0), _rssi_bursts_abandoned(0), + _rssi_dc_blocked(0), _silence_last_report_ms(0), _image_cal_last_temp_c(INT16_MIN), _image_cal_last_ms(0), _image_cal_wait_ms(0), @@ -842,6 +844,20 @@ float LoRaRadioBase::getLastSNR() const return _last_snr; } +int LoRaRadioBase::hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us) +{ + for (int i = 0; i < n; i++) { + if (i) { + k_busy_wait(spacing_us); + } + out[i] = hwGetCurrentRSSI(); + if (out[i] == -128) { + return i; /* refused partway; caller abandons */ + } + } + return n; +} + bool LoRaRadioBase::isRadioReady() { /* BUSY high means the radio cannot accept SPI commands now @@ -938,8 +954,17 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) } /* Skip when the radio cannot accept commands right now - * (e.g. duty-cycle sleep BUSY window). */ + * (e.g. duty-cycle sleep BUSY window). + * + * Counted, because this refusal was invisible and a whole line of + * reasoning was built on the wrong counter: the r/b/a figures only move + * when a read fails from INSIDE an already-started burst, so they say + * nothing about how often the sampler is turned away here. Whether the + * noise floor actually goes stale under duty cycle — and therefore + * whether the CAD prefilter admits probes at moments that are not quiet — + * cannot be answered without this number. */ if (!isRadioReady()) { + _rssi_dc_blocked++; return; } @@ -994,23 +1019,37 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) * whole burst. */ uint32_t window_us = rssi_avg_window_us(bw_khz); int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; - for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { - if (i) { - k_busy_wait(window_us); - } - samples[i] = hwGetCurrentRSSI(); - if (samples[i] == -128) { - /* Chip busy or RSSI read contended — keep the short - * retry deadline set above and try again shortly. - * - * Counted, not yet tolerated: whether to accept a partial - * burst depends on how these reads fail, which is exactly - * what the counters are here to establish. */ - _rssi_reads_busy++; - _rssi_bursts_abandoned++; - return; - } - _rssi_reads_ok++; + int got = hwGetRssiBurst(samples, NOISE_FLOOR_SAMPLES_PER_TICK, window_us); + + if (got < 0) { + /* A preamble or header landed inside the window, so these + * samples measure that signal and not the floor. Not a sampler + * fault: charging it to the read counters would report a busy + * channel as a failing bus, which is the distinction those + * counters exist to draw. Only the abandoned count moves, and + * the short retry deadline set above stands. + * + * Leaving _sample_fresh clear is load-bearing beyond the floor. + * On the LR families the CAD probe's own isReceiving() re-check + * below cannot see this — with a duty cycle armed it answers + * from a latch the sampler's re-arm has just zeroed — so this is + * what actually keeps a calibration CAD off the air while a + * neighbour is transmitting. */ + _rssi_bursts_abandoned++; + return; + } + + _rssi_reads_ok += (uint32_t)got; + if (got < NOISE_FLOOR_SAMPLES_PER_TICK) { + /* Chip busy or RSSI read contended — keep the short retry + * deadline set above and try again shortly. + * + * Counted, not yet tolerated: whether to accept a partial burst + * depends on how these reads fail, which is exactly what the + * counters are here to establish. */ + _rssi_reads_busy++; + _rssi_bursts_abandoned++; + return; } /* A full sample landed: next one is a full interval away. */ @@ -1053,6 +1092,14 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) _rssi_bursts >>= 1; _rssi_spread_sum >>= 1; _rssi_degenerate >>= 1; + /* The read counters are printed alongside the burst count as + * (N rX/bY/aZ), so a reader computes ratios across the two sets. + * Halving only the burst side made those ratios wrong by 2x + * after the first rescale and 4x after the next. */ + _rssi_reads_ok >>= 1; + _rssi_reads_busy >>= 1; + _rssi_bursts_abandoned >>= 1; + _rssi_dc_blocked >>= 1; } /* Publish this sample for cadMaintenance(). The CAD probe needs exactly @@ -1275,13 +1322,14 @@ void LoRaRadioBase::radioMaintenance() (_silence_last_report_ms == 0 || (now - _silence_last_report_ms) >= SILENCE_REPORT_MS)) { _silence_last_report_ms = now ? now : 1; - LOG_INF("silence: %u s no RX | in_rx=%d dc=%d rssi_ok=%u rssi_busy=%u abandoned=%u floor=%d", + LOG_INF("silence: %u s no RX | in_rx=%d dc=%d rssi_ok=%u rssi_busy=%u abandoned=%u dc_blocked=%u floor=%d", (unsigned)(silent_ms / 1000U), (int)atomic_get(&_in_recv_mode), (int)_rx_duty_cycle_enabled, (unsigned)_rssi_reads_ok, (unsigned)_rssi_reads_busy, (unsigned)_rssi_bursts_abandoned, + (unsigned)_rssi_dc_blocked, (int)_noise_floor); } } @@ -1484,10 +1532,43 @@ int8_t LoRaRadioBase::cadLevelMaxEff() } void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset, - uint16_t probe_interval_s, uint8_t busycap_pct) + uint16_t probe_interval_s, uint8_t busycap_pct, + uint8_t stored_base) { const int8_t lo = cadLevelMinEff(); const int8_t hi = cadLevelMaxEff(); + const uint8_t base = hwCadBasePeak(); + + /* Re-anchor across a base-table change. + * + * cad_offset is persisted; the per-level probe statistics that justified + * it are not (RAM only, cleared by reconfigure() and lost at every + * reboot). So when a firmware update moves the family base table, a + * converged node wakes up with an offset that names a different absolute + * detPeak than the one it spent days measuring. + * + * Preserve the PEAK, not the offset: the peak is the physical quantity + * the node actually measured, and the offset is only how we address it. + * A node at base 51 / offset -7 lands on base 44 / offset 0 — the same + * detPeak 44, now centred in its window instead of one rung off the rail. + * + * Resetting to 0 instead would throw away real convergence for no reason, + * and is only the right answer when the preserved peak falls outside the + * window the new base can reach — which the clamp below handles, because + * a peak that is no longer addressable is not a peak we can operate at. + * + * stored_base == 0 means "never recorded" (a node upgrading from a build + * without the field), and is deliberately a no-op: with no record of + * which base the offset came from, any adjustment would be a guess. */ + if (stored_base != 0 && base != 0 && stored_base != base) { + int adj = (int)offset + (int)stored_base - (int)base; + + LOG_INF("cad: base %u -> %u, re-anchoring offset %d -> %d " + "(peak %d held)", + (unsigned)stored_base, (unsigned)base, + (int)offset, adj, (int)stored_base + (int)offset); + offset = (int8_t)(adj < -128 ? -128 : (adj > 127 ? 127 : adj)); + } if (offset < lo) offset = lo; if (offset > hi) offset = hi; @@ -1511,9 +1592,14 @@ void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset, hwCadSetPeakOffset(_cad_offset); - LOG_INF("cad: auto=%d offset=%d measure_interval=%ums busycap=%u%%", - (int)auto_enabled, (int)offset, (unsigned)_measure_interval_ms, - (unsigned)busycap_pct); + LOG_INF("cad: auto=%d offset=%d base=%u measure_interval=%ums busycap=%u%%", + (int)auto_enabled, (int)offset, (unsigned)base, + (unsigned)_measure_interval_ms, (unsigned)busycap_pct); +} + +uint8_t LoRaRadioBase::cadBasePeak() +{ + return hwCadBasePeak(); } void LoRaRadioBase::resetCadStats() @@ -1631,7 +1717,8 @@ void LoRaRadioBase::cadStaircaseStep() * airtime cap and bouncing straight back up. */ int b_dn = busy_rate(oi - 1); bool busy_ok = (cap_permille == 0) || - (b_dn <= cap_permille - CAD_BUSY_DEFER_HYST_PERMILLE); + (b_dn <= cap_permille - + (cap_permille * CAD_BUSY_DEFER_HYST_PCT) / 100); if (_cad_offset > cadLevelMinEff() && r_dn >= 0 && r_dn - r_op < CAD_KNEE_SLOPE_PERMILLE && r_op <= CAD_PLATEAU_CLEAN_PERMILLE && busy_ok) { @@ -1662,6 +1749,47 @@ void LoRaRadioBase::cadMaintenance() _cad_last_decay_ms = now; } + /* A CAD_RX probe from an earlier pass, now resolvable. + * + * This replaces the four-poll confirmation window that used to run + * inline after every busy probe. That window existed only because the + * old CAD_ONLY exit threw away the reception which triggered the + * detection, leaving nothing to wait for and no choice but to guess from + * a poll -- and it guessed badly, confirming about a quarter of busies on + * the best node and one in forty-five on a duty-cycled LR1110. + * + * With CAD_RX the chip keeps that reception and always resolves it with + * a terminal interrupt: a packet, or its own cadTimeout. So there is a + * real event to observe, the answer is read once after the chip's own + * deadline, and it costs no chip access at all. */ + if (_cad_pending_level != INT8_MIN && now >= _cad_pending_deadline_ms) { + CadLevelStats &ps = _cad_stats[_cad_pending_level - CAD_LEVEL_MIN]; + int outcome = hwCadRxOutcome(); + + if (outcome == 1) { + ps.tp++; + } else if (outcome == 2) { + ps.fp++; + } else { + /* Past the chip's own deadline with neither terminal + * interrupt seen. Nothing to infer from that, and + * inventing a verdict would poison the very curve the + * staircase reads -- so un-count the sample entirely + * rather than book it as either. Dropping the busy with + * it keeps busy_rate and fp_rate describing the same + * population. */ + if (ps.probes) ps.probes--; + if (ps.busy) ps.busy--; + LOG_WRN("cad: probe at %+d unresolved past deadline, discarded", + (int)_cad_pending_level); + } + _cad_pending_level = INT8_MIN; + + if (_cad_auto) { + cadStaircaseStep(); + } + } + /* No separate probe-interval check: the probe interval IS the measurement * interval (setCadParams derives _measure_interval_ms from it), so a * fresh sample means a probe is due by construction. */ @@ -1685,21 +1813,72 @@ void LoRaRadioBase::cadMaintenance() } _sample_fresh = false; + /* One probe in flight at a time. A second CAD while the first is still + * in its CAD_RX window would abort that Rx -- destroying the reception + * being measured -- and there is only one pending slot to book it to. */ + if (_cad_pending_level != INT8_MIN) { + return; + } + + /* No CAD_RX ground truth on this radio means no probing at all. + * + * The staircase reads a FALSE-positive rate, so a probe whose outcome + * can never be established is not a weaker sample, it is a poisoned one: + * every busy verdict would be booked as neither fp nor tp, fp_rate would + * read zero at every level, the curve would look perfectly clean, and + * the controller would walk to the most sensitive rail on a channel it + * has learned nothing about. Silently collecting unusable data is worse + * than collecting none. + * + * The one radio this currently excludes is the LR2021. Its cad_timeout + * is 24 bits of 32 MHz periods (DS 6.3.11), i.e. 524 ms, while a + * max-length packet at the default SF7/BW62.5 preset runs 1704 ms -- and + * the datasheet is explicit that the chip "stays in Rx until a packet is + * demodulated or the timer reaches the timeout", so that bound truncates + * receptions rather than merely ending a wait. Enabling CAD_RX there + * would trade a measurement for lost packets, which is the wrong way + * round. `set cad.offset` still works by hand. + * + * (The SX126x's ceiling is 262 s at 15.625 us steps and the LR11xx's is + * 512 s at 32768 Hz RTC steps, so neither comes close to binding.) */ + if (hwCadRxTimeoutMs() == 0) { + return; + } + if (!_sample_channel_quiet) { return; } + /* Re-check immediately before the CAD. The sampler's own isReceiving() + * guard ran BEFORE its 8-read burst, so by the time we get here it is + * about a millisecond stale — long enough for a packet to have started. + * Calibration must never cost a reception, and abandoning the probe is + * free: the next interval is 15 s away and nothing depends on this one. */ + if (isReceiving()) { + return; + } + _cad_last_probe_ms = now; int8_t level = pickCadProbeLevel(); int ret = hwCadProbe(level); + /* hwCadProbe() BLOCKS for the whole CAD, so `now` is already stale here + * and must not be used as the base of the CAD_RX deadline below. */ + int64_t probe_done_ms = k_uptime_get(); - /* The probe leaves the chip in STANDBY (driver state REST) — re-arm - * RX immediately so an incoming packet isn't lost while we classify. - * In duty-cycle mode this re-enters the DC cycle (same path as the - * parked-RX watchdog re-arm). */ - atomic_set(&_in_recv_mode, 0); - startReceive(); + /* Where the chip is now depends on the verdict, and the caller must not + * guess: + * free (0) -> standby; re-enter RX exactly as before. + * busy (2) -> the chip is ALREADY in RX, locked on the signal CAD + * found, and re-entering would tear down the reception + * this probe exists to observe. Leave it alone. + * busy (1) -> a radio still on the CAD_ONLY exit (no CAD_RX support): + * standby, re-enter RX, and no ground truth is available. + * error -> standby; re-enter RX and give up on this pass. */ + if (ret != 2) { + atomic_set(&_in_recv_mode, 0); + startReceive(); + } if (ret < 0) { if (ret != -ENOSYS) { @@ -1717,53 +1896,31 @@ void LoRaRadioBase::cadMaintenance() if (ret > 0) { s.busy++; + } - /* Ground-truth post-check: was the CAD hit a REAL signal or a - * correlator false positive? A real transmitter that tripped - * CAD keeps radiating, so over the next preamble+header window - * one of two things shows up: - * (a) the restarted RX syncs on it -> isReceiving(), or - * (b) instantaneous RSSI climbs above the noise floor. - * (b) is the important addition: the probe tears RX down to run - * CAD, and the STANDBY->RX restart routinely eats the preamble of - * a real packet, so RX never re-syncs — the old isReceiving()-only - * snapshot booked those strong-but-missed packets as false - * positives, a ~detPeak-independent floor that flattened the FP - * curve and drove the staircase to the ceiling. Channel energy - * doesn't depend on winning the preamble race, so it recovers - * them. Neither signal over the whole window => genuine FP. A - * below-floor packet we can neither sync nor see stays ambiguous - * and counts as FP — bias toward higher detPeak (the safe side). - * The prefilter above guaranteed RSSI <= floor+guard pre-probe, - * so a rise past that threshold now is a newly-arrived signal. */ - uint8_t sf = getActiveSpreadingFactor(); - uint16_t bw_x10 = getActiveBandwidthKHzX10(); - uint32_t tsym_us = bw_x10 ? (uint32_t)(((1UL << sf) * 10000UL) - / bw_x10) : 1024; - uint32_t step_ms = (3U * tsym_us) / 1000U; /* ~3 symbols/sample */ - - if (step_ms < 5) step_ms = 5; - if (step_ms > 100) step_ms = 100; - - bool floor_valid = (_noise_floor != DEFAULT_NOISE_FLOOR); - int16_t rssi_thresh = _noise_floor + CAD_PROBE_RSSI_GUARD; - - bool real = false; - for (int k = 0; k < 4 && !real; k++) { /* ~12 symbols total */ - k_sleep(K_MSEC(step_ms)); - if (isReceiving()) { - real = true; - } else if (floor_valid && - hwGetCurrentRSSI() > rssi_thresh) { - real = true; - } - } - - if (real) { - s.tp++; - } else { - s.fp++; - } + if (ret == 2) { + /* Book the rung and come back once the chip's own cadTimeout has + * passed, when a terminal interrupt is guaranteed to have landed. + * The extra 100 ms is interrupt-to-work-queue latency, not a + * safety margin against the chip: the deadline itself is the + * chip's. + * + * Measured from AFTER the probe, because the chip's cadTimeout + * starts when CAD_DONE hands it into Rx — which is exactly when + * the blocking hwCadProbe() returns. Based on `now` instead, it + * was short by the whole CAD duration, and a 4-symbol CAD is + * ~131 ms at SF11/BW62.5 and ~262 ms at SF12 (the figure + * sx126x_cad_timeout_ms() sizes its own wait from), i.e. more + * than the margin. The outcome was then read before the chip + * could raise its timeout, so every FALSE positive was discarded + * while true positives — which land early, on a packet — still + * counted: fp_rate read ~0 at every rung and the staircase + * descended to the sensitive rail on a curve it had not + * measured. */ + _cad_pending_level = level; + _cad_pending_deadline_ms = + probe_done_ms + (int64_t)hwCadRxTimeoutMs() + 100; + return; } if (_cad_auto) { @@ -1808,7 +1965,17 @@ uint32_t LoRaRadioBase::msUntilNextMaintenance() * off the noise-floor sampler's measurement (see cadMaintenance), so its * wake is already accounted for above. Giving it a second deadline is * what produced two independent 15 s grids ~3 s apart — one extra wake - * per interval, forever, on every repeater. */ + * per interval, forever, on every repeater. + * + * A CAD_RX probe awaiting its terminal event is the one exception, and it + * is not a second grid: it is one wake, only while a probe is actually + * pending, at the moment the chip has guaranteed an answer exists. It + * would otherwise wait for the next sampler tick, leaving the node in the + * plain RX that CAD_RX entered instead of handing the duty cycle back. */ + if (_cad_pending_level != INT8_MIN) { + next = mesh::maintenanceSooner( + next, clampDeadline(_cad_pending_deadline_ms - now)); + } /* Stats decay. _cad_last_decay_ms == 0 means the first call latches it * rather than decaying, so treat that as due now. */ @@ -1878,17 +2045,29 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) (int)base + _cad_offset, base, spread_mean10 / 10U, spread_mean10 % 10U, degen_pct); if (room_for_count) { - /* bursts(ok reads/busy reads/abandoned bursts) — the busy and - * abandoned figures are what distinguish a sampler that is losing - * the odd read from one that is being refused outright. */ - n += snprintf(buf + n, cap > n ? cap - n : 0, "(%u r%u/b%u/a%u)", + /* bursts(ok reads/busy reads/abandoned bursts/dc-blocked) — the + * busy and abandoned figures distinguish a sampler that is losing + * the odd read from one that is being refused mid-burst, and the + * dc figure distinguishes both from one that never gets to start + * because the duty-cycle sleep window turns it away. Without the + * last one, "refused constantly" and "running perfectly" both + * print b0/a0. */ + n += snprintf(buf + n, cap > n ? cap - n : 0, + "(%u r%u/b%u/a%u/d%u)", (unsigned)_rssi_bursts, (unsigned)_rssi_reads_ok, (unsigned)_rssi_reads_busy, - (unsigned)_rssi_bursts_abandoned); + (unsigned)_rssi_bursts_abandoned, + (unsigned)_rssi_dc_blocked); } n += snprintf(buf + n, cap > n ? cap - n : 0, " bc:%u%%", (unsigned)_cad_busycap_pct); + /* Probing off because the radio cannot supply CAD_RX ground truth (see + * cadMaintenance). Stated rather than left to be inferred from level + * counters that never move. */ + if (hwCadRxTimeoutMs() == 0) { + n += snprintf(buf + n, cap > n ? cap - n : 0, " probe:n/a"); + } /* Only the 3 rungs around the operating offset — the far rungs are mildly * irrelevant; what matters is where we sit on the ladder. The window is diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 637886a..a31d818 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -130,7 +130,9 @@ public: /* Adaptive CAD (LBT detPeak calibration) */ void setCadParams(bool auto_enabled, int8_t offset, - uint16_t probe_interval_s, uint8_t busycap_pct) override; + uint16_t probe_interval_s, uint8_t busycap_pct, + uint8_t stored_base = 0) override; + uint8_t cadBasePeak() override; void cadMaintenance() override; /** Deaf-aware AGC unstick + temperature-drift recalibration. * Called from Dispatcher::maintenanceLoop(); never on the packet path. */ @@ -162,6 +164,31 @@ protected: virtual int hwSendAsync(uint8_t *buf, uint32_t len, struct k_poll_signal *sig) = 0; virtual int16_t hwGetCurrentRSSI() = 0; + /** Read `n` instantaneous RSSI samples spaced `spacing_us` apart. + * + * Exists so the whole burst can be bracketed ONCE by whatever a family + * needs to take a reading, instead of once per sample. On the LR + * families a single RSSI read stands the duty cycle down, enters + * continuous Rx, waits 1 ms to settle, reads, and re-arms the cycle -- + * and that re-arm clears every IRQ and zeroes the RX-busy latch. Called + * eight times for one median that is eight cycle tear-downs, eight latch + * wipes and 8 ms of settle, every sampling interval, whether or not a + * CAD probe follows. + * + * Returns the number of valid samples written (< n means the read was + * refused partway and the caller should abandon the burst), or a NEGATIVE + * value when the burst must be abandoned for a reason that is not a read + * failure: -EAGAIN says the receiver detected a preamble or header inside + * the window, so the samples describe that signal and not the floor. + * Worth its own return because the caller's counters exist to tell a + * failing bus from a busy channel, and only a family that brackets its own + * Rx entry is in a position to notice the latter. + * + * The default implementation is the old per-sample loop, which is already + * correct for radios whose RSSI read has no such bracket (SX126x bails on + * BUSY and touches nothing; SX127x has no duty cycle at all) and which + * therefore never reports -EAGAIN. */ + virtual int hwGetRssiBurst(int16_t *out, int n, uint32_t spacing_us); /* Non-destructive read of the radio's "currently receiving" signal — * latch + raw IRQ bits, never clears. Backs LoRaRadioBase::isReceiving(). */ virtual bool hwIsReceiving() = 0; @@ -174,9 +201,24 @@ protected: * CAD (SX127x): probing unsupported, offset ignored. ───────────── */ /** Blocking calibration CAD at (family base detPeak + level). - * Leaves the chip in STANDBY; caller restarts RX. - * Returns 1 = busy, 0 = free, <0 = error / unsupported. */ + * Returns 0 = free (chip in STANDBY, caller restarts RX), + * 1 = busy, chip in STANDBY, caller restarts RX, + * 2 = busy, chip left in RX on the detected signal (CAD_RX + * exit mode) -- caller must NOT restart RX, and reads the + * outcome later via hwCadRxOutcome(), + * <0 = error / unsupported. */ virtual int hwCadProbe(int8_t level) { (void)level; return -ENOSYS; } + + /** Outcome of the RX a hwCadProbe() == 2 left the chip in. + * 1 = a packet completed, so the detection was real; + * 2 = the chip's own CAD timeout expired with nothing decoded; + * 0 = not armed, or the terminal interrupt has not arrived yet. + * Reads driver state only -- no chip access, no polling loop. */ + virtual int hwCadRxOutcome() { return 0; } + + /** How long the chip may stay in a CAD_RX-entered RX before it raises + * its own timeout. Bounds the wait before hwCadRxOutcome() is read. */ + virtual uint32_t hwCadRxTimeoutMs() { return 0; } /** Apply the operating detPeak offset for all subsequent LBT CADs. */ virtual void hwCadSetPeakOffset(int8_t offset) { (void)offset; } /** Per-SF base detPeak for the current config (0 = unsupported). */ @@ -336,6 +378,12 @@ protected: int8_t _cad_offset; /* operating detPeak offset (levels) */ uint16_t _probe_interval_s; /* 0 = CAD probing disabled; drives _measure_interval_ms */ uint8_t _cad_busycap_pct; /* airtime cap: max % TX deferred (0 = off) */ + /* A CAD_RX probe awaiting its terminal event. _cad_pending_level is the + * stats rung the result belongs to; _cad_pending_deadline_ms is when the + * chip guarantees it has resolved, so the answer is READ once at that + * point rather than polled for. Level INT8_MIN means nothing pending. */ + int8_t _cad_pending_level; + int64_t _cad_pending_deadline_ms; int64_t _cad_last_probe_ms; int64_t _cad_last_decay_ms; /* Earliest uptime at which a due-but-blocked probe may be retried. The @@ -382,6 +430,15 @@ protected: uint32_t _rssi_reads_ok; uint32_t _rssi_reads_busy; uint32_t _rssi_bursts_abandoned; + /* Attempts turned away BEFORE a burst starts, by the isRadioReady() + * guard — i.e. the duty-cycle sleep window. The three counters above + * cannot see this: they only move once a burst is already running, so a + * sampler that is being refused outright reads as b0/a0, which is + * indistinguishable from one that is running perfectly. That ambiguity + * is what makes the "does the floor go stale under duty cycle?" question + * unanswerable today, and it is the missing denominator for the ~0.2% + * completion rate noted above. */ + uint32_t _rssi_dc_blocked; /* Silence tracking for deafness hunting. Reports only; the recovery * decision lives in agcIdleMaintenance() and is family-gated. */ diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index 3117ec2..65d4c3e 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -141,6 +141,16 @@ int SX126xRadio::hwCadProbe(int8_t level) return sx126x_cad_probe(_dev, level); } +int SX126xRadio::hwCadRxOutcome() +{ + return sx126x_cad_rx_outcome(_dev); +} + +uint32_t SX126xRadio::hwCadRxTimeoutMs() +{ + return sx126x_cad_rx_timeout_ms(_dev); +} + void SX126xRadio::hwCadSetPeakOffset(int8_t offset) { sx126x_cad_set_peak_offset(_dev, offset); diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index d05a140..9728069 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -41,6 +41,8 @@ protected: bool hwIsChipBusy() override; uint32_t hwWakeupTimeUs() override; int hwCadProbe(int8_t level) override; + int hwCadRxOutcome() override; + uint32_t hwCadRxTimeoutMs() override; void hwCadSetPeakOffset(int8_t offset) override; uint8_t hwCadBasePeak() override; uint8_t hwCadPeakMin() override; diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index 7a2661d..a42d1c8 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -96,13 +96,19 @@ static inline uint32_t rssi_settle_delay_us(uint16_t bw_khz) * Housekeeping-tick CAD probes accumulate per-level busy/free statistics; * a one-sided staircase converges on the lowest detPeak offset whose * false-positive rate stays under target. Levels are signed offsets from - * the chip family's per-SF base detPeak (SX126x: SF+13; LR11xx/LR20xx: - * 56-68 table) so the C++ layer stays scale-independent. */ + * the chip family's per-SF base detPeak, so the C++ layer stays + * scale-independent. Those bases are per-SF AND per-bandwidth tables taken + * from Semtech's own reference stack (LoRa Basics Modem v4.9.0, + * ral_{sx126x,lr11xx}.c) plus the LR2021 datasheet's symbol-indexed Table 6-19; + * the flat "SX126x: SF+13 / LR: 56-68" this comment used to quote has not been + * the whole story since 2026-08-29. */ /* Operating-offset range (levels from the per-SF family base detPeak). Wide * on purpose — a dense hilltop may need a much higher detPeak than a quiet - * valley node; the per-family absolute clamp in the driver (SX126x 15-40, - * LR 48-90) is a firmware guardrail against "CAD never/always fires", NOT a - * chip limit (cadDetPeak is a full uint8_t). + * valley node; the per-family absolute clamp in the driver (SX126x 12-48, + * LR11xx 40-100, LR20xx 48-90) is a firmware guardrail against "CAD + * never/always fires", NOT a chip limit (cadDetPeak is a full uint8_t). The + * clamps are exported via hwCadPeakMin/Max so cadLevelMinEff/MaxEff can narrow + * this window honestly where a base sits close to one of them. * MUST match CAD_OFFSET_MIN/MAX in helpers/NodePrefs.h. */ #define CAD_LEVEL_MIN (-8) /* most sensitive probe level */ #define CAD_LEVEL_MAX 12 /* least sensitive probe level */ @@ -134,9 +140,20 @@ static inline uint32_t rssi_settle_delay_us(uint16_t bw_khz) * cap, step UP (less sensitive) regardless of FP — self-targeting, since a * quiet node's busy rate never reaches it. HYST keeps a descend from bouncing * straight back into the cap. The cap itself is a per-node pref - * (`cad_busycap`, percent, `set cad.busycap`; default 25, 0 = off) since it is + * (`cad_busycap`, percent, `set cad.busycap`; default 15, 0 = off) since it is * a policy call (airtime vs. collision/capture), not a physical constant. */ -#define CAD_BUSY_DEFER_HYST_PERMILLE 100 /* descend only if frontier busy <= cap-10% */ +/* Descend hysteresis, as a PERCENTAGE OF THE CAP rather than a fixed permille + * subtrahend. It used to be a flat 100‰, which works at the old default cap of + * 25% (threshold 15%, hysteresis 40% of the cap) and degenerates as the cap + * falls: at cap 15% the threshold is 5%, and at cap 10% — the value + * docs/ADAPTIVE_CAD.md recommends for saturated hilltops, and also the CLI's + * minimum non-zero setting — it reaches ZERO. There, descent requires exactly + * zero busy probes at the frontier, so the staircase becomes a one-way upward + * ratchet at precisely the setting the documentation recommends. + * + * 40% reproduces the old behaviour exactly at cap 25 and holds that same share + * at every other setting. */ +#define CAD_BUSY_DEFER_HYST_PCT 40 /* descend only if frontier busy <= 60% of cap */ #define CAD_PROBE_RSSI_GUARD 7 /* dB above floor = channel visibly busy, skip probe */ #define CAD_STATS_DECAY_MS (6UL * 3600UL * 1000UL) /* halve counters every 6 h */ /* NOTE: the probe has no retry deadline and no wake of its own. It runs off diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 920f88c..417e88c 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -277,6 +277,12 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { * stock orientation every already-deployed node runs. */ fs_read(&file, &prefs.display_rotate, sizeof(prefs.display_rotate)); fs_read(&file, &prefs.input_rotate, sizeof(prefs.input_rotate)); + /* Family base detPeak cad_offset was learned against, offset 307. Absent + * in <308-byte files; the no-op EOF read leaves 0, which setCadParams() + * reads as "no base recorded" and acts on by leaving the stored offset + * alone — correct, since a node upgrading across a table change cannot + * know which base its offset came from. */ + fs_read(&file, &prefs.cad_base, sizeof(prefs.cad_base)); fs_close(&file); @@ -427,6 +433,8 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { /* Mounting orientation (offsets 305-306) */ fs_write(&file, &prefs.display_rotate, sizeof(prefs.display_rotate)); fs_write(&file, &prefs.input_rotate, sizeof(prefs.input_rotate)); + /* Family base detPeak cad_offset was learned against (offset 307) */ + fs_write(&file, &prefs.cad_base, sizeof(prefs.cad_base)); ret = fs_sync(&file); fs_close(&file); diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index 4dcb7b9..42c33c2 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -188,7 +188,10 @@ protected: } void applyCadPrefs() override { _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, - _prefs.probe_interval, _prefs.cad_busycap); + _prefs.probe_interval, _prefs.cad_busycap, + _prefs.cad_base); + _prefs.cad_offset = _radio->getCadOffset(); + _prefs.cad_base = _radio->cadBasePeak(); } void resetCadStats() override { _radio->resetCadStats(); diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index 20ed14b..8b576ee 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -158,7 +158,10 @@ protected: } void applyCadPrefs() override { _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, - _prefs.probe_interval, _prefs.cad_busycap); + _prefs.probe_interval, _prefs.cad_busycap, + _prefs.cad_base); + _prefs.cad_offset = _radio->getCadOffset(); + _prefs.cad_base = _radio->cadBasePeak(); } void resetCadStats() override { _radio->resetCadStats(); diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index a5579fc..866c4c4 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -728,6 +728,12 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch * _prefs->cad_offset, and _cad_offset inside the radio, which * applyCadPrefs() reloads through setCadParams(). */ _prefs->cad_offset = cliDefaults()->cad_offset; + /* Clear the recorded base too. applyCadPrefs() below re-stamps it + * from the radio, so a reset always leaves offset and base + * describing the same configuration -- leaving a stale base here + * would make the NEXT base-table change re-anchor a freshly reset + * offset away from zero. */ + _prefs->cad_base = 0; _callbacks->resetCadStats(); _callbacks->applyCadPrefs(); savePrefs(); diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index b54d264..fa83ae8 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -107,11 +107,14 @@ struct NodePrefs { uint8_t _reserved_apc_margin; uint8_t meshtimesync; // 1 = mesh time-sync clock correction on (default off) uint8_t cad_auto; // 1 = adaptive-CAD staircase acts on probe stats (default ON) - int8_t cad_offset; // operating detPeak offset from family base (-4..4) + int8_t cad_offset; // operating detPeak offset from family base (CAD_OFFSET_MIN..MAX, -8..+12) uint8_t probe_interval; // seconds between periodic radio measurements: // one noise-floor sample, and the CAD probe that // consumes it (0 = CAD probing off, default 15) - uint8_t cad_busycap; // airtime-protection: max % of TX attempts deferred before backing off detPeak (0 = off, default 25) + uint8_t cad_busycap; // faint-tolerance / airtime cap: raise detPeak once more than this % + // of QUIET-MOMENT PROBES trip on a faint signal (0 = off, default 15). + // Not "% of TX attempts deferred" -- probes are prefiltered to quiet + // moments, so strong traffic never enters the statistic at all. /* LR2021 side detectors: extra spreading factors received concurrently * with `sf`, on the same bandwidth. Zero-terminated list — the first 0 * ends it, so an all-zero array means the feature is off (which is what @@ -133,6 +136,21 @@ struct NodePrefs { uint8_t display_rotate; // 1 = panel rotated 180 degrees uint8_t input_rotate; // 1 = joystick up/down and left/right swapped + /* The family base detPeak that cad_offset was learned against (0 = never + * stored, i.e. a node upgrading from a build without this field). + * + * cad_offset is a SIGNED OFFSET from a per-SF/per-bandwidth base table + * that lives in the driver, so changing that table silently re-points a + * stored offset at a different absolute detPeak. The probe statistics + * behind the offset are RAM-only and die at the reboot a firmware upgrade + * involves, but the offset itself is persisted and survives — so after a + * table change a converged node quietly starts operating somewhere it + * never measured. Recording the base turns that into something the + * firmware can correct at boot (see LoRaRadioBase::setCadParams), instead + * of a "run set cad.reset after upgrading" line in the release notes that + * most users will not read. */ + uint8_t cad_base; + /* ---- Companion-only fields ---- */ uint8_t manual_add_contacts; uint8_t telemetry_mode_base; @@ -344,7 +362,7 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->cad_auto = 1; // Default ON — adaptive staircase acts on probe stats prefs->cad_offset = 0; // Start at family base detPeak (SF+13 on SX126x) prefs->probe_interval = 15; // floor sample + CAD probe; staircase responds in ~1-2 h - prefs->cad_busycap = 25; // back off detPeak once >25% of TX attempts are deferred + prefs->cad_busycap = 15; // back off detPeak once >15% of quiet-moment probes trip on faint signals prefs->wake_on_msg = 1; // Default ON — wake display when message arrives prefs->display_rotate = 0; // Default OFF — panel in the stock case orientation prefs->input_rotate = 0; // Default OFF — joystick axes as the board wires them diff --git a/zephcore/include/mesh/Radio.h b/zephcore/include/mesh/Radio.h index 768cc6d..3642696 100644 --- a/zephcore/include/mesh/Radio.h +++ b/zephcore/include/mesh/Radio.h @@ -41,11 +41,22 @@ public: /* Adaptive CAD (LBT detPeak calibration). Default no-ops for radios * without hardware CAD (SX127x). */ + /* stored_base is the family base detPeak `offset` was learned against, + * or 0 when none was recorded. When it disagrees with the base the + * driver reports now — i.e. the base table moved under a stored offset — + * the implementation re-anchors so the ABSOLUTE detPeak is preserved. + * Read cadOffset()/cadBasePeak() afterwards to persist the corrected + * pair. */ virtual void setCadParams(bool auto_enabled, int8_t offset, - uint16_t probe_interval_s, uint8_t busycap_pct) { + uint16_t probe_interval_s, uint8_t busycap_pct, + uint8_t stored_base = 0) { (void)auto_enabled; (void)offset; (void)probe_interval_s; - (void)busycap_pct; + (void)busycap_pct; (void)stored_base; } + /* Family base detPeak actually in force. Pair it with getCadOffset() to + * persist the (base, offset) the node is really operating at; 0 means the + * radio has no adaptive CAD, so there is nothing to store. */ + virtual uint8_t cadBasePeak() { return 0; } /* One housekeeping tick of the CAD calibrator: maybe run a probe, * update stats, maybe step the staircase (auto mode). */ virtual void cadMaintenance() {} diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c index 0acc4c4..f2e51da 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c @@ -146,6 +146,19 @@ struct lr11xx_data { * every LBT CAD; cad_probe_peak overrides for one calibration probe. */ int8_t cad_peak_offset; uint8_t cad_probe_peak; + /* CAD_RX exit-mode bookkeeping. With LR11XX_RADIO_CAD_EXIT_MODE_RX a + * positive CAD keeps the chip in Rx on the signal it just found instead + * of dropping to standby, so the calibration probe can be TOLD whether + * the detection was real -- by a packet arriving, or by the chip's own + * cad_timeout expiring -- rather than having to guess from a poll. On a + * negative CAD the chip enters standby exactly as with STANDBYRC + * (lr11xx_radio_types.h: "If the CAD operation is negative with + * RADIO_CAD_EXIT_MODE_RX ... the LR11XX enters Standby RC mode"). + * + * cad_exit_rx marks the CAD in flight as armed that way; cad_rx_state + * records which terminal interrupt resolved it. */ + bool cad_exit_rx; + atomic_t cad_rx_state; /* Deferred hardware init — heavy SPI/radio work runs on first config() */ bool hw_initialized; @@ -167,16 +180,23 @@ struct lr11xx_data { * bit is cleared and TX released. All accesses under spi_mutex. */ uint32_t preamble_seen_at_ms; - /* Timestamp (k_uptime_get_32(), ms) at which lr11xx_is_receiving() - * first saw SYNC_WORD_HEADER_VALID latched in the current RX cycle; - * 0 = no payload phase being timed. Bounds the payload phase the same - * way preamble_seen_at_ms bounds the preamble phase: the header bit is - * cleared only by the terminal DIO1 event's bulk clear, and continuous - * RX (SetRx 0xFFFFFF) has no symbol timer, so a header whose packet - * never completes produces no terminal IRQ and would pin the TX gate - * true forever — the node keeps receiving but never transmits again, - * silently. Released after lr11xx_max_payload_ms(). All accesses - * under spi_mutex. */ + /* Timestamp (k_uptime_get_32(), ms) at which SYNC_WORD_HEADER_VALID was + * seen for the reception in progress; 0 = no payload phase being timed. + * + * Stamped by the DIO1 work handler, which is why that IRQ is routed to + * DIO1: with a duty cycle armed lr11xx_is_receiving() may not touch the + * bus, so this timestamp is the ONLY thing it can answer from, and while + * nothing wrote it there the RX-busy gate was dead on every duty-cycled + * node. The poll path still stamps it too, for the window before the + * work item runs. + * + * Bounds the payload phase the same way preamble_seen_at_ms bounds the + * preamble phase: continuous RX (SetRx 0xFFFFFF) has no symbol timer, so + * a header whose packet never completes produces no terminal IRQ and + * would pin the TX gate true forever — the node keeps receiving but + * never transmits again, silently. Released after + * lr11xx_max_payload_ms(), and cleared by every RX (re)start through + * lr11xx_reset_rx_busy_signals(). All writes under spi_mutex. */ uint32_t header_seen_at_ms; /* Wedge-recovery watchdog: the LR1110 can rarely be left BUSY-high with @@ -200,6 +220,22 @@ struct lr11xx_data { * the two never drift out of sync — same shape as the SX126x driver's * sx126x_reset_rx_busy_signals(). Called from every RX (re)start site and * before TX / CAD entry. */ +/* cad_rx_state values -- see the field comment in the data struct. */ +#define LR11XX_CAD_RX_IDLE 0 +#define LR11XX_CAD_RX_ARMED 1 +#define LR11XX_CAD_RX_PACKET 2 +#define LR11XX_CAD_RX_TMOUT 3 + +/* Resolve an in-flight CAD_RX with the terminal event that just arrived. + * A no-op unless one is armed, so the packet path pays one atomic compare. + * Returns true when this call is the one that resolved it, which is also how + * the caller tells "the probe's own cad_timeout" apart from an unrelated + * timeout that happens to raise the same IRQ. */ +static inline bool lr11xx_cad_rx_resolve(struct lr11xx_data *data, int outcome) +{ + return atomic_cas(&data->cad_rx_state, LR11XX_CAD_RX_ARMED, outcome); +} + static inline void lr11xx_reset_rx_busy_signals(struct lr11xx_data *data) { data->preamble_seen_at_ms = 0; @@ -373,6 +409,26 @@ static void lr11xx_apply_modem_config(struct lr11xx_data *data, LR11XX_SYSTEM_IRQ_RX_DONE | LR11XX_SYSTEM_IRQ_TX_DONE | LR11XX_SYSTEM_IRQ_TIMEOUT | LR11XX_SYSTEM_IRQ_CRC_ERROR | LR11XX_SYSTEM_IRQ_HEADER_ERROR | + /* SYNC_WORD_HEADER_VALID is routed because it is the ONLY thing + * that can stamp header_seen_at_ms, and that timestamp is the + * entire RX-busy answer once a duty cycle is armed: the poll path + * below is forbidden to touch the bus there, so with this bit off + * DIO1 nothing ever wrote the field and lr11xx_is_receiving() + * returned false unconditionally on every duty-cycled node -- + * blinding the TX gate, the noise-floor sampler and the CAD probe + * alike. + * + * It is safe to route where PREAMBLE_DETECTED is not: a header + * only latches after a sync-word match AND a header CRC, so it + * fires at most once per real packet rather than on noise. This + * is the same split the SX126x driver makes for the same reason + * (preamble off DIO1, header on) -- the two families now agree. + * + * The cost is one extra wake per header-decoded packet, on + * packets the node is receiving anyway, and it needs a handler + * branch that treats the event as "reception continues" rather + * than letting the safety net restart RX over it (see there). */ + LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID | /* CAD_DONE/CAD_DETECTED MUST be redirected to DIO1 or the blocking * LBT CAD (run before every TX, cad.mode=LBT) never completes — its * semaphore is signaled from the DIO1 handler, so without this the @@ -635,6 +691,21 @@ static void lr11xx_dio1_work_handler(struct k_work *work) data->dio1_stuck_count = 0; } + /* "A valid header has been seen for the reception in progress" — from + * this IRQ word, or from the latch an earlier pass stamped. + * + * The latch half is load-bearing now that SYNC_WORD_HEADER_VALID is + * routed to DIO1: the handler fires mid-packet and bulk-clears the IRQ + * register, so by the time RX_DONE arrives the live bit is gone and only + * the latch remembers the header. Testing the live bit alone would read + * a foreign header error landing on our good packet as a genuine one and + * drop the packet — precisely the ~7-10% under-load LR1110 loss the two + * tests below exist to prevent. Computed here, before any branch can + * reset the latch. */ + bool hdr_valid_seen = + (irq & LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID) != 0 || + data->header_seen_at_ms != 0; + /* ── RX done ── * Deliver on RX_DONE unless it is a GENUINE reception failure: * - CRC_ERROR: a CRC-failed packet asserts RX_DONE+CRC_ERROR together @@ -647,10 +718,18 @@ static void lr11xx_dio1_work_handler(struct k_work *work) * the SX126x path never routes HEADER_ERROR at all. Dropping these good * packets was the LR1110 under-load packet-loss bug (verified 2026-07-24: * ~7-10% loss vs SX1262, load-dependent, size-skewed). */ + /* Ground truth for a CAD_RX probe: anything that proves a transmitter + * was actually there. A CRC or header error counts as much as a clean + * packet -- the probe is asking whether the detection was real, not + * whether the packet was usable. */ + if (irq & (LR11XX_SYSTEM_IRQ_RX_DONE | LR11XX_SYSTEM_IRQ_CRC_ERROR | + LR11XX_SYSTEM_IRQ_HEADER_ERROR)) { + lr11xx_cad_rx_resolve(data, LR11XX_CAD_RX_PACKET); + } + if ((irq & LR11XX_SYSTEM_IRQ_RX_DONE) && !(irq & LR11XX_SYSTEM_IRQ_CRC_ERROR) && - !((irq & LR11XX_SYSTEM_IRQ_HEADER_ERROR) && - !(irq & LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID))) { + !((irq & LR11XX_SYSTEM_IRQ_HEADER_ERROR) && !hdr_valid_seen)) { lr11xx_radio_rx_buffer_status_t rx_stat; lr11xx_radio_get_rx_buffer_status(ctx, &rx_stat); @@ -718,6 +797,25 @@ static void lr11xx_dio1_work_handler(struct k_work *work) if (irq & LR11XX_SYSTEM_IRQ_CAD_DONE) { bool detected = (irq & LR11XX_SYSTEM_IRQ_CAD_DETECTED) != 0; + /* CAD_RX with a positive verdict: the chip is in Rx on the + * signal it found, not in standby. Arm the tracker so whichever + * terminal interrupt follows records what it was. + * + * rx_restarted is what stops the safety net at the bottom of + * this handler from undoing that. in_rx_mode is now true and no + * branch below sets the flag, so without it every positive probe + * ended in "DIO1 safety: no IRQ handled" + lr11xx_restart_rx(), + * which forced standby and tore down the very reception the + * probe exists to observe: no terminal IRQ ever followed, the + * outcome read back as unresolved, and every busy sample was + * discarded. Same meaning as the foreign-header-error branch + * below -- reception continues, nothing needs restarting. */ + if (data->cad_exit_rx && detected) { + data->in_rx_mode = true; + atomic_set(&data->cad_rx_state, LR11XX_CAD_RX_ARMED); + rx_restarted = true; + } + if (data->cad_cb) { lora_cad_cb cb = data->cad_cb; void *ud = data->cad_user_data; @@ -750,6 +848,12 @@ static void lr11xx_dio1_work_handler(struct k_work *work) /* ── Timeout ── */ if (irq & LR11XX_SYSTEM_IRQ_TIMEOUT) { + /* cad_timeout expired with nothing decoded: the detection had + * no packet behind it. Resolved before the branches below, + * which put the receiver back on air. */ + bool was_cad_rx = lr11xx_cad_rx_resolve(data, + LR11XX_CAD_RX_TMOUT); + if (data->tx_active) { /* The chip's Tx timeout fired, so the transmission was * stopped and TX_DONE will never arrive. This branch @@ -771,8 +875,14 @@ static void lr11xx_dio1_work_handler(struct k_work *work) * the 2*RxPeriod + SleepPeriod window the preamble * detect restarted (UM §7.2.6) expired with no packet, * so the chip left the loop. Counting it is what makes - * `get dc.restarts` mean something on this radio. */ - if (data->rx_duty_cycle_enabled) { + * `get dc.restarts` mean something on this radio. + * + * Not when this timeout is the CAD_RX probe's own + * cad_timeout: that is a calibration false positive, it + * raises the identical IRQ, and counting it would mix + * one tick per busy probe into a figure that is supposed + * to describe duty-cycle preamble behaviour. */ + if (data->rx_duty_cycle_enabled && !was_cad_rx) { atomic_inc(&data->dc_timeout_restarts); } if (lr11xx_restart_rx(data, false) < 0) { @@ -797,8 +907,7 @@ static void lr11xx_dio1_work_handler(struct k_work *work) * error under load is what cost the LR1110 packets (the SX126x never routes * HEADER_ERROR for exactly this reason). Leave the chip in RX; the good * packet's own RX_DONE delivers it above. */ - if ((irq & LR11XX_SYSTEM_IRQ_HEADER_ERROR) && - (irq & LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID) && + if ((irq & LR11XX_SYSTEM_IRQ_HEADER_ERROR) && hdr_valid_seen && !(irq & LR11XX_SYSTEM_IRQ_RX_DONE)) { LOG_DBG("RX: foreign HDR err during valid header — not aborting"); rx_restarted = true; /* reception continues; skip safety-net restart */ @@ -838,6 +947,37 @@ static void lr11xx_dio1_work_handler(struct k_work *work) return; } + /* ── Header valid ── + * A packet is past sync word and header CRC and is now in its payload + * phase. Stamp the latch lr11xx_is_receiving() answers from; bounded + * there by lr11xx_max_payload_ms(), and cleared by the terminal event's + * lr11xx_reset_rx_busy_signals(). + * + * Gated on no terminal bit for OUR packet in this same pass -- those + * branches above own the outcome and have already reset the signals, so + * stamping after them would re-arm a gate for a packet that is finished. + * HEADER_ERROR is deliberately NOT in that gate: with a valid header it + * means a foreign signal errored while our packet is still arriving, + * which is exactly a reception to keep the gate closed for. + * + * rx_restarted, for the same reason the CAD_RX branch sets it: nothing + * needs restarting, the chip is mid-packet, and letting the safety net + * below fire here would destroy the very packet this branch exists to + * protect -- the failure that kept this IRQ off DIO1 in the first + * place. */ + if ((irq & LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID) && + !(irq & (LR11XX_SYSTEM_IRQ_RX_DONE | LR11XX_SYSTEM_IRQ_CRC_ERROR | + LR11XX_SYSTEM_IRQ_TIMEOUT))) { + uint32_t now = k_uptime_get_32(); + + /* 1 as the "set" sentinel if k_uptime is 0 right after boot. */ + data->header_seen_at_ms = (now == 0) ? 1U : now; + /* The latch is the truth source now; a preamble timestamp left + * behind would outlive it and be read as a live grace window. */ + data->preamble_seen_at_ms = 0; + rx_restarted = true; + } + safety_check: /* Safety net: if we should be in RX but no IRQ branch restarted it, * force a restart. This catches: @@ -1281,6 +1421,46 @@ static void lr11xx_dc_resume(struct lr11xx_data *data, bool was_armed) data->in_rx_mode = true; } +/* Front-end settle to wait after entering Rx before the first GetRssiInst. + * + * DS Table 13-82 puts the RSSI averaging window at ~936 us*kHz / BW and the + * post-Rx-entry delay at 12-15 of those windows; the C++ sampler will not read + * until 16 have passed (rssi_settle_delay_us(), radio_common.h). An Rx entry + * made HERE has to clear the same bar, or the reading it brackets comes from a + * front end the firmware itself considers unsettled. + * + * The flat 1 ms this replaces clears it from BW20.83 up but not below -- + * BW15.63 wants 1008 us, BW10.42 1504 us, BW7.81 2144 us -- and `set radio` + * accepts bandwidths that low. Floored at 1000 so no preset that works today + * waits less than it did. Deliberately the same 936/16 the C++ side uses: two + * settle models that could disagree would be worse than one that is wrong. */ +static uint32_t lr11xx_rssi_settle_us(struct lr11xx_data *data) +{ + uint32_t bw_khz = (uint32_t)bw_enum_to_khz(data->modem_cfg.bandwidth); + uint32_t settle; + + if (bw_khz == 0) { + return 1000U; + } + settle = ((936U + bw_khz - 1U) / bw_khz) * 16U; + + return settle < 1000U ? 1000U : settle; +} + +/* Sleep the whole millisecond, busy-wait only the remainder. k_sleep() rounds + * up to a tick and the tick period is a board Kconfig, so asking it for a + * sub-millisecond excess is not portable; this keeps the old wait exactly and + * adds the deficit on top. */ +static void lr11xx_rssi_settle(struct lr11xx_data *data) +{ + uint32_t settle_us = lr11xx_rssi_settle_us(data); + + k_sleep(K_MSEC(1)); + if (settle_us > 1000U) { + k_busy_wait(settle_us - 1000U); + } +} + int16_t lr11xx_get_rssi_inst(const struct device *dev) { struct lr11xx_data *data = dev->data; @@ -1302,13 +1482,14 @@ int16_t lr11xx_get_rssi_inst(const struct device *dev) * * The settle wait is the receiver's, not the bus's: UM Rx timing * has the value valid once the front end has settled after Rx - * entry, and 1 ms clears that at every bandwidth this firmware - * offers. It is also the entire cost of the manoeuvre — against - * a noise-floor interval measured in seconds, the receiver is - * off air for well under a hundredth of a percent of the time. */ + * entry, and lr11xx_rssi_settle() sizes that from the bandwidth + * (a flat 1 ms was short of it below BW20.83). It is also the + * entire cost of the manoeuvre — against a noise-floor interval + * measured in seconds, the receiver is off air for well under a + * hundredth of a percent of the time. */ lr11xx_radio_set_rx_with_timeout_in_rtc_step(&data->hal_ctx, 0xFFFFFF); - k_sleep(K_MSEC(1)); + lr11xx_rssi_settle(data); } if (lr11xx_radio_get_rssi_inst(&data->hal_ctx, &rssi) == @@ -1385,30 +1566,163 @@ static uint32_t lr11xx_max_payload_ms(struct lr11xx_data *data) return ms + (ms / 4U) + 100U; } +/* The cad_timeout a CAD_RX probe programs, in RTC steps at 32768 Hz. + * + * Computed here rather than through lr11xx_radio_convert_time_in_ms_to_rtc_step() + * for the reason the Tx path already documents: that helper is + * `(uint32_t)(time_in_ms * LR11XX_RTC_FREQ_IN_HZ / 1000)`, so the multiply + * overflows uint32 above 131071 ms. lr11xx_max_payload_ms() crosses that at + * SF12/BW15.63 (~136 s), SF12/BW10.42 (~204 s), SF12/BW7.81 (~286 s) and + * SF11/BW7.81 (~152 s), where the wrapped value would end the Rx almost + * immediately -- truncating a real reception (UM: the chip stays in Rx until a + * packet is demodulated or the timer expires) and booking the probe as a false + * positive. Same 64-bit maths and same 24-bit saturation as the Tx timeout; + * the ceiling is 512 s, which no preset reaches. */ +static uint32_t lr11xx_cad_rx_timeout_steps(struct lr11xx_data *data) +{ + uint64_t steps = ((uint64_t)lr11xx_max_payload_ms(data) * 32768U) / 1000U; + + if (steps > 0x00FFFFFFU) { + steps = 0x00FFFFFFU; + } + return (uint32_t)steps; +} + +int lr11xx_get_rssi_burst(const struct device *dev, int16_t *out, int n, + uint32_t spacing_us) +{ + struct lr11xx_data *data = dev->data; + int got = 0; + + /* One stand-down for the WHOLE burst, not one per sample. + * + * lr11xx_get_rssi_inst() has to suspend the duty cycle, enter continuous + * Rx, wait 1 ms for the front end, read, and then hand the cycle back -- + * and lr11xx_dc_resume() clears every IRQ and zeroes the RX-busy latch, + * because a re-armed cycle must start from a known point. The noise-floor + * sampler wants a median of eight, and calling the single-shot read eight + * times therefore cost eight cycle tear-downs, eight latch wipes and 8 ms + * of settle every sampling interval -- on a receiver whose whole job is to + * be listening. That is the same fault the CAD classifier had, in a + * different function; here it fires whether or not a probe follows. + * + * Bracketing once collapses it to one of each. It also holds the SPI + * mutex for a single short span instead of taking it eight times, and the + * reads are spaced by the caller's averaging window exactly as before, so + * they stay independent. */ + if (k_mutex_lock(&data->spi_mutex, K_NO_WAIT) != 0) { + return 0; + } + + bool armed = lr11xx_dc_suspend(data); + + if (armed) { + lr11xx_radio_set_rx_with_timeout_in_rtc_step(&data->hal_ctx, + 0xFFFFFF); + /* Start the window clean so the check after the loop reports + * only what arrived INSIDE it -- these bits are latched and + * nothing clears them between duty-cycle re-arms, so a stale + * preamble from earlier in the cycle would otherwise condemn + * every burst. Safe here and nowhere else: the cycle is already + * torn down and dc_resume() clears the lot again on the way out, + * so this destroys no state the poll path could still want. + * CMD_ERROR rides along because the LR1110 raises it on any + * ClearIrq whose mask excludes it (all FW versions). */ + lr11xx_system_clear_irq_status(&data->hal_ctx, + LR11XX_SYSTEM_IRQ_PREAMBLE_DETECTED | + LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID | + LR11XX_SYSTEM_IRQ_CMD_ERROR); + lr11xx_rssi_settle(data); + } + + for (int i = 0; i < n; i++) { + int8_t rssi; + + if (i) { + k_busy_wait(spacing_us); + } + if (lr11xx_radio_get_rssi_inst(&data->hal_ctx, &rssi) != LR11XX_STATUS_OK) { + break; + } + out[i] = (int16_t)rssi; + got++; + } + + /* Did a transmitter turn up inside the window? Then these samples + * measure it, not the floor, and the caller must throw them away. + * + * This covers a gap the caller's own guards cannot. cadMaintenance() + * re-checks isReceiving() immediately before its CAD probe, and that + * now works on this family -- but dc_resume() below zeroes + * header_seen_at_ms on its way out, one call earlier, so a reception + * that began inside this bracket is invisible to the re-check by the + * time it runs. Reporting the contamination from in here, where the + * evidence still exists, closes it: the sampler abandons the burst, + * _sample_fresh stays clear, and the probe -- which rides on that flag + * -- does not run this pass. + * + * Only meaningful with a cycle armed. Without one this function makes + * no Rx entry of its own and clears nothing, so the caller's guards see + * the reception unaided. */ + if (armed) { + lr11xx_system_irq_mask_t irq = 0; + + if (lr11xx_system_get_irq_status(&data->hal_ctx, &irq) == + LR11XX_STATUS_OK && + (irq & (LR11XX_SYSTEM_IRQ_PREAMBLE_DETECTED | + LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID))) { + got = -EAGAIN; + } + } + + lr11xx_dc_resume(data, armed); + k_mutex_unlock(&data->spi_mutex); + + return got; +} + bool lr11xx_is_receiving(const struct device *dev) { struct lr11xx_data *data = dev->data; - /* With a cycle armed, answer from the DIO1-driven latch and issue no SPI - * at all. + /* Payload phase, from the latch the DIO1 handler stamps on + * SYNC_WORD_HEADER_VALID. Read before the duty-cycle split because it + * is the one signal both sides share — and the only one the armed side + * has at all. * * This path runs on every TX gate, so it cannot bracket itself in * suspend/resume the way the samplers do — standing the cycle down to * ask "am I receiving?" would end the very reception being asked about. - * It does not need to: header_seen_at_ms is stamped by the DIO1 work - * handler, which needs no bus access to be current. - * - * It must not fall back to a BUSY read either. BUSY is high both in the + * It must not fall back to a BUSY read either: BUSY is high both in the * sleep phase and through ordinary Rx on this family, so a BUSY-high * "not receiving" answer is wrong exactly when it matters — mid-packet, - * with the TX gate asking for permission to transmit over it. */ - if (data->rx_duty_cycle_enabled) { - uint32_t seen = data->header_seen_at_ms; + * with the TX gate asking for permission to transmit over it. + * + * That leaves the latch, which is why the header IRQ is routed to DIO1 + * (see lr11xx_configure_irq): the handler needs no bus access to keep + * this current, and until it did the armed side had nothing to read. */ + uint32_t hdr_seen = data->header_seen_at_ms; - if (seen == 0) { - return false; + if (hdr_seen != 0 && + (k_uptime_get_32() - hdr_seen) < lr11xx_max_payload_ms(data)) { + return true; + } + + if (data->rx_duty_cycle_enabled) { + /* Never stamped, or the payload deadline has blown. No bus + * access is permitted here to look any further, and none is + * wanted: the latch is the whole answer on this side. Drop a + * stale one so the compare above stops repeating, but only if + * the mutex is free — the DIO1 handler owns this field, and if + * it is mid-update it will maintain the field itself. */ + if (hdr_seen != 0 && + k_mutex_lock(&data->spi_mutex, K_NO_WAIT) == 0) { + LOG_WRN("RX header latched %u ms with no packet, releasing TX gate", + k_uptime_get_32() - hdr_seen); + data->header_seen_at_ms = 0; + k_mutex_unlock(&data->spi_mutex); } - return (k_uptime_get_32() - seen) < lr11xx_max_payload_ms(data); + return false; } /* Non-blocking — skip if SPI busy */ @@ -1426,19 +1740,20 @@ bool lr11xx_is_receiving(const struct device *dev) * never completes would otherwise mute TX until reboot. */ if (irq & LR11XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID) { uint32_t now = k_uptime_get_32(); - uint32_t seen = data->header_seen_at_ms; - if (seen == 0) { + /* The DIO1 handler normally stamps this before any poll sees + * the bit; the poll can still get there first in the window + * before the work item runs, so it stays able to stamp. A latch + * still inside its deadline already returned true at the top of + * the function, so reaching here with one set means the deadline + * is blown and the gate has to be released. */ + if (data->header_seen_at_ms == 0) { data->header_seen_at_ms = (now == 0) ? 1U : now; k_mutex_unlock(&data->spi_mutex); return true; } - if ((now - seen) < lr11xx_max_payload_ms(data)) { - k_mutex_unlock(&data->spi_mutex); - return true; - } LOG_WRN("RX header latched %u ms with no packet, releasing TX gate", - now - seen); + now - data->header_seen_at_ms); /* Drop the sticky reception bits so the next poll starts clean. * CMD_ERROR goes with them — the LR1110 sets it whenever ClearIrq * is called with a mask that excludes it (all FW versions). */ @@ -1718,19 +2033,44 @@ int16_t lr11xx_get_chip_temp_c(const struct device *dev) * the SX1262 in the same room settled at offset -1 while these walked to -8. * * Below BW125 Semtech returns RAL_STATUS_UNKNOWN_VALUE and offers nothing. We - * run BW62.5 by default, so that gap is our normal operating point. We reuse - * the BW125 row there rather than extrapolating the trend downward: the curve - * is empirical PER-test data with visible noise (SF9 breaks monotonicity in all - * three brackets), a one-octave extrapolation at SF7 would invent ~40, and we - * already run a closed-loop controller whose entire job is to find the local - * value. Reusing BW125 leaves the adaptive offset a short, well-centred walk - * instead of substituting a guess for a measurement we take anyway. */ + * run BW62.5 by default, so that gap is our normal operating point, and the + * sub-125 row below is MEASURED rather than published. + * + * Provenance and its honest limit: over 2026-08-30..09-01 two LR1110 T1000-Es — + * one here, one in another country, on different sites — both converged to an + * absolute detPeak of 44 at SF7/BW62.5, i.e. offset -7 against the BW125 row's + * 51 (52 minus the 4-symbol correction). Two independent sites agreeing to the + * count is what makes this a measurement; three SX1262s in the same campaign + * landed within one count of each other across a house, a roof and a + * mountaintop, which is the general finding that the site scales the FP curve + * without moving its bend. + * + * The correction is applied FLAT, as a -7 translation of the whole BW125 row. + * A constant preserves the per-SF shape Semtech actually measured; scaling each + * SF by its own bandwidth slope would lean on their noisiest dimension (SF9 + * steps +5 then +15 across the two published octaves) and distort that shape + * from a single anchor point. Flat also errs more sensitive at high SF, the + * safe side of an asymmetric offset range (-8 down, +12 up). + * + * Sanity check on the magnitude, since only one SF was measured: Semtech's own + * SF7 trend is 52/64/77 across BW125/250/500, about 12 counts per octave, so a + * linear extrapolation one octave down would predict -12 and a base of 40. The + * measured -7 sits between zero (what we shipped before) and that, which is the + * shape expected from a curve flattening at the narrow end. The measurement is + * not fighting the trend; it lands inside the bracket the trend allows. + * + * ONE SF MEASURED, SEVEN EXTRAPOLATED. That is the real limit of this row, and + * it is acceptable only because the closed-loop staircase exists to find the + * local value from a starting point — this makes the start honest, it does not + * claim to be the answer. */ static uint8_t lr11xx_cad_detect_peak(uint8_t sf, uint16_t bw_khz, uint8_t symb_nb) { /* SF5 SF6 SF7 SF8 SF9 SF10 SF11 SF12 */ static const uint8_t bw500[8] = { 65, 70, 77, 85, 78, 80, 79, 82 }; static const uint8_t bw250[8] = { 60, 61, 64, 72, 63, 71, 73, 75 }; static const uint8_t bw125[8] = { 56, 52, 52, 58, 58, 62, 66, 68 }; + /* bw125 - 7, measured at SF7/BW62.5 on two sites. See the note above. */ + static const uint8_t bw_sub125[8] = { 49, 45, 45, 51, 51, 55, 59, 61 }; const uint8_t *row; int peak; @@ -1742,9 +2082,11 @@ static uint8_t lr11xx_cad_detect_peak(uint8_t sf, uint16_t bw_khz, uint8_t symb_ row = bw500; } else if (bw_khz >= 250) { row = bw250; - } else { - /* BW125 and everything narrower — see the note above. */ + } else if (bw_khz >= 125) { row = bw125; + } else { + /* Narrower than BW125: Semtech publishes nothing, we measured. */ + row = bw_sub125; } peak = (int)row[sf - 5]; @@ -1770,14 +2112,34 @@ static uint8_t lr11xx_cad_detect_peak(uint8_t sf, uint16_t bw_khz, uint8_t symb_ * documented failure mode on the LR2021 (see LR2021Radio::hwCadPeakMin), and * the old 48 floor here reproduced it on the LR1110 at SF7. * - * The bounds are chosen so the clamp never truncates the offset window itself: - * the lowest base this table yields is 51 (SF6/SF7, BW<=125, 4 symbols), and - * CAD_LEVEL_MIN is -8, so anything above 43 would silently collapse the bottom - * rungs; the highest base is 85 (SF8, BW500) and CAD_LEVEL_MAX is +12. Within - * those, CAD_LEVEL_MIN/MAX remain the real limit and this is only a guardrail - * against "CAD never fires" / "CAD always busy". 40 is also roughly where - * Semtech's own BW trend extrapolates for the sub-125 bandwidths it declines to - * tabulate, which is where our default preset lives. */ + * 40 is DELIBERATELY left where it was when the sub-125 row was measured down + * to 45 (base 44 after the 4-symbol correction), which means it now binds: + * 44 + CAD_LEVEL_MIN(-8) = 36 is below it, so cadLevelMinEff() narrows the + * offset window to -4..+12 at SF6 and SF7 below BW125. That narrowing is + * intended, and it must not be "fixed" by lowering this constant. + * + * The reason is what the sub-125 row is built on: two LR1110s, on different + * sites in different countries, converged to the SAME absolute detPeak. A base + * anchored by two independent agreeing measurements does not need eight rungs + * of downward travel — it needs to be centred, which it now is. The old -8 + * window was sized for a base that was wrong by seven counts; carrying that + * much headroom onto a corrected base would be carrying the symptom across the + * fix. CAD_SWEEP_MIN is -4, so the dry-run sweep still fits exactly. + * + * Only SF6 and SF7 below BW125 narrow at all. SF5 keeps the full window by one + * count (48 - 8 = 40), and every other cell sits well clear: + * + * sub-125 base (4 sym) SF5 48 SF6 44 SF7 44 SF8 50 ... SF12 60 + * effective min offset -8 -4 -4 -8 -8 + * + * SF7/BW62.5 is of course the default preset, so the one configuration that + * narrows is the one that matters — which is the point, since it is also the + * only one anyone has measured. The upper bound is untouched: the highest base + * is 85 (SF8, BW500) against CAD_LEVEL_MAX +12. + * + * Watch item: if a third, quieter LR1110 site ever rails at -4, that is the + * signal to revisit this — and it is the third data point the sub-125 row wants + * in any case. */ #define LR11XX_CAD_PEAK_MIN 40 #define LR11XX_CAD_PEAK_MAX 100 @@ -1826,8 +2188,28 @@ static int lr11xx_do_cad(struct lr11xx_data *data) .cad_symb_nb = symb_nb, .cad_detect_peak = detect_peak, .cad_detect_min = mc->cad.detection_minimum ? mc->cad.detection_minimum : 10, - .cad_exit_mode = LR11XX_RADIO_CAD_EXIT_MODE_STANDBYRC, - .cad_timeout = 0, + /* Exit mode is per-CAD. The calibration probe arms CAD_RX so a + * positive detection flows straight into Rx on the signal it + * found; the pre-TX LBT keeps STANDBYRC, where the verdict is + * all the caller wants and entering Rx would leave the chip + * somewhere the transmit path does not expect. Detection is + * identical either way -- same symbols, same detPeak, same + * detMin -- so the probe still measures what LBT runs. + * + * cad_timeout bounds the Rx a positive CAD_RX enters, in RTC + * steps at 32768 Hz -- the unit LR11XX_RTC_FREQ_IN_HZ names + * (30.5176 us), not the 31.25 us the header's prose claims, and + * the LR20xx driver already documents that these doc comments + * are not reliable. Max-length-packet airtime is the right + * bound: anything shorter would cut off the packet the detection + * was for. See lr11xx_cad_rx_timeout_steps() for why the + * vendor's millisecond wrapper is not used to convert it. */ + .cad_exit_mode = data->cad_exit_rx + ? LR11XX_RADIO_CAD_EXIT_MODE_RX + : LR11XX_RADIO_CAD_EXIT_MODE_STANDBYRC, + .cad_timeout = data->cad_exit_rx + ? lr11xx_cad_rx_timeout_steps(data) + : 0, }; lr11xx_radio_set_cad_params(ctx, &cad); @@ -1947,14 +2329,49 @@ int lr11xx_cad_probe(const struct device *dev, int8_t peak_offset) } /* One-shot absolute override consumed by lr11xx_do_cad(). Probes and - * LBT both run on the mesh loop thread, so no concurrent CAD exists. */ + * LBT both run on the mesh loop thread, so no concurrent CAD exists -- + * which is also what makes cad_exit_rx safe as a plain flag. */ data->cad_probe_peak = (uint8_t)peak; + data->cad_exit_rx = true; + atomic_set(&data->cad_rx_state, LR11XX_CAD_RX_IDLE); ret = lr11xx_lora_cad(dev, K_MSEC(lr11xx_cad_timeout_ms(data))); + data->cad_exit_rx = false; data->cad_probe_peak = 0; + /* 2 rather than 1 tells the caller the chip is in Rx on the signal it + * detected, so it must NOT re-enter Rx itself, and that an outcome will + * be readable from lr11xx_cad_rx_outcome() once the chip resolves it. */ + if (ret > 0) { + return 2; + } + return ret; } +uint32_t lr11xx_cad_rx_timeout_ms(const struct device *dev) +{ + struct lr11xx_data *data = dev->data; + + /* Derived from the exact step count do_cad() programs, so the caller's + * wait and the chip's deadline cannot drift apart -- including when the + * 24-bit saturation in lr11xx_cad_rx_timeout_steps() shortens it. */ + return (uint32_t)(((uint64_t)lr11xx_cad_rx_timeout_steps(data) * 1000U) + / 32768U); +} + +int lr11xx_cad_rx_outcome(const struct device *dev) +{ + struct lr11xx_data *data = dev->data; + atomic_val_t st = atomic_get(&data->cad_rx_state); + + if (st != LR11XX_CAD_RX_PACKET && st != LR11XX_CAD_RX_TMOUT) { + return 0; /* nothing armed, or still awaiting the terminal IRQ */ + } + + atomic_set(&data->cad_rx_state, LR11XX_CAD_RX_IDLE); + return (st == LR11XX_CAD_RX_PACKET) ? 1 : 2; +} + /* ── Deferred hardware init ─────────────────────────────────────────── */ static int lr11xx_hw_init(struct lr11xx_data *data, diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h index f24985f..4f0387f 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h @@ -29,6 +29,17 @@ extern "C" { */ int16_t lr11xx_get_rssi_inst(const struct device *dev); +/* Read n RSSI samples spaced spacing_us apart, bracketing the duty-cycle + * stand-down ONCE for the whole burst instead of once per sample. Returns the + * number of valid samples written; fewer than n means the read was refused + * partway and the caller should discard the burst. A NEGATIVE return (-EAGAIN) + * means discard for a different reason: the receiver detected a preamble or + * header inside the window, so the samples describe that signal rather than the + * noise floor. The two are worth distinguishing -- one indicts the sampler, + * the other reports the channel. */ +int lr11xx_get_rssi_burst(const struct device *dev, int16_t *out, int n, + uint32_t spacing_us); + /** * @brief Check if radio is actively receiving a packet * @@ -165,8 +176,25 @@ uint8_t lr11xx_cad_peak_max(void); * @param peak_offset Signed offset from the base table value * @return 1 = activity detected, 0 = channel free, <0 = error */ +/* Armed with the CAD_RX exit mode: a NEGATIVE CAD exits to standby and the + * caller must restart Rx, a POSITIVE one leaves the chip in Rx on the signal it + * detected and the caller must not. + * Returns 2 = detected, chip in Rx (see lr11xx_cad_rx_outcome()); + * 0 = channel free, chip in standby; <0 = error. */ int lr11xx_cad_probe(const struct device *dev, int8_t peak_offset); +/* Outcome of the Rx a positive lr11xx_cad_probe() entered. The chip always + * resolves it with a terminal interrupt, so this reports an observed event + * rather than an inference. Call once after the cad_timeout deadline; it + * consumes the result. + * Returns 1 = a packet arrived (detection was real); + * 2 = cad_timeout expired with nothing decoded; + * 0 = nothing armed, or the terminal interrupt has not arrived yet. */ +int lr11xx_cad_rx_outcome(const struct device *dev); + +/* The cad_timeout a CAD_RX probe programs, in milliseconds. */ +uint32_t lr11xx_cad_rx_timeout_ms(const struct device *dev); + #ifdef __cplusplus } #endif diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c index dcff79c..a0c20d3 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -161,11 +161,17 @@ struct lr20xx_data { int dio1_stuck_count; bool tcxo_disabled; /* set when the TCXO fallback has already fired */ - /* RX-busy tracking for lr20xx_is_receiving(). PREAMBLE_DETECTED and - * SYNC_WORD_HEADER_VALID are latched (DS 5.7), kept off DIO1, and read - * non-destructively — so continuous RX, which has no timeout, needs a - * software release or a noise preamble pins the TX gate forever. - * All accesses under spi_mutex. */ + /* RX-busy tracking for lr20xx_is_receiving(). Both bits are latched + * (DS 5.7) and read non-destructively — continuous RX has no timeout, so + * each needs a software release or a noise preamble pins the TX gate + * forever. All writes under spi_mutex. + * + * PREAMBLE_DETECTED is kept off DIO1 (it fires on noise) and is + * poll-only. SYNC_WORD_HEADER_VALID is routed to DIO1 and stamps + * header_seen_at_ms from the work handler: with a duty cycle armed + * is_receiving() may not touch the bus, so that timestamp is the ONLY + * thing it can answer from, and while nothing wrote it the RX-busy gate + * was dead on every duty-cycled node. */ uint32_t preamble_seen_at_ms; uint32_t header_seen_at_ms; @@ -965,14 +971,30 @@ static void lr20xx_apply_modem_config(struct lr20xx_data *data, (half_power & 1) ? 5 : 0, rc); } - /* Only the events the DIO1 handler actually acts on. PREAMBLE_DETECTED - * and SYNC_WORD_HEADER_VALID are deliberately NOT here: they fire on - * noise, and an unhandled DIO1 assertion drives the safety path, which - * restarts RX and destroys the very packet that was arriving. They stay - * readable in the IRQ register for lr20xx_is_receiving(), which is the - * same split the SX126x driver uses for its RX-busy gate. */ + /* The events the DIO1 handler acts on, plus SYNC_WORD_HEADER_VALID. + * + * PREAMBLE_DETECTED is deliberately NOT here: it fires on noise, and an + * unhandled DIO1 assertion drives the safety path, which restarts RX and + * destroys the very packet that was arriving. It stays readable in the + * IRQ register for lr20xx_is_receiving()'s grace window. + * + * SYNC_WORD_HEADER_VALID used to sit alongside it, which was wrong on + * both counts. A header only latches after a sync-word match AND a + * header CRC, so it fires at most once per real packet, not on noise; + * and the safety-path objection is answered by handling it rather than + * by hiding it (the handler now has a branch that marks the reception as + * continuing). Leaving it off DIO1 meant nothing ever stamped + * header_seen_at_ms, and that timestamp is the entire RX-busy answer + * once a duty cycle is armed -- the poll path is forbidden to touch the + * bus there -- so lr20xx_is_receiving() returned false unconditionally on + * every duty-cycled node, blinding the TX gate, the noise-floor sampler + * and the CAD probe alike. + * + * This IS the split the SX126x driver uses, which the old comment + * claimed while doing something else: preamble off DIO1, header on. */ rc = lr20xx_system_set_dio_irq_cfg(ctx, lr20xx_irq_dio(cfg), LR20XX_SYSTEM_IRQ_RX_DONE | + LR20XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID | LR20XX_SYSTEM_IRQ_TX_DONE | LR20XX_SYSTEM_IRQ_CAD_DONE | LR20XX_SYSTEM_IRQ_CAD_DETECTED | @@ -1856,6 +1878,35 @@ static void lr20xx_dio1_work_handler(struct k_work *work) return; } + /* ── Header valid ── + * A packet is past sync word and header CRC and is now in its payload + * phase. Stamp the latch lr20xx_is_receiving() answers from; bounded + * there by lr20xx_max_payload_ms(), and cleared by the terminal event's + * lr20xx_reset_rx_busy_signals(). + * + * Gated on no terminal bit in this same pass -- those branches above own + * the outcome and have already reset the signals, so stamping after them + * would re-arm a gate for a packet that is finished. (The header-error + * branch above returns, so it cannot reach here at all.) + * + * rx_restarted, for the same reason the CAD_RX branch sets it: nothing + * needs restarting, the chip is mid-packet, and letting the safety net + * below fire here would destroy the very packet this branch exists to + * protect -- the failure that kept this IRQ off DIO1 in the first + * place. */ + if ((irq & LR20XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID) && + !(irq & (LR20XX_SYSTEM_IRQ_RX_DONE | LR20XX_SYSTEM_IRQ_CRC_ERROR | + LR20XX_SYSTEM_IRQ_TIMEOUT))) { + uint32_t now = k_uptime_get_32(); + + /* 1 as the "set" sentinel if k_uptime is 0 right after boot. */ + data->header_seen_at_ms = (now == 0) ? 1U : now; + /* The latch is the truth source now; a preamble timestamp left + * behind would outlive it and be read as a live grace window. */ + data->preamble_seen_at_ms = 0; + rx_restarted = true; + } + safety_check: /* A zero IRQ word means the edge was already consumed — a duplicate * re-submit, or the restart path cleared it. Nothing failed, so do not @@ -2434,6 +2485,46 @@ static int lr20xx_lora_recv(const struct device *dev, uint8_t *buf, /* ── LR20xx extension API ───────────────────────────────────────────── */ +/* Front-end settle to wait after entering Rx before the first GetRssiInst. + * + * DS Table 13-82 puts the RSSI averaging window at ~936 us*kHz / BW and the + * post-Rx-entry delay at 12-15 of those windows; the C++ sampler will not read + * until 16 have passed (rssi_settle_delay_us(), radio_common.h). An Rx entry + * made HERE has to clear the same bar, or the reading it brackets comes from a + * front end the firmware itself considers unsettled. + * + * The flat 1 ms this replaces clears it from BW20.83 up but not below -- + * BW15.63 wants 1008 us, BW10.42 1504 us, BW7.81 2144 us -- and `set radio` + * accepts bandwidths that low. Floored at 1000 so no preset that works today + * waits less than it did. Deliberately the same 936/16 the C++ side uses: two + * settle models that could disagree would be worse than one that is wrong. */ +static uint32_t lr20xx_rssi_settle_us(struct lr20xx_data *data) +{ + uint32_t bw_khz = (uint32_t)bw_enum_to_khz(data->modem_cfg.bandwidth); + uint32_t settle; + + if (bw_khz == 0) { + return 1000U; + } + settle = ((936U + bw_khz - 1U) / bw_khz) * 16U; + + return settle < 1000U ? 1000U : settle; +} + +/* Sleep the whole millisecond, busy-wait only the remainder. k_sleep() rounds + * up to a tick and the tick period is a board Kconfig, so asking it for a + * sub-millisecond excess is not portable; this keeps the old wait exactly and + * adds the deficit on top. */ +static void lr20xx_rssi_settle(struct lr20xx_data *data) +{ + uint32_t settle_us = lr20xx_rssi_settle_us(data); + + k_sleep(K_MSEC(1)); + if (settle_us > 1000U) { + k_busy_wait(settle_us - 1000U); + } +} + int16_t lr20xx_get_rssi_inst(const struct device *dev) { struct lr20xx_data *data = dev->data; @@ -2453,12 +2544,13 @@ int16_t lr20xx_get_rssi_inst(const struct device *dev) /* GetRssiInst reads a live receiver; the stand-down left the chip * in standby, where there is nothing to measure. Enter * continuous Rx for the reading and hand the cycle back after. - * 1 ms clears the front-end settle at every bandwidth this - * firmware offers, and against a noise-floor interval measured in - * seconds it costs well under a hundredth of a percent of airtime. */ + * lr20xx_rssi_settle() sizes the front-end settle from the + * bandwidth (a flat 1 ms was short of it below BW20.83), and + * against a noise-floor interval measured in seconds it costs + * well under a hundredth of a percent of airtime. */ lr20xx_radio_common_set_rx_with_timeout_in_rtc_step( &data->hal_ctx, 0xFFFFFF); - k_sleep(K_MSEC(1)); + lr20xx_rssi_settle(data); } if (lr20xx_radio_common_get_rssi_inst(&data->hal_ctx, &rssi, @@ -2530,6 +2622,100 @@ static uint32_t lr20xx_max_payload_ms(struct lr20xx_data *data) return ms + (ms / 4U) + 100U; } +int lr20xx_get_rssi_burst(const struct device *dev, int16_t *out, int n, + uint32_t spacing_us) +{ + struct lr20xx_data *data = dev->data; + int got = 0; + + /* One stand-down for the WHOLE burst, not one per sample. + * + * lr20xx_get_rssi_inst() has to suspend the duty cycle, enter continuous + * Rx, wait 1 ms for the front end, read, and then hand the cycle back -- + * and lr20xx_dc_resume() clears every IRQ and zeroes the RX-busy latch, + * because a re-armed cycle must start from a known point. The noise-floor + * sampler wants a median of eight, and calling the single-shot read eight + * times therefore cost eight cycle tear-downs, eight latch wipes and 8 ms + * of settle every sampling interval -- on a receiver whose whole job is to + * be listening. That is the same fault the CAD classifier had, in a + * different function; here it fires whether or not a probe follows. + * + * Bracketing once collapses it to one of each. It also holds the SPI + * mutex for a single short span instead of taking it eight times, and the + * reads are spaced by the caller's averaging window exactly as before, so + * they stay independent. */ + if (k_mutex_lock(&data->spi_mutex, K_NO_WAIT) != 0) { + return 0; + } + + bool armed = lr20xx_dc_suspend(data); + + if (armed) { + lr20xx_radio_common_set_rx_with_timeout_in_rtc_step( + &data->hal_ctx, 0xFFFFFF); + /* Start the window clean so the check after the loop reports + * only what arrived INSIDE it -- these bits are latched (DS 5.7) + * and nothing clears them between duty-cycle re-arms, so a stale + * preamble from earlier in the cycle would otherwise condemn + * every burst. Safe here and nowhere else: the cycle is already + * torn down and dc_resume() clears the lot again on the way out, + * so this destroys no state the poll path could still want. + * CMD_ERROR is deliberately absent -- that is an LR11xx quirk + * with no LR20xx equivalent, as lr20xx_is_receiving() notes. */ + lr20xx_system_clear_irq_status(&data->hal_ctx, + LR20XX_SYSTEM_IRQ_PREAMBLE_DETECTED | + LR20XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID); + lr20xx_rssi_settle(data); + } + + for (int i = 0; i < n; i++) { + int16_t rssi = 0; + uint8_t half_dbm = 0; + + if (i) { + k_busy_wait(spacing_us); + } + if (lr20xx_radio_common_get_rssi_inst(&data->hal_ctx, &rssi, + &half_dbm) != LR20XX_STATUS_OK) { + break; + } + out[i] = rssi; + got++; + } + + /* Did a transmitter turn up inside the window? Then these samples + * measure it, not the floor, and the caller must throw them away. + * + * This covers a gap the caller's own guards cannot. cadMaintenance() + * re-checks isReceiving() immediately before its CAD probe, and that + * now works on this family -- but dc_resume() below zeroes + * header_seen_at_ms on its way out, one call earlier, so a reception + * that began inside this bracket is invisible to the re-check by the + * time it runs. Reporting the contamination from in here, where the + * evidence still exists, closes it: the sampler abandons the burst, + * _sample_fresh stays clear, and the probe -- which rides on that flag + * -- does not run this pass. + * + * Only meaningful with a cycle armed. Without one this function makes + * no Rx entry of its own and clears nothing, so the caller's guards see + * the reception unaided. get_status() clears nothing. */ + if (armed) { + lr20xx_system_irq_mask_t irq = 0; + + if (lr20xx_system_get_status(&data->hal_ctx, NULL, NULL, &irq) == + LR20XX_STATUS_OK && + (irq & (LR20XX_SYSTEM_IRQ_PREAMBLE_DETECTED | + LR20XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID))) { + got = -EAGAIN; + } + } + + lr20xx_dc_resume(data, armed); + k_mutex_unlock(&data->spi_mutex); + + return got; +} + bool lr20xx_is_receiving(const struct device *dev) { struct lr20xx_data *data = dev->data; @@ -2547,22 +2733,41 @@ bool lr20xx_is_receiving(const struct device *dev) * is the truthful answer, not just the safe one. CAD still gates TX, * so the channel check is not lost. Same guard the LR11xx applies. * - * With a cycle armed, answer from the DIO1-driven latch and issue no SPI - * at all. This path cannot bracket itself in suspend/resume the way the - * samplers do — standing the cycle down to ask "am I receiving?" would - * end the very reception being asked about. It does not need to: - * header_seen_at_ms is stamped by the DIO1 work handler and needs no bus - * access to be current. Nor may it fall back to a BUSY read, because - * BUSY is high through ordinary Rx on this family too — a BUSY-high - * "not receiving" is wrong exactly when it matters, mid-packet, with the - * TX gate asking permission to transmit over it. */ - if (data->rx_duty_cycle_enabled) { - uint32_t seen = data->header_seen_at_ms; + * Payload phase, from the latch the DIO1 handler stamps on + * SYNC_WORD_HEADER_VALID. Read before the duty-cycle split because it + * is the one signal both sides share — and the only one the armed side + * has at all. That path cannot bracket itself in suspend/resume the way + * the samplers do (standing the cycle down to ask "am I receiving?" + * would end the very reception being asked about), nor fall back to a + * BUSY read, because BUSY is high through ordinary Rx on this family too + * — a BUSY-high "not receiving" is wrong exactly when it matters, + * mid-packet, with the TX gate asking permission to transmit over it. + * + * That leaves the latch, which is why the header IRQ is routed to DIO1 + * (see the set_dio_irq_cfg call): the handler needs no bus access to keep + * this current, and until it did the armed side had nothing to read. */ + uint32_t hdr_seen = data->header_seen_at_ms; - if (seen == 0) { - return false; + if (hdr_seen != 0 && + (k_uptime_get_32() - hdr_seen) < lr20xx_max_payload_ms(data)) { + return true; + } + + if (data->rx_duty_cycle_enabled) { + /* Never stamped, or the payload deadline has blown. No bus + * access is permitted here to look any further, and none is + * wanted: the latch is the whole answer on this side. Drop a + * stale one so the compare above stops repeating, but only if + * the mutex is free — the DIO1 handler owns this field, and if + * it is mid-update it will maintain the field itself. */ + if (hdr_seen != 0 && + k_mutex_lock(&data->spi_mutex, K_NO_WAIT) == 0) { + LOG_WRN("RX header latched %u ms with no packet, releasing TX gate", + k_uptime_get_32() - hdr_seen); + data->header_seen_at_ms = 0; + k_mutex_unlock(&data->spi_mutex); } - return (k_uptime_get_32() - seen) < lr20xx_max_payload_ms(data); + return false; } /* Use non-destructive get_status to check for preamble/header @@ -2584,19 +2789,20 @@ bool lr20xx_is_receiving(const struct device *dev) * never completes would otherwise mute TX until reboot. */ if (irq & LR20XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID) { uint32_t now = k_uptime_get_32(); - uint32_t seen = data->header_seen_at_ms; - if (seen == 0) { + /* The DIO1 handler normally stamps this before any poll sees + * the bit; the poll can still get there first in the window + * before the work item runs, so it stays able to stamp. A latch + * still inside its deadline already returned true at the top of + * the function, so reaching here with one set means the deadline + * is blown and the gate has to be released. */ + if (data->header_seen_at_ms == 0) { data->header_seen_at_ms = (now == 0) ? 1U : now; k_mutex_unlock(&data->spi_mutex); return true; } - if ((now - seen) < lr20xx_max_payload_ms(data)) { - k_mutex_unlock(&data->spi_mutex); - return true; - } LOG_WRN("RX header latched %u ms with no packet, releasing TX gate", - now - seen); + now - data->header_seen_at_ms); lr20xx_system_clear_irq_status(&data->hal_ctx, LR20XX_SYSTEM_IRQ_PREAMBLE_DETECTED | LR20XX_SYSTEM_IRQ_SYNC_WORD_HEADER_VALID | diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h index 8aa36a1..b5fc867 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h @@ -26,6 +26,17 @@ extern "C" { */ int16_t lr20xx_get_rssi_inst(const struct device *dev); +/* Read n RSSI samples spaced spacing_us apart, bracketing the duty-cycle + * stand-down ONCE for the whole burst instead of once per sample. Returns the + * number of valid samples written; fewer than n means the read was refused + * partway and the caller should discard the burst. A NEGATIVE return (-EAGAIN) + * means discard for a different reason: the receiver detected a preamble or + * header inside the window, so the samples describe that signal rather than the + * noise floor. The two are worth distinguishing -- one indicts the sampler, + * the other reports the channel. */ +int lr20xx_get_rssi_burst(const struct device *dev, int16_t *out, int n, + uint32_t spacing_us); + /** * @brief Running carrier-frequency-error statistics * diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h index ee19567..8fde9eb 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h +++ b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h @@ -189,16 +189,45 @@ uint8_t sx126x_cad_peak_max(void); /** * @brief Run one blocking calibration CAD at base detPeak + peak_offset * - * Uses the operating modem config (SF/BW/symbol count). Leaves the chip - * in STANDBY — the caller must restart RX afterwards. Must be called - * from the mesh loop thread only (same thread as the LBT CAD). + * Uses the operating modem config (SF/BW/symbol count). Armed with the + * CAD_RX exit mode, so the two verdicts leave the chip in different places: + * a NEGATIVE CAD exits to standby and the caller must restart RX, while a + * POSITIVE one leaves the chip in Rx on the signal it detected and the caller + * must NOT. The return value distinguishes them. Must be called from the + * mesh loop thread only (same thread as the LBT CAD). * * @param dev LoRa device * @param peak_offset Signed offset from the base table value - * @return 1 = activity detected, 0 = channel free, <0 = error + * @return 2 = activity detected, chip left in Rx (see sx126x_cad_rx_outcome()) + * 0 = channel free, chip in standby, caller restarts RX + * <0 = error */ int sx126x_cad_probe(const struct device *dev, int8_t peak_offset); +/** + * @brief Outcome of the Rx a positive sx126x_cad_probe() entered + * + * The chip always resolves that Rx with a terminal interrupt -- a packet, or + * RX_TX_TIMEOUT at cadTimeout -- so this reports an observed event rather than + * an inference, and never has to be polled in a loop. Call it once after the + * cadTimeout deadline has passed; it consumes the result. + * + * @param dev LoRa device + * @return 1 = a packet completed (RX_DONE or CRC_ERR): the detection was real + * 2 = cadTimeout expired with nothing decoded: no packet followed + * 0 = nothing armed, or the terminal interrupt has not arrived yet + */ +int sx126x_cad_rx_outcome(const struct device *dev); + +/** + * @brief The cadTimeout a CAD_RX probe programs, in milliseconds + * + * Max-length-packet airtime at the current SF/BW. Exposed so a caller waiting + * to read sx126x_cad_rx_outcome() uses the chip's own deadline rather than a + * guess of its own. + */ +uint32_t sx126x_cad_rx_timeout_ms(const struct device *dev); + #ifdef __cplusplus } #endif diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index f9d75df..bfeb033 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -486,14 +486,38 @@ BW500 column SF+13 is ten counts too sensitive at SF12, and `set bw` accepts octave and drove field units onto the offset rail. Below BW125 Semtech declines to tabulate, and BW62.5 is our default preset, so -that gap is where we normally operate. SF+13 is kept there, and it is not a -guess: it sits ~1 count below Semtech's BW125 column at every SF, which is the -correct direction for a narrower bandwidth, and a field SX1262 at SF7/BW62.5 -converged to detPeak 19 against this table's 20 -- one rung of adaptive -correction. SF+13 is also already a 4-symbol figure (RadioLib programs it with -CAD_ON_4_SYMB citing DS rev 1.1 p.92), so the symbol correction must not be -applied to it a second time. Net effect: no behaviour change at the default -preset, and a real table where there was none at BW250/500. +that gap is where we normally operate. There we take the BW125 column minus a +constant 1 and let the shared symbol correction apply normally, which happens +to reproduce the old SF+13 heuristic exactly at SF5, SF6, SF7, SF9, SF10 and +SF12, and to differ from it by a single count at SF8 and SF11. + +Why not simply keep SF+13, given it was measured right at SF7? Because SF+13 +has a uniform +1 slope and Semtech's measured row does not: + + SF 5 6 7 8 9 10 11 12 + Semtech 20 21 22 24 24 25 27 27 steps +1 +1 +2 0 +1 +2 0 + SF+13 18 19 20 21 22 23 24 25 steps +1 +1 +1 +1 +1 +1 +1 + +detPeak is empirical PER data; there is no reason for it to be linear in SF, +and we adopt Semtech's shape for BW125, BW250 and BW500 in this very function. +Keeping SF+13 below BW125 would mean the driver used two different SF shapes +depending on bandwidth -- but bandwidth shifts the level, it does not reshape +the curve. The sub-125 constant (-1 here, against -7 on the LR11xx) is that +level shift; Semtech's row is the shape. + +Field agreement: three SX1262s at SF7/BW62.5 -- a house, a roof and a very busy +mountaintop -- converged to 19/19/20 against this row's base of 20, which this +edit does not move. The only SF8 datum we have (that hilltop, offset +2/+3 +against SF+13's 21) points the same way as Semtech's +2 step at SF8. Nothing +points the other way. + +SF+13 was already a 4-symbol figure (RadioLib programs it with CAD_ON_4_SYMB +citing DS rev 1.1 p.92), which is why it was excluded from the symbol +correction. The sub-125 row is a raw table value like every other bracket, so +it takes the correction like every other bracket -- that is the whole point of +removing the special case. Net effect: no behaviour change at the default +preset, a real table where there was none at BW250/500, and a one-count move at +SF8 and SF11 below BW125. The clamp around the adaptive offset moves from 15-40 to SX126X_CAD_PEAK_MIN/ MAX (12-48) and is now exported through sx126x_cad_peak_min/max(). Both halves @@ -553,10 +577,10 @@ Regenerate with: appended to this preamble. diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c -index 30243ba5dc7..16823387b1d 100644 +index 30243ba5dc7..a2c57493caf 100644 --- a/drivers/lora/native/sx126x/sx126x.c +++ b/drivers/lora/native/sx126x/sx126x.c -@@ -6,72 +6,220 @@ +@@ -6,72 +6,236 @@ #include #include #include @@ -722,6 +746,22 @@ index 30243ba5dc7..16823387b1d 100644 + * so the fields never drift out of sync. Called from every RX (re)start + * site, every terminal-event handler (RX_DONE / CRC_ERR / RX_TX_TIMEOUT), + * and on TX-state entry. */ ++/* cad_rx_state values -- see the field comment in sx126x.h. */ ++#define SX126X_CAD_RX_IDLE 0 ++#define SX126X_CAD_RX_ARMED 1 ++#define SX126X_CAD_RX_PACKET 2 ++#define SX126X_CAD_RX_TMOUT 3 ++ ++/* Resolve an in-flight CAD_RX with the terminal event that just arrived. ++ * A no-op unless a CAD_RX is actually armed, so the packet path pays one ++ * atomic compare for it. Returns true when this call is the one that ++ * resolved it, which is how sx126x_handle_irq_timeout() tells the probe's own ++ * cadTimeout apart from a duty-cycle timeout raising the identical IRQ. */ ++static inline bool sx126x_cad_rx_resolve(struct sx126x_data *data, int outcome) ++{ ++ return atomic_cas(&data->cad_rx_state, SX126X_CAD_RX_ARMED, outcome); ++} ++ +static inline void sx126x_reset_rx_busy_signals(struct sx126x_data *data) +{ + data->rx_packet_active = false; @@ -745,7 +785,7 @@ index 30243ba5dc7..16823387b1d 100644 + if (bandwidth_to_hz(data->config.bandwidth, &bw_hz) < 0 || + sf < 5 || sf > 12) { + return 1000; /* safe default ~1 s if config is uninitialised */ - } ++ } + /* (preamble + 8) syms * 2^sf * 1000000 / bw_hz -> us; +999/1000 -> ms */ + uint64_t us = ((uint64_t)(preamble + 8U) << sf) * 1000000ULL / bw_hz; + @@ -790,7 +830,7 @@ index 30243ba5dc7..16823387b1d 100644 + + if (numer > 4U * (uint32_t)sf) { + numer -= 4U * (uint32_t)sf; -+ } + } + uint32_t n_sym = 8U + ((numer + denom - 1U) / denom) * 8U; + + /* n_sym * 2^sf * 1000000 / bw_hz -> us, then +25% and +100 ms margin. */ @@ -801,7 +841,7 @@ index 30243ba5dc7..16823387b1d 100644 } static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth bw, -@@ -81,7 +229,11 @@ static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth +@@ -81,7 +245,11 @@ static bool should_enable_ldro(enum lora_datarate sf, enum lora_signal_bandwidth return true; } @@ -814,7 +854,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Symbol time = 2^SF / BW (in seconds) */ /* 16.38 ms = 16380 us */ /* 2^SF / BW > 0.01638 => 2^SF * 1000000 / BW > 16380 */ -@@ -216,12 +368,43 @@ static int sx126x_set_modulation_params(const struct device *dev, +@@ -216,12 +384,43 @@ static int sx126x_set_modulation_params(const struct device *dev, return sx126x_hal_write_cmd(dev, SX126X_CMD_SET_MODULATION_PARAMS, buf, 4); } @@ -858,7 +898,7 @@ index 30243ba5dc7..16823387b1d 100644 sys_put_be16(preamble_len, &buf[0]); buf[2] = header_type; -@@ -229,7 +412,31 @@ static int sx126x_set_packet_params(const struct device *dev, +@@ -229,7 +428,31 @@ static int sx126x_set_packet_params(const struct device *dev, buf[4] = crc_mode; buf[5] = invert_iq; @@ -891,7 +931,7 @@ index 30243ba5dc7..16823387b1d 100644 } static int sx126x_set_sync_word(const struct device *dev, bool public_network) -@@ -243,10 +450,128 @@ static int sx126x_set_sync_word(const struct device *dev, bool public_network) +@@ -243,10 +466,128 @@ static int sx126x_set_sync_word(const struct device *dev, bool public_network) return sx126x_hal_write_regs(dev, SX126X_REG_LORA_SYNC_WORD_MSB, buf, 2); } @@ -1022,7 +1062,7 @@ index 30243ba5dc7..16823387b1d 100644 return sx126x_hal_write_regs(dev, SX126X_REG_RX_GAIN, &val, 1); } -@@ -296,7 +621,7 @@ static int sx126x_get_packet_status(const struct device *dev, +@@ -296,7 +637,7 @@ static int sx126x_get_packet_status(const struct device *dev, uint8_t buf[3]; int ret; @@ -1031,7 +1071,7 @@ index 30243ba5dc7..16823387b1d 100644 if (ret == 0) { /* RSSI is -value/2 dBm */ *rssi = -((int16_t)buf[0] >> 1); -@@ -307,6 +632,140 @@ static int sx126x_get_packet_status(const struct device *dev, +@@ -307,6 +648,140 @@ static int sx126x_get_packet_status(const struct device *dev, return ret; } @@ -1172,7 +1212,7 @@ index 30243ba5dc7..16823387b1d 100644 static int sx126x_chip_init(const struct device *dev) { const struct sx126x_hal_config *config = dev->config; -@@ -367,6 +826,26 @@ static int sx126x_chip_init(const struct device *dev) +@@ -367,6 +842,26 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -1199,7 +1239,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Set packet type to LoRa */ ret = sx126x_set_packet_type(dev, SX126X_PACKET_TYPE_LORA); if (ret < 0) { -@@ -374,10 +853,23 @@ static int sx126x_chip_init(const struct device *dev) +@@ -374,10 +869,23 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -1226,7 +1266,7 @@ index 30243ba5dc7..16823387b1d 100644 if (ret < 0) { LOG_ERR("Set IRQ params failed: %d", ret); return ret; -@@ -390,6 +882,10 @@ static int sx126x_chip_init(const struct device *dev) +@@ -390,6 +898,10 @@ static int sx126x_chip_init(const struct device *dev) return ret; } @@ -1237,7 +1277,7 @@ index 30243ba5dc7..16823387b1d 100644 LOG_INF("SX126x initialized"); return 0; } -@@ -398,15 +894,44 @@ static void sx126x_dio1_callback(const struct device *dev) +@@ -398,15 +910,44 @@ static void sx126x_dio1_callback(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -1284,7 +1324,7 @@ index 30243ba5dc7..16823387b1d 100644 sx126x_hal_set_rf_switch(dev, enable, tx); } } -@@ -426,6 +951,7 @@ static void sx126x_disconnect_rf_gpios(const struct device *dev) +@@ -426,6 +967,7 @@ static void sx126x_disconnect_rf_gpios(const struct device *dev) sx126x_disconnect_gpio(&config->antenna_enable); sx126x_disconnect_gpio(&config->tx_enable); sx126x_disconnect_gpio(&config->rx_enable); @@ -1292,7 +1332,7 @@ index 30243ba5dc7..16823387b1d 100644 } static int sx126x_reconnect_rf_gpios(const struct device *dev) -@@ -451,10 +977,238 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) +@@ -451,10 +993,238 @@ static int sx126x_reconnect_rf_gpios(const struct device *dev) return ret; } @@ -1531,7 +1571,7 @@ index 30243ba5dc7..16823387b1d 100644 static int sx126x_set_sleep(const struct device *dev) { struct sx126x_data *data = dev->data; -@@ -533,8 +1287,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -533,8 +1303,16 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta struct sx126x_data *data = dev->data; struct sx126x_rx_result result = { 0 }; uint8_t payload_len = 0, offset = 0; @@ -1548,7 +1588,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Get received packet info */ ret = sx126x_get_rx_buffer_status(dev, &payload_len, &offset); if (ret < 0) { -@@ -544,9 +1306,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -544,9 +1322,26 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta /* Get signal quality */ sx126x_get_packet_status(dev, &result.rssi, &result.snr); @@ -1578,7 +1618,7 @@ index 30243ba5dc7..16823387b1d 100644 result.status = -EIO; } else { /* Read payload into shared buffer */ -@@ -564,23 +1343,55 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -564,23 +1359,55 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta } } @@ -1610,17 +1650,17 @@ index 30243ba5dc7..16823387b1d 100644 + * sx126x_retention_debug_check(). Compiles to nothing + * without CONFIG_ZEPHCORE_LORA_RETENTION_DEBUG. */ + sx126x_retention_debug_check(dev, "dc-rx", true); -+ } + } +- /* Restart RX unless the callback stopped reception */ +- if (data->rx_cb != NULL) { +- sx126x_set_rx(dev, 0); + sx126x_restart_rx(dev, data); + + k_mutex_lock(&data->lock, K_FOREVER); + if ((data->rx_cb != rx_cb) || + (atomic_get(&data->rx_cb_gen) != rx_cb_gen)) { + rx_cb = NULL; - } -- /* Restart RX unless the callback stopped reception */ -- if (data->rx_cb != NULL) { -- sx126x_set_rx(dev, 0); ++ } + k_mutex_unlock(&data->lock); + + if (rx_cb != NULL && result.status < 0) { @@ -1647,14 +1687,21 @@ index 30243ba5dc7..16823387b1d 100644 sx126x_set_sleep(dev); k_msgq_put(&data->rx_msgq, &result, K_NO_WAIT); } -@@ -589,8 +1400,83 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta +@@ -589,8 +1416,99 @@ static void sx126x_handle_irq_rx_done(const struct device *dev, uint16_t irq_sta static void sx126x_handle_irq_timeout(const struct device *dev) { struct sx126x_data *data = dev->data; + lora_recv_cb rx_cb = NULL; ++ bool was_cad_rx; LOG_DBG("Timeout"); + ++ /* cadTimeout expired with nothing decoded: the CAD_RX detection had no ++ * packet behind it. Resolved first, before anything below re-arms the ++ * node's normal Rx mode, and the verdict is kept because the duty-cycle ++ * branch must not count this timeout (see there). */ ++ was_cad_rx = sx126x_cad_rx_resolve(data, SX126X_CAD_RX_TMOUT); ++ + /* Terminal event -- clear the RX-busy latch and preamble-grace + * timestamp. Fires for DC preamble false-positive re-arm and any + * future non-DC timeout path. */ @@ -1692,7 +1739,16 @@ index 30243ba5dc7..16823387b1d 100644 + atomic_get(&data->state) != SX126X_STATE_TX) { + int ret; + -+ atomic_inc(&data->dc_timeout_restarts); ++ /* Not when this timeout is the CAD_RX probe's own cadTimeout: ++ * that is a calibration false positive, it raises the identical ++ * IRQ, and counting it would mix one tick per busy probe into a ++ * figure that is supposed to describe duty-cycle preamble ++ * behaviour. The re-arm below still runs -- CAD_RX left the ++ * chip in a plain Rx, so the duty cycle does need handing ++ * back. */ ++ if (!was_cad_rx) { ++ atomic_inc(&data->dc_timeout_restarts); ++ } + ret = sx126x_restart_rx(dev, data); + if (ret < 0) { + LOG_WRN("Duty-cycle restart failed (%d), retry from sleep", ret); @@ -1731,16 +1787,39 @@ index 30243ba5dc7..16823387b1d 100644 sx126x_set_sleep(dev); if (data->tx_async_signal != NULL) { -@@ -633,10 +1519,54 @@ static void sx126x_irq_work_handler(struct k_work *work) - sx126x_handle_irq_rx_done(dev, irq_status); +@@ -630,13 +1548,80 @@ static void sx126x_irq_work_handler(struct k_work *work) } + if (irq_status & SX126X_IRQ_RX_DONE) { ++ /* Before the handler, which re-arms Rx and clears the latches: ++ * a packet completing is the ground truth a CAD_RX was waiting ++ * for. CRC_ERR rides in the same status word and counts too -- ++ * a corrupt packet is still a real transmitter, which is the ++ * question the probe is asking. */ ++ sx126x_cad_rx_resolve(data, SX126X_CAD_RX_PACKET); + sx126x_handle_irq_rx_done(dev, irq_status); + } ++ if ((irq_status & SX126X_IRQ_CRC_ERR) && ++ !(irq_status & SX126X_IRQ_RX_DONE)) { ++ sx126x_cad_rx_resolve(data, SX126X_CAD_RX_PACKET); ++ } ++ + if (irq_status & SX126X_IRQ_CAD_DONE) { + bool detected = (irq_status & SX126X_IRQ_CAD_ACTIVITY_DETECTED) != 0; + + LOG_DBG("CAD done: %s", detected ? "activity" : "free"); + data->cad_active = false; + ++ /* CAD_RX with a positive verdict: the chip did NOT return to ++ * standby, it is in Rx on the signal it found. Arm the outcome ++ * tracker so whichever terminal IRQ follows -- RX_DONE, CRC_ERR ++ * or RX_TX_TIMEOUT at cadTimeout -- records what it was. A ++ * negative CAD_RX exits to STDBY_RC exactly like CAD_ONLY, so ++ * there is nothing to track. */ ++ if (data->cad_exit_rx && detected) { ++ atomic_set(&data->cad_rx_state, SX126X_CAD_RX_ARMED); ++ } ++ + if (data->cad_cb) { + lora_cad_cb cb = data->cad_cb; + void *ud = data->cad_user_data; @@ -1753,8 +1832,11 @@ index 30243ba5dc7..16823387b1d 100644 + k_sem_give(&data->cad_sem); + } + } -+ + if (irq_status & SX126X_IRQ_RX_TX_TIMEOUT) { ++ /* The CAD_RX resolve for this event lives at the top of ++ * sx126x_handle_irq_timeout(), which needs its verdict to keep ++ * the probe's own cadTimeout out of dc_timeout_restarts. */ sx126x_handle_irq_timeout(dev); } @@ -1786,7 +1868,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Re-enable the DIO1 interrupt for the next event (unless sleeping) */ if (atomic_get(&data->state) != SX126X_REST_STATE) { sx126x_hal_dio1_irq_enable(dev); -@@ -648,6 +1578,7 @@ static int sx126x_lora_config(const struct device *dev, +@@ -648,6 +1633,7 @@ static int sx126x_lora_config(const struct device *dev, { struct sx126x_data *data = dev->data; const struct sx126x_hal_config *hal_config = dev->config; @@ -1794,10 +1876,11 @@ index 30243ba5dc7..16823387b1d 100644 bool ldro; int ret; -@@ -679,25 +1610,45 @@ static int sx126x_lora_config(const struct device *dev, +@@ -679,25 +1665,45 @@ static int sx126x_lora_config(const struct device *dev, goto out; } +- /* Configure PA and TX power based on chip variant and frequency */ + /* Install the band's RSSI/AGC calibration (DS Sec 6.1.6) before the + * RX gain write below, which consumes the AgcSensiAdjust this caches. + * Re-run on every config so a runtime frequency change switches bands @@ -1807,7 +1890,7 @@ index 30243ba5dc7..16823387b1d 100644 + goto out; + } + - /* Configure PA and TX power based on chip variant and frequency */ ++ /* Configure PA and TX power based on chip variant and frequency */ ret = sx126x_hal_configure_tx_params(dev, config->tx_power, config->frequency, - SX126X_RAMP_200_US); @@ -1842,7 +1925,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Set sync word */ ret = sx126x_set_sync_word(dev, config->public_network); if (ret < 0) { -@@ -721,6 +1672,10 @@ out: +@@ -721,6 +1727,10 @@ out: return ret; } @@ -1853,7 +1936,7 @@ index 30243ba5dc7..16823387b1d 100644 static int sx126x_lora_send_async(const struct device *dev, uint8_t *data_buf, uint32_t data_len, struct k_poll_signal *async) -@@ -738,11 +1693,27 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -738,11 +1748,27 @@ static int sx126x_lora_send_async(const struct device *dev, return -EINVAL; } @@ -1883,7 +1966,7 @@ index 30243ba5dc7..16823387b1d 100644 k_mutex_lock(&data->lock, K_FOREVER); ret = sx126x_ensure_ready(dev); -@@ -752,6 +1723,59 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -752,6 +1778,59 @@ static int sx126x_lora_send_async(const struct device *dev, return ret; } @@ -1943,7 +2026,7 @@ index 30243ba5dc7..16823387b1d 100644 data->tx_async_signal = async; k_msgq_purge(&data->tx_msgq); -@@ -777,8 +1801,99 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -777,8 +1856,99 @@ static int sx126x_lora_send_async(const struct device *dev, /* Enable antenna and set TX path */ sx126x_set_rf_path(dev, true, true); @@ -2045,7 +2128,7 @@ index 30243ba5dc7..16823387b1d 100644 if (ret < 0) { goto out_error; } -@@ -847,6 +1962,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, +@@ -847,6 +2017,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, } data->rx_cb = NULL; @@ -2054,7 +2137,7 @@ index 30243ba5dc7..16823387b1d 100644 k_msgq_purge(&data->rx_msgq); /* Set packet parameters for variable length reception */ -@@ -918,6 +2035,8 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -918,6 +2090,8 @@ static int sx126x_lora_recv_async(const struct device *dev, /* Stop async reception */ data->rx_cb = NULL; data->rx_cb_user_data = NULL; @@ -2063,7 +2146,7 @@ index 30243ba5dc7..16823387b1d 100644 if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { sx126x_set_standby(dev, SX126X_STANDBY_RC); sx126x_set_sleep(dev); -@@ -932,6 +2051,19 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -932,6 +2106,19 @@ static int sx126x_lora_recv_async(const struct device *dev, return -EINVAL; } @@ -2083,7 +2166,7 @@ index 30243ba5dc7..16823387b1d 100644 if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { LOG_ERR("Busy"); k_mutex_unlock(&data->lock); -@@ -945,8 +2077,18 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -945,8 +2132,18 @@ static int sx126x_lora_recv_async(const struct device *dev, return ret; } @@ -2102,7 +2185,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Set packet parameters */ ret = sx126x_set_packet_params(dev, -@@ -959,6 +2101,7 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -959,6 +2156,7 @@ static int sx126x_lora_recv_async(const struct device *dev, SX126X_LORA_IQ_INVERTED : SX126X_LORA_IQ_STANDARD); if (ret < 0) { data->rx_cb = NULL; @@ -2110,7 +2193,7 @@ index 30243ba5dc7..16823387b1d 100644 sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); return ret; -@@ -971,11 +2114,21 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -971,11 +2169,21 @@ static int sx126x_lora_recv_async(const struct device *dev, ret = sx126x_set_rx(dev, 0); if (ret < 0) { data->rx_cb = NULL; @@ -2132,7 +2215,7 @@ index 30243ba5dc7..16823387b1d 100644 k_mutex_unlock(&data->lock); return 0; } -@@ -993,7 +2146,9 @@ static uint32_t sx126x_lora_airtime(const struct device *dev, uint32_t data_len) +@@ -993,7 +2201,9 @@ static uint32_t sx126x_lora_airtime(const struct device *dev, uint32_t data_len) } /* Calculate symbol time in microseconds */ @@ -2143,7 +2226,7 @@ index 30243ba5dc7..16823387b1d 100644 sf = data->config.datarate; /* Symbol time = 2^SF / BW (seconds) */ -@@ -1051,7 +2206,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1051,7 +2261,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, /* Set PA config and TX power */ ret = sx126x_hal_configure_tx_params(dev, tx_power, frequency, @@ -2152,7 +2235,7 @@ index 30243ba5dc7..16823387b1d 100644 if (ret < 0) { sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); -@@ -1083,14 +2238,983 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1083,14 +2293,1067 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, return 0; } @@ -2561,14 +2644,37 @@ index 30243ba5dc7..16823387b1d 100644 + * wide-band preset, and `set bw` accepts 250 and 500. + * + * Below BW125 Semtech returns RAL_STATUS_UNKNOWN_VALUE. Our default preset is -+ * BW62.5, so that gap is where we normally live, and there we keep SF+13 — it -+ * is not a guess: it sits ~1 count below Semtech's BW125 column at every SF, -+ * which is the correct direction for a narrower bandwidth, and it is confirmed -+ * on hardware. A field SX1262 at SF7/BW62.5 converged to detPeak 19 against -+ * this table's 20, i.e. the adaptive controller moved it a single rung. Note -+ * SF+13 is already a 4-symbol figure (RadioLib programs it with CAD_ON_4_SYMB, -+ * citing DS rev 1.1 p.92), so the symbol correction below must NOT be applied -+ * to it a second time. */ ++ * BW62.5, so that gap is where we normally live. There we use the BW125 column ++ * minus a constant 1, and then let the shared symbol correction below apply ++ * normally — which happens to reproduce the old SF+13 heuristic exactly at SF5, ++ * SF6, SF7, SF9, SF10 and SF12, and differ from it by a single count at SF8 and ++ * SF11. ++ * ++ * Why not just keep SF+13, given it was measured right at SF7? Because SF+13 ++ * has a uniform +1 slope and Semtech's measured row does not: ++ * ++ * SF 5 6 7 8 9 10 11 12 ++ * Semtech 20 21 22 24 24 25 27 27 steps +1 +1 +2 0 +1 +2 0 ++ * SF+13 18 19 20 21 22 23 24 25 steps +1 +1 +1 +1 +1 +1 +1 ++ * ++ * detPeak is empirical PER data; there is no reason for it to be linear in SF, ++ * and we already adopted Semtech's shape for BW125, BW250 and BW500 in this ++ * very function. Keeping SF+13 below BW125 would mean the driver used two ++ * different SF shapes depending on bandwidth — but bandwidth shifts the level, ++ * it does not reshape the curve. The sub-125 constant (-1 here, against -7 on ++ * the LR11xx) is the level shift; Semtech's row is the shape. ++ * ++ * Field agreement: three SX1262s at SF7/BW62.5 — a house, a roof and a very busy ++ * mountaintop — converged to 19/19/20 against this row's base of 20, unchanged ++ * by this edit. The only SF8 datum we have (that hilltop, offset +2/+3 against ++ * SF+13's 21) points the same way as Semtech's +2 step at SF8. Nothing points ++ * the other way. ++ * ++ * Note SF+13 was already a 4-symbol figure (RadioLib programs it with ++ * CAD_ON_4_SYMB, citing DS rev 1.1 p.92), which is why it was excluded from the ++ * symbol correction. The row below is a raw table value like every other ++ * bracket, so it takes the correction like every other bracket — that is the ++ * whole point of removing the special case. */ +static uint8_t sx126x_cad_detect_peak(uint8_t sf, uint32_t bw_hz, uint8_t symb_nb) +{ + /* SF5 SF6 SF7 SF8 SF9 SF10 SF11 SF12 */ @@ -2582,14 +2688,16 @@ index 30243ba5dc7..16823387b1d 100644 + sf = 12; + } + -+ /* Narrower than BW125: Semtech offers nothing, SF+13 is measured. */ -+ if (bw_hz < 125000U) { -+ return sf + 13; -+ } -+ + /* Semtech brackets only >=500 and >=125 on this family — BW250 shares -+ * the BW125 column, unlike the LR11xx which gives it its own. */ -+ peak = (int)(bw_hz >= 500000U ? bw500[sf - 5] : bw125[sf - 5]); ++ * the BW125 column, unlike the LR11xx which gives it its own. Below ++ * BW125 they publish nothing; see the note above for the -1. */ ++ if (bw_hz >= 500000U) { ++ peak = (int)bw500[sf - 5]; ++ } else if (bw_hz >= 125000U) { ++ peak = (int)bw125[sf - 5]; ++ } else { ++ peak = (int)bw125[sf - 5] - 1; ++ } + + /* More symbols means more looks at the same correlation, so the same + * detection quality is reached at a lower threshold. We run 4 symbols @@ -2634,8 +2742,10 @@ index 30243ba5dc7..16823387b1d 100644 + * that disagreed with the peak actually programmed would make every rung the + * C++ staircase reasons about a lie. + * -+ * An unresolvable bandwidth falls through to the sub-125 branch, i.e. SF+13, -+ * which is what this driver did unconditionally before the table existed. */ ++ * An unresolvable bandwidth (bw_hz forced to 0) falls through to the sub-125 ++ * branch, the most sensitive of the three — the safe direction for an unknown ++ * configuration, since too sensitive costs airtime while too insensitive costs ++ * collisions. */ +static uint8_t sx126x_cad_base_peak_cfg(struct lora_modem_config *mc) +{ + uint32_t bw_hz; @@ -2710,16 +2820,31 @@ index 30243ba5dc7..16823387b1d 100644 + } + + /* SetCadParams: symb_nb, detect_peak, detect_min, exit_mode, -+ * timeout (3 bytes, unused for STANDBYRC exit) */ ++ * timeout (3 bytes, used only by CAD_RX). ++ * ++ * Exit mode is per-CAD, not global. The calibration probe arms CAD_RX ++ * (DS §13.4.7 0x01) so a positive detection flows straight into Rx on ++ * the signal it found; the pre-TX LBT keeps CAD_ONLY, because there the ++ * verdict is all the caller wants and entering Rx would put the chip ++ * somewhere the transmit path does not expect. Detection itself is ++ * identical either way -- same symbol count, same detPeak, same detMin -- ++ * so the probe still measures exactly the configuration LBT runs. ++ * ++ * The timeout bounds the Rx that a positive CAD_RX enters, in 15.625 us ++ * steps (DS §13.1.5's unit, the same one SX126X_MS_TO_TIMEOUT converts ++ * to). Max-length-packet airtime at the current SF/BW is the right ++ * bound: shorter would cut off the very packet the detection was for. ++ * It is a chip-side deadline that always raises RX_TX_TIMEOUT, which is ++ * what lets the outcome be *observed* rather than polled for. */ + uint8_t buf[7]; ++ uint32_t cad_timeout = data->cad_exit_rx ++ ? SX126X_MS_TO_TIMEOUT(sx126x_max_payload_ms(data)) : 0U; + + buf[0] = symb_nb; + buf[1] = detect_peak; + buf[2] = mc->cad.detection_minimum ? mc->cad.detection_minimum : 10; -+ buf[3] = 0x00; /* CAD_ONLY (exit to STANDBYRC) */ -+ buf[4] = 0; /* timeout[23:16] */ -+ buf[5] = 0; /* timeout[15:8] */ -+ buf[6] = 0; /* timeout[7:0] */ ++ buf[3] = data->cad_exit_rx ? 0x01 : 0x00; /* CAD_RX : CAD_ONLY */ ++ sys_put_be24(cad_timeout & 0xFFFFFFU, &buf[4]); + + ret = sx126x_hal_write_cmd(dev, SX126X_CMD_SET_CAD_PARAMS, buf, 7); + if (ret < 0) { @@ -2795,7 +2920,16 @@ index 30243ba5dc7..16823387b1d 100644 + return ret; + } + -+ atomic_set(&data->state, SX126X_REST_STATE); ++ /* A positive CAD_RX left the chip in Rx. Say so, rather than reporting ++ * REST_STATE and letting every later CAS reason about a chip that is ++ * somewhere else: the RX_DONE and timeout handlers both gate on state, ++ * and sx126x_lora_send_async's RX -> TX entry has to abort a real Rx ++ * rather than assume standby. */ ++ if (data->cad_result > 0 && data->cad_exit_rx) { ++ atomic_set(&data->state, SX126X_STATE_RX); ++ } else { ++ atomic_set(&data->state, SX126X_REST_STATE); ++ } + return data->cad_result; +} + @@ -2874,14 +3008,47 @@ index 30243ba5dc7..16823387b1d 100644 + } + + /* One-shot absolute override consumed by sx126x_do_cad(). Probes and -+ * LBT both run on the mesh loop thread, so no concurrent CAD exists. */ ++ * LBT both run on the mesh loop thread, so no concurrent CAD exists -- ++ * which is also what makes cad_exit_rx safe as a plain flag. */ + data->cad_probe_peak = (uint8_t)peak; ++ data->cad_exit_rx = true; ++ atomic_set(&data->cad_rx_state, SX126X_CAD_RX_IDLE); + ret = sx126x_lora_cad(dev, K_MSEC(sx126x_cad_timeout_ms(data))); ++ data->cad_exit_rx = false; + data->cad_probe_peak = 0; + ++ /* 2 rather than 1 tells the caller the chip is in Rx on the signal it ++ * detected, so it must NOT re-enter Rx itself, and that an outcome will ++ * be readable from sx126x_cad_rx_outcome() once the chip resolves it. */ ++ if (ret > 0) { ++ return 2; ++ } ++ + return ret; +} + ++uint32_t sx126x_cad_rx_timeout_ms(const struct device *dev) ++{ ++ struct sx126x_data *data = dev->data; ++ ++ /* The same figure do_cad() programs into cadTimeout, so the caller's ++ * wait and the chip's deadline cannot drift apart. */ ++ return sx126x_max_payload_ms(data); ++} ++ ++int sx126x_cad_rx_outcome(const struct device *dev) ++{ ++ struct sx126x_data *data = dev->data; ++ atomic_val_t st = atomic_get(&data->cad_rx_state); ++ ++ if (st != SX126X_CAD_RX_PACKET && st != SX126X_CAD_RX_TMOUT) { ++ return 0; /* nothing armed, or still awaiting the terminal IRQ */ ++ } ++ ++ atomic_set(&data->cad_rx_state, SX126X_CAD_RX_IDLE); ++ return (st == SX126X_CAD_RX_PACKET) ? 1 : 2; ++} ++ +/* -- Driver API: recv_duty_cycle -- */ + +static int sx126x_lora_recv_duty_cycle(const struct device *dev, @@ -3143,7 +3310,7 @@ index 30243ba5dc7..16823387b1d 100644 }; #ifdef CONFIG_PM_DEVICE -@@ -1112,6 +3236,7 @@ static int sx126x_pm_action(const struct device *dev, +@@ -1112,6 +3375,7 @@ static int sx126x_pm_action(const struct device *dev, static int sx126x_init(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -3151,7 +3318,7 @@ index 30243ba5dc7..16823387b1d 100644 int ret; /* Initialize data structures */ -@@ -1121,9 +3246,33 @@ static int sx126x_init(const struct device *dev) +@@ -1121,9 +3385,33 @@ static int sx126x_init(const struct device *dev) k_msgq_init(&data->rx_msgq, (char *)&data->rx_result, sizeof(struct sx126x_rx_result), 1); k_work_init(&data->irq_work, sx126x_irq_work_handler); @@ -3185,7 +3352,7 @@ index 30243ba5dc7..16823387b1d 100644 /* Initialize HAL */ ret = sx126x_hal_init(dev); -@@ -1185,6 +3334,8 @@ static int sx126x_init(const struct device *dev) +@@ -1185,6 +3473,8 @@ static int sx126x_init(const struct device *dev) {0}), \ .rx_enable = GPIO_DT_SPEC_INST_GET_OR(inst, rx_enable_gpios, \ {0}), \ @@ -3194,7 +3361,7 @@ index 30243ba5dc7..16823387b1d 100644 .dio2_tx_enable = DT_INST_PROP(inst, dio2_tx_enable), \ .dio3_tcxo_enable = DT_INST_NODE_HAS_PROP(inst, dio3_tcxo_voltage), \ .dio3_tcxo_voltage = DT_INST_PROP_OR(inst, dio3_tcxo_voltage, 0), \ -@@ -1239,6 +3390,8 @@ DT_INST_FOREACH_STATUS_OKAY_VARGS(SX126X_INIT, true) +@@ -1239,6 +3529,8 @@ DT_INST_FOREACH_STATUS_OKAY_VARGS(SX126X_INIT, true) {0}), \ .rx_enable = GPIO_DT_SPEC_INST_GET_OR(inst, rx_enable_gpios, \ {0}), \ @@ -3204,10 +3371,10 @@ index 30243ba5dc7..16823387b1d 100644 .dio3_tcxo_enable = DT_INST_NODE_HAS_PROP(inst, dio3_tcxo_voltage), \ .dio3_tcxo_voltage = DT_INST_PROP_OR(inst, dio3_tcxo_voltage, 0), \ diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h -index 9dbf3f26586..51fb808bb46 100644 +index 9dbf3f26586..1d528604d1c 100644 --- a/drivers/lora/native/sx126x/sx126x.h +++ b/drivers/lora/native/sx126x/sx126x.h -@@ -56,13 +56,121 @@ struct sx126x_data { +@@ -56,13 +56,143 @@ struct sx126x_data { /* Async RX callback */ lora_recv_cb rx_cb; void *rx_cb_user_data; @@ -3324,6 +3491,28 @@ index 9dbf3f26586..51fb808bb46 100644 + /* Adaptive-CAD: signed offset applied to the per-SF base detPeak on + * every LBT CAD (set via sx126x_cad_set_peak_offset). cad_probe_peak, + * when non-zero, overrides everything for one calibration probe. */ ++ /* CAD_RX exit-mode bookkeeping. ++ * ++ * With cadExitMode = CAD_RX (DS §13.4.7) a positive CAD does not return ++ * to standby: the chip stays in Rx on the signal it just found, until a ++ * packet completes or cadTimeout expires. That removes the ++ * standby->SetRx round trip that otherwise eats the preamble which ++ * triggered the detection, so the calibration probe can be told whether ++ * what it heard was real instead of having to guess from a poll. ++ * ++ * cad_exit_rx marks the CAD currently in flight as one armed that way, ++ * so the CAD_DONE handler knows the chip is in Rx rather than standby. ++ * cad_rx_state then records which terminal event resolved it -- the ++ * whole point being that the chip always raises one, so nothing has to ++ * poll or infer: ++ * IDLE nothing in flight ++ * ARMED positive CAD, chip in Rx, awaiting a terminal IRQ ++ * PACKET RX_DONE or CRC_ERR arrived -- the detection was real ++ * TMOUT RX_TX_TIMEOUT at cadTimeout -- nothing decodable followed ++ * Read and consumed by sx126x_cad_rx_outcome(). */ ++ bool cad_exit_rx; ++ atomic_t cad_rx_state; ++ + int8_t cad_peak_offset; + uint8_t cad_probe_peak; }; diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 1752c0e..127bc86 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -780,7 +780,10 @@ public: lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, companion_mesh.prefs.cad_offset, companion_mesh.prefs.probe_interval, - companion_mesh.prefs.cad_busycap); + companion_mesh.prefs.cad_busycap, + companion_mesh.prefs.cad_base); + companion_mesh.prefs.cad_offset = lora_radio.getCadOffset(); + companion_mesh.prefs.cad_base = lora_radio.cadBasePeak(); } void resetCadStats() override { lora_radio.resetCadStats(); @@ -1626,7 +1629,18 @@ int main(void) lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, companion_mesh.prefs.cad_offset, companion_mesh.prefs.probe_interval, - companion_mesh.prefs.cad_busycap); + companion_mesh.prefs.cad_busycap, + companion_mesh.prefs.cad_base); + /* Write back the (offset, base) actually in force. setCadParams() may + * have re-anchored the offset across a base-table change, and the pair + * has to be persisted for the NEXT upgrade to have an anchor of its own. + * Guarded so a node whose base has not moved never writes flash at boot. */ + if (companion_mesh.prefs.cad_base != lora_radio.cadBasePeak() || + companion_mesh.prefs.cad_offset != lora_radio.getCadOffset()) { + companion_mesh.prefs.cad_offset = lora_radio.getCadOffset(); + companion_mesh.prefs.cad_base = lora_radio.cadBasePeak(); + data_store.savePrefs(companion_mesh.prefs); + } /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a * zero-terminated list; a rejected set (SF/BW changed since it was diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index eab8890..d87c733 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -786,7 +786,18 @@ int main(void) lora_radio.setFemRxEnable(prefs->fem_rxgain != 0); lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, - prefs->probe_interval, prefs->cad_busycap); + prefs->probe_interval, prefs->cad_busycap, + prefs->cad_base); + /* Write back the (offset, base) actually in force. setCadParams() may + * have re-anchored the offset across a base-table change, and the pair + * has to be persisted for the NEXT upgrade to have an anchor of its own. + * Guarded so a node whose base has not moved never writes flash at boot. */ + if (prefs->cad_base != lora_radio.cadBasePeak() || + prefs->cad_offset != lora_radio.getCadOffset()) { + prefs->cad_offset = lora_radio.getCadOffset(); + prefs->cad_base = lora_radio.cadBasePeak(); + repeater_mesh.savePrefs(); + } /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a * zero-terminated list; a rejected set (SF/BW changed since it was diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index ec65810..ba037fc 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -671,7 +671,18 @@ int main(void) lora_radio.setFemRxEnable(prefs->fem_rxgain != 0); lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, - prefs->probe_interval, prefs->cad_busycap); + prefs->probe_interval, prefs->cad_busycap, + prefs->cad_base); + /* Write back the (offset, base) actually in force. setCadParams() may + * have re-anchored the offset across a base-table change, and the pair + * has to be persisted for the NEXT upgrade to have an anchor of its own. + * Guarded so a node whose base has not moved never writes flash at boot. */ + if (prefs->cad_base != lora_radio.cadBasePeak() || + prefs->cad_offset != lora_radio.getCadOffset()) { + prefs->cad_offset = lora_radio.getCadOffset(); + prefs->cad_base = lora_radio.cadBasePeak(); + room_mesh.savePrefs(); + } /* LR2021 side detectors (multi-SF RX) from prefs. extra_sf is a * zero-terminated list; a rejected set (SF/BW changed since it was