From fd2cbe9d627d153b5d487e244c0b6938107a99dc Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:33:29 +0200 Subject: [PATCH] radio: extract CadController and NoiseFloorEstimator, one prefs source - CadController (stats, window, re-anchor, visits, staircase, safety, starvation relax) and NoiseFloorEstimator split out of LoRaRadioBase behind a 4-call CadHw interface; Zephyr-free, host unit tests added - prefs mandatory: setPrefs() before begin(), LoRaConfig fallbacks and the companion/observer placeholder prefs removed; LoRaConfig is the one defaults source (invalid-prefs fallback now uses the board TX default) - TX-wait thread stack owned by the base; reconfigureWithParams removed - driver extension headers on the public include path (zephyr/drivers/lora/*.h); patch 0003 include updated - comment diet (image-identical); rationale moved to devdocs LLD 03 --- docs/ARCHITECTURE.md | 2 +- zephcore/CMakeLists.txt | 12 +- zephcore/adapters/radio/CadController.cpp | 313 ++++ zephcore/adapters/radio/CadController.h | 124 ++ zephcore/adapters/radio/LR1110Radio.cpp | 16 +- zephcore/adapters/radio/LR1110Radio.h | 5 +- zephcore/adapters/radio/LR2021Radio.cpp | 16 +- zephcore/adapters/radio/LR2021Radio.h | 5 +- zephcore/adapters/radio/LoRaRadioBase.cpp | 1349 +++-------------- zephcore/adapters/radio/LoRaRadioBase.h | 288 +--- zephcore/adapters/radio/NoiseFloorEstimator.h | 95 ++ zephcore/adapters/radio/SX126xRadio.cpp | 11 +- zephcore/adapters/radio/SX126xRadio.h | 3 +- zephcore/adapters/radio/SX127xRadio.cpp | 14 +- zephcore/adapters/radio/SX127xRadio.h | 5 +- zephcore/adapters/radio/radio_common.h | 97 +- zephcore/adapters/radio/radio_tuning.h | 36 + zephcore/app/ObserverMesh.cpp | 13 +- zephcore/app/RepeaterDataStore.cpp | 11 +- zephcore/app/RepeaterMesh.cpp | 6 - zephcore/app/RoomServerMesh.cpp | 6 - zephcore/app/main_observer.cpp | 27 +- zephcore/helpers/CommonCLI.cpp | 4 +- zephcore/helpers/LoRaConfig.h | 15 +- zephcore/helpers/NodePrefs.h | 18 +- zephcore/include/mesh/Dispatcher.h | 16 +- zephcore/include/mesh/MeshCore.h | 18 +- .../drivers/lora/lr11xx/lr11xx_lora.c | 4 +- .../drivers/lora/lr20xx/lr20xx_lora.c | 2 +- .../zephyr/drivers/lora}/lr11xx_lora.h | 0 .../zephyr/drivers/lora}/lr20xx_lora.h | 0 .../zephyr/drivers/lora}/sx126x_ext.h | 0 .../zephyr/0003-lora-sx126x-native.patch | 6 +- zephcore/src/main_companion.cpp | 23 +- zephcore/src/main_repeater.cpp | 2 +- zephcore/src/main_room_server.cpp | 2 +- 36 files changed, 935 insertions(+), 1629 deletions(-) create mode 100644 zephcore/adapters/radio/CadController.cpp create mode 100644 zephcore/adapters/radio/CadController.h create mode 100644 zephcore/adapters/radio/NoiseFloorEstimator.h create mode 100644 zephcore/adapters/radio/radio_tuning.h rename zephcore/patches/zephyr-new/{drivers/lora/lr11xx => include/zephyr/drivers/lora}/lr11xx_lora.h (100%) rename zephcore/patches/zephyr-new/{drivers/lora/lr20xx => include/zephyr/drivers/lora}/lr20xx_lora.h (100%) rename zephcore/patches/zephyr-new/{drivers/lora/native/sx126x => include/zephyr/drivers/lora}/sx126x_ext.h (100%) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2e0d183..6f15e19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -453,7 +453,7 @@ via `Dispatcher::onCadOffsetChanged()`. The narrowing is not cosmetic: where `base + offset` falls outside the hardware clamp, several offsets program the **same** peak, and the staircase then compares rungs that are physically identical and reads sampling noise as curvature. `hwCadPeakMin/Max()` report the -driver clamp and `cadLevelMinEff()/MaxEff()` derive the usable window, so every +driver clamp and `CadController::levelMinEff()/levelMaxEff()` derive the usable window, so every level the controller can reach is a distinct configuration and the `pk` shown by `get cad.stats` is what the chip was actually given. It binds on the LR2021, whose 4-symbol base is 51 at SF5–7 (effective −3…+12) and 54 at SF8 (−6…+12); the diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 09af64d..993d0ce 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -342,6 +342,7 @@ target_sources(app PRIVATE src/Utils.cpp adapters/board/ZephyrBoard.cpp adapters/radio/LoRaRadioBase.cpp + adapters/radio/CadController.cpp adapters/clock/ZephyrMillisecondClock.cpp adapters/clock/ZephyrRTCClock.cpp adapters/rng/ZephyrRNG.cpp @@ -468,10 +469,6 @@ if(CONFIG_ZEPHCORE_RADIO_LR1110) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/adapters/radio/lr11xx ) - # Driver header (lr11xx_lora.h) is in the Zephyr tree after patching - target_include_directories(app PRIVATE - ${ZEPHYR_DIR}/drivers/lora/lr11xx - ) elseif(CONFIG_ZEPHCORE_RADIO_LR2021) message(STATUS "ZephCore Radio: LR2021 (Zephyr LoRa driver)") target_sources(app PRIVATE @@ -480,9 +477,6 @@ elseif(CONFIG_ZEPHCORE_RADIO_LR2021) target_include_directories(app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/adapters/radio/lr20xx ) - target_include_directories(app PRIVATE - ${ZEPHYR_DIR}/drivers/lora/lr20xx - ) elseif(CONFIG_ZEPHCORE_RADIO_SX127X) message(STATUS "ZephCore Radio: SX127x (Zephyr loramac-node driver)") target_sources(app PRIVATE @@ -494,10 +488,6 @@ else() target_sources(app PRIVATE adapters/radio/SX126xRadio.cpp ) - # Extension API header (sx126x_ext.h) is in the Zephyr driver tree - target_include_directories(app PRIVATE - ${ZEPHYR_DIR}/drivers/lora/native/sx126x - ) endif() # ========== Role-Specific Sources ========== diff --git a/zephcore/adapters/radio/CadController.cpp b/zephcore/adapters/radio/CadController.cpp new file mode 100644 index 0000000..1255eed --- /dev/null +++ b/zephcore/adapters/radio/CadController.cpp @@ -0,0 +1,313 @@ +/* + * SPDX-License-Identifier: MIT + * Adaptive CAD controller — see CadController.h. + */ + +#include "CadController.h" +#include + +#include +LOG_MODULE_DECLARE(lora_radio_base, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); + +namespace mesh { + +CadController::CadController(CadHw &hw) + : _hw(hw), _auto(false), _offset(0), _temp_offset(0), _visiting(false), + _busycap_pct(0), _probe_rr(0), _pending_level(INT8_MIN), + _pending_deadline_ms(0) +{ + memset(_stats, 0, sizeof(_stats)); +} + +/* The clamp only binds when base + CAD_LEVEL_MIN would land below it. (Once + * written `base - CAD_LEVEL_MIN`, which is always above pmin: inert.) */ +int8_t CadController::levelMinEff() +{ + uint8_t base = _hw.hwCadBasePeak(); + uint8_t pmin = _hw.hwCadPeakMin(); + + if (base == 0 || pmin == 0 || + (int)base + CAD_LEVEL_MIN >= (int)pmin) { + return CAD_LEVEL_MIN; + } + return (int8_t)((int)pmin - (int)base); +} + +int8_t CadController::levelMaxEff() +{ + uint8_t base = _hw.hwCadBasePeak(); + uint8_t pmax = _hw.hwCadPeakMax(); + + if (base == 0 || pmax == 0 || (int)pmax - (int)base >= CAD_LEVEL_MAX) { + return CAD_LEVEL_MAX; + } + return (int8_t)((int)pmax - (int)base); +} + +void CadController::configure(bool auto_enabled, int8_t offset, + uint8_t busycap_pct, uint8_t stored_base) +{ + const int8_t lo = levelMinEff(); + const int8_t hi = levelMaxEff(); + const uint8_t base = _hw.hwCadBasePeak(); + + /* Re-anchor across a base-table change: the offset is persisted, the + * stats behind it are not, so keep the measured PEAK. A peak the new + * window cannot reach is clamped below. */ + 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; + + _auto = auto_enabled; + _offset = offset; + _busycap_pct = busycap_pct; + + /* Effective: `set cad.*` may arrive during a visit, and the visited + * preset must keep its own threshold. */ + _hw.hwCadSetPeakOffset(effectiveOffset()); +} + +void CadController::beginVisit(bool new_preset) +{ + _temp_offset = new_preset ? 0 : _offset; + _visiting = true; + _hw.hwCadSetPeakOffset(effectiveOffset()); +} + +void CadController::endVisit() +{ + _visiting = false; + _hw.hwCadSetPeakOffset(effectiveOffset()); +} + +void CadController::resetStats() +{ + memset(_stats, 0, sizeof(_stats)); + _probe_rr = 0; + + /* A probe in flight belongs to the table being dropped. */ + _pending_level = INT8_MIN; + _pending_deadline_ms = 0; +} + +void CadController::decayStats() +{ + for (int i = 0; i < CAD_NUM_LEVELS; i++) { + _stats[i].probes >>= 1; + _stats[i].busy >>= 1; + _stats[i].fp >>= 1; + _stats[i].tp >>= 1; + } +} + +int8_t CadController::pickProbeLevel() +{ + _probe_rr++; + + if (!_auto) { + /* Parked outside the sweep window: probe where the operator is, + * or the safety rung never gets evidence for that level. */ + if (_offset < CAD_SWEEP_MIN || _offset > CAD_SWEEP_MAX) { + return _offset; + } + + /* Dry run: an even sweep, so `get cad` shows the whole curve. */ + int span = CAD_SWEEP_MAX - CAD_SWEEP_MIN + 1; + + return (int8_t)(CAD_SWEEP_MIN + (_probe_rr % span)); + } + + /* Auto: operating level half the time, each neighbour a quarter, so the + * staircase can read the slope on both sides. Out-of-range neighbours + * fall back to the operating level. */ + int8_t lvl; + switch (_probe_rr & 3) { + case 1: lvl = (int8_t)(_offset - 1); break; /* more sensitive */ + case 3: lvl = (int8_t)(_offset + 1); break; /* less sensitive */ + default: lvl = _offset; break; /* operating (0, 2) */ + } + if (lvl < levelMinEff() || lvl > levelMaxEff()) { + lvl = _offset; + } + return lvl; +} + +void CadController::recordProbe(int8_t level, bool busy) +{ + LevelStats &s = _stats[level - CAD_LEVEL_MIN]; + + if (s.probes >= 0xFFF0) { + decayStats(); + } + s.probes++; + + if (busy) { + s.busy++; + } +} + +void CadController::resolvePending(int outcome) +{ + LevelStats &ps = _stats[_pending_level - CAD_LEVEL_MIN]; + + if (outcome == 1) { + ps.tp++; + } else if (outcome == 2) { + ps.fp++; + } else { + /* No terminal event: an invented verdict would poison the curve, + * so withdraw the sample (probe and busy, to keep both rates + * over the same population). */ + if (ps.probes) ps.probes--; + if (ps.busy) ps.busy--; + LOG_WRN("cad: probe at %+d unresolved past deadline, discarded", + (int)_pending_level); + } + _pending_level = INT8_MIN; +} + +/* Per-level FALSE-positive rate in permille, or -1 when too few samples. */ +int CadController::fpRate(int idx) const +{ + if (idx < 0 || idx >= CAD_NUM_LEVELS || + _stats[idx].probes < CAD_STEP_MIN_PROBES) { + return -1; + } + return (int)(((uint32_t)_stats[idx].fp * 1000U) / _stats[idx].probes); +} + +/* Per-level TOTAL busy (defer) rate in permille — false + real traffic. */ +int CadController::busyRate(int idx) const +{ + if (idx < 0 || idx >= CAD_NUM_LEVELS || + _stats[idx].probes < CAD_STEP_MIN_PROBES) { + return -1; + } + return (int)(((uint32_t)_stats[idx].busy * 1000U) / _stats[idx].probes); +} + +void CadController::stepOffset(int8_t to) +{ + _offset = to; + _hw.hwCadSetPeakOffset(_offset); +} + +/* Steps toward the knee: the most sensitive level whose FP rate has already + * bottomed out. Uses slopes, so the decision does not depend on a site's FP + * floor. */ +void CadController::staircaseStep() +{ + int oi = _offset - CAD_LEVEL_MIN; + + int r_op = fpRate(oi); + if (r_op < 0) { + return; /* operating level not warm yet */ + } + int r_up = fpRate(oi + 1); /* one step less sensitive */ + int r_dn = fpRate(oi - 1); /* frontier, one step more sensitive */ + + int cap_permille = (int)_busycap_pct * 10; + + /* Up when the level above is markedly cleaner: below the knee. */ + if (_offset < levelMaxEff() && r_up >= 0 && + r_op - r_up >= CAD_KNEE_SLOPE_PERMILLE) { + stepOffset((int8_t)(_offset + 1)); + LOG_INF("cad: step up -> offset %d (op %d dn->up %d)", + (int)_offset, r_op, r_up); + return; + } + + /* Down only on a flat, already-clean plateau, and not into the airtime + * cap (hysteresis keeps it from bouncing straight back up). */ + int b_dn = busyRate(oi - 1); + bool busy_ok = (cap_permille == 0) || + (b_dn <= cap_permille - + (cap_permille * CAD_BUSY_DEFER_HYST_PCT) / 100); + if (_offset > levelMinEff() && r_dn >= 0 && + r_dn - r_op < CAD_KNEE_SLOPE_PERMILLE && + r_op <= CAD_PLATEAU_CLEAN_PERMILLE && busy_ok) { + stepOffset((int8_t)(_offset - 1)); + LOG_INF("cad: step down -> offset %d (op %d dn %d busy %d)", + (int)_offset, r_op, r_dn, b_dn); + return; + } + + /* Otherwise: at the knee, or a noisy plateau — hold. */ +} + +/* The proactive half of the TX safety pair: works off probe statistics, so it + * acts on a silent mesh too, where relaxOnTxStarvation() would wait for the + * next own transmit. Not gated on auto: it is a safety, not an optimiser. */ +bool CadController::safetyStep() +{ + if (_offset >= levelMaxEff()) { + return false; + } + + int oi = _offset - CAD_LEVEL_MIN; + if (oi < 0 || oi >= CAD_NUM_LEVELS) { + return false; + } + + uint16_t probes = _stats[oi].probes; + if (probes == 0) { + return false; + } + + int b_op = (int)(((uint32_t)_stats[oi].busy * 1000U) / probes); + int cap_permille = (int)_busycap_pct * 10; + + /* Pathological: trips on nearly every probe, so it cannot clear a + * transmit either. Few samples suffice; the cap is ignored. */ + bool pathological = probes >= CAD_SAFETY_MIN_PROBES && + b_op >= CAD_SAFETY_PATHOLOGICAL_PERMILLE; + /* Marginal: over the operator's airtime cap (0 = off). */ + bool over_cap = cap_permille && probes >= CAD_STEP_MIN_PROBES && + b_op > cap_permille; + + if (!pathological && !over_cap) { + return false; + } + + stepOffset((int8_t)(_offset + 1)); + if (pathological) { + LOG_WRN("cad: safety step up -> offset %d (busy %d permille over " + "%u probes — detector too sensitive to clear, auto=%d)", + (int)_offset, b_op, (unsigned)probes, (int)_auto); + } else { + LOG_INF("cad: step up -> offset %d (airtime, busy %d cap %d)", + (int)_offset, b_op, cap_permille); + } + return true; +} + +/* Ignores auto, the cap and the stats: the caller has seen the harm directly. + * One step at a time. Runs during a visit too (a muted node is muted for + * real), stepping the visitor's offset so nothing reaches prefs. */ +bool CadController::relaxOnTxStarvation() +{ + int8_t &off = _visiting ? _temp_offset : _offset; + + if (off >= levelMaxEff()) { + return false; + } + + off++; + _hw.hwCadSetPeakOffset(effectiveOffset()); + LOG_WRN("cad: TX starvation override -> offset %d (auto=%d%s) — LBT was " + "refusing every transmit", + (int)off, (int)_auto, + _visiting ? ", temp preset" : ""); + return true; +} + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/CadController.h b/zephcore/adapters/radio/CadController.h new file mode 100644 index 0000000..6065ee8 --- /dev/null +++ b/zephcore/adapters/radio/CadController.h @@ -0,0 +1,124 @@ +/* + * SPDX-License-Identifier: MIT + * Adaptive CAD controller: learns the LBT detPeak offset from probe verdicts. + * + * Pure algorithm over a four-call hardware interface. LoRaRadioBase decides + * when to probe, runs the probe and feeds the verdicts in; this class keeps + * the per-level statistics and moves the operating offset. Design notes: + * devdocs/lld/03-radio-contract.md §13 and docs/ADAPTIVE_CAD.md. + */ + +#pragma once + +#include +#include "radio_tuning.h" + +namespace mesh { + +/* The detPeak side of the chip, as the controller sees it. */ +class CadHw { +public: + /* Family base detPeak at the current SF/BW; 0 = no adaptive CAD. */ + virtual uint8_t hwCadBasePeak() = 0; + /* The driver's absolute detPeak clamp; 0 = none reported. */ + virtual uint8_t hwCadPeakMin() = 0; + virtual uint8_t hwCadPeakMax() = 0; + /* Program the offset used by every subsequent LBT CAD. */ + virtual void hwCadSetPeakOffset(int8_t offset) = 0; + +protected: + ~CadHw() = default; +}; + +class CadController { +public: + struct LevelStats { + uint16_t probes; /* probes run at this level */ + uint16_t busy; /* raw busy verdicts */ + uint16_t fp; /* busy with nothing decoded after it (false positive) */ + uint16_t tp; /* busy that turned into a received packet */ + }; + + explicit CadController(CadHw &hw); + + /* Apply the CAD prefs. stored_base is the base the persisted offset was + * learned against (0 = unknown); across a base-table change the PEAK is + * kept, not the offset. Programs the hardware. */ + void configure(bool auto_enabled, int8_t offset, uint8_t busycap_pct, + uint8_t stored_base); + + bool autoEnabled() const { return _auto; } + uint8_t busycapPct() const { return _busycap_pct; } + /* The configured offset: the value that is persisted. */ + int8_t offset() const { return _offset; } + /* The offset the chip is programmed with (the visitor's during a visit). */ + int8_t effectiveOffset() const { return _visiting ? _temp_offset : _offset; } + + /* A temporary radio override is up. new_preset: the radio moved to a + * preset it was not running, so the visit starts at that preset's base + * (offset 0); otherwise the running offset is kept. Adaptation is + * suspended and nothing the visit does reaches offset(). */ + void beginVisit(bool new_preset); + void endVisit(); + bool visiting() const { return _visiting; } + + /* The level window the controller may use: [CAD_LEVEL_MIN, CAD_LEVEL_MAX] + * narrowed so every level programs a distinct peak under the driver's + * clamp. Stats stay indexed by the static window. */ + int8_t levelMinEff(); + int8_t levelMaxEff(); + + void resetStats(); + void decayStats(); + const LevelStats &stats(int level) const { return _stats[level - CAD_LEVEL_MIN]; } + + /* Probing. */ + int8_t pickProbeLevel(); + void recordProbe(int8_t level, bool busy); + /* A busy probe whose ground truth arrives later (CAD_RX). */ + void setPending(int8_t level, int64_t deadline_ms) + { + _pending_level = level; + _pending_deadline_ms = deadline_ms; + } + bool hasPending() const { return _pending_level != INT8_MIN; } + int8_t pendingLevel() const { return _pending_level; } + int64_t pendingDeadline() const { return _pending_deadline_ms; } + /* outcome: 1 = packet (true positive), 2 = chip timeout (false + * positive), anything else = unresolved, the sample is withdrawn. */ + void resolvePending(int outcome); + + /* After a verdict: the safety rung first; the optimiser only when the + * safety did not act and auto is on. */ + void adapt() + { + if (!safetyStep() && _auto) { + staircaseStep(); + } + } + /* Airtime protection. Runs regardless of auto. True when it moved. */ + bool safetyStep(); + /* Knee-seeking optimiser. */ + void staircaseStep(); + /* The node's own TX is being refused: one step less sensitive, whatever + * auto and the stats say. During a visit it steps the visitor's offset. */ + bool relaxOnTxStarvation(); + +private: + int fpRate(int idx) const; + int busyRate(int idx) const; + void stepOffset(int8_t to); + + CadHw &_hw; + LevelStats _stats[CAD_NUM_LEVELS]; + bool _auto; + int8_t _offset; + int8_t _temp_offset; + bool _visiting; + uint8_t _busycap_pct; + uint8_t _probe_rr; + int8_t _pending_level; + int64_t _pending_deadline_ms; +}; + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index 02238d1..17a2283 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -8,7 +8,7 @@ /* LR11xx driver extension API */ extern "C" { -#include "lr11xx_lora.h" +#include } #include @@ -16,21 +16,11 @@ LOG_MODULE_REGISTER(lr1110_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); namespace mesh { -K_THREAD_STACK_DEFINE(lr11xx_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -LR1110Radio::LR1110Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) +LR1110Radio::LR1110Radio(const struct device *lora_dev, MainBoard &board) + : LoRaRadioBase(lora_dev, board) { } -void LR1110Radio::begin() -{ - startTxThread(lr11xx_tx_wait_stack, - K_THREAD_STACK_SIZEOF(lr11xx_tx_wait_stack)); - LoRaRadioBase::begin(); -} - uint32_t LR1110Radio::getDutyCycleTimeoutRestarts() const { return lr11xx_get_dc_timeout_restarts(_dev); diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 88d5401..59d7328 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -13,10 +13,7 @@ namespace mesh { class LR1110Radio : public LoRaRadioBase { public: - LR1110Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; + LR1110Radio(const struct device *lora_dev, MainBoard &board); /* Duty-cycle false-preamble re-arm count, behind `get dc.restarts`. * Without these the base class's stub answers 0 forever, and the one diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index 70aef2b..797d791 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -8,7 +8,7 @@ /* LR20xx driver extension API */ extern "C" { -#include "lr20xx_lora.h" +#include } #include @@ -16,21 +16,11 @@ LOG_MODULE_REGISTER(lr2021_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); namespace mesh { -K_THREAD_STACK_DEFINE(lr20xx_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -LR2021Radio::LR2021Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) +LR2021Radio::LR2021Radio(const struct device *lora_dev, MainBoard &board) + : LoRaRadioBase(lora_dev, board) { } -void LR2021Radio::begin() -{ - startTxThread(lr20xx_tx_wait_stack, - K_THREAD_STACK_SIZEOF(lr20xx_tx_wait_stack)); - LoRaRadioBase::begin(); -} - bool LR2021Radio::configSideDetectors(const uint8_t *sfs, uint8_t num) { /* The driver validates against the configured SF/BW, so this must run diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index 1d1b29e..a7ca83a 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -13,10 +13,7 @@ namespace mesh { class LR2021Radio : public LoRaRadioBase { public: - LR2021Radio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; + LR2021Radio(const struct device *lora_dev, MainBoard &board); /* Duty-cycle false-preamble re-arm count, behind `get dc.restarts`. * Without these the base class's stub answers 0 forever, and the one diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 936956d..3a07afd 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -6,7 +6,6 @@ #include "LoRaRadioBase.h" #include "radio_common.h" #include "pm_sleep_guard.h" -#include #include /* MAX_TRANS_UNIT */ #include #include @@ -41,24 +40,19 @@ static uint16_t rxDutyDetectSymbols(uint8_t sf) /* ── Constructor ─────────────────────────────────────────────── */ -LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) +LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board) : _loramac_node(false), - _dev(lora_dev), _prefs(prefs), _board(&board), + _dev(lora_dev), _prefs(nullptr), _board(&board), _in_recv_mode(0), _tx_active(0), _tx_complete(0), _last_rssi(0), _last_snr(0), _rx_head(0), _rx_tail(0), - _noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0), + _calibration_threshold(0), _noise_floor_next_ms(0), _noise_floor_retries(0), _measure_interval_ms(CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS), _sample_rssi(0), _sample_channel_quiet(false), _sample_fresh(false), _rx_entry_cyc(0), _rssi_bursts(0), _rssi_spread_sum(0), _rssi_degenerate(0), - _cad_auto(false), _cad_offset(0), _cad_temp_offset(0), _probe_interval_s(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), + _cad(*this), _probe_interval_s(0), _cad_last_decay_ms(0), _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), _rx_boost_enabled(true), _dc_last_rx_us(0), _dc_last_sleep_us(0), @@ -83,41 +77,19 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, k_poll_signal_init(&_tx_signal); k_sem_init(&_tx_start_sem, 0, 1); memset(_rx_ring, 0, sizeof(_rx_ring)); - memset(_cad_stats, 0, sizeof(_cad_stats)); } /* ── TX wait thread ──────────────────────────────────────────── */ -/* How long to wait for TX_DONE before declaring the transmit lost. - * - * TX_TIMEOUT_MS alone is a fixed 5 s, which is shorter than the airtime of a - * great many legal presets — a 255-byte packet is 28.6 s at SF12/BW62.5 and - * 10.2 s at SF9/BW31.25 — so the wait would expire mid-transmission and - * startReceive() would yank the radio out of TX, losing a packet that was - * transmitting perfectly well. Scale from the driver's own airtime instead, - * with the same doubling the drivers' internal sync-send waits use. - * - * MAX(), never a bare replacement: on fast presets the scaled value is smaller - * than 5 s (SF7/BW62.5, 64 bytes: ~1.4 s), and shortening this deadline on the - * presets every radio in the fleet is running today would be a regression for - * no gain. The floor keeps existing behaviour exactly; only slow presets move. - * - * lora_airtime() is a pure calculation on the cached modem config, so calling - * it from this thread costs no SPI and cannot race the radio. */ +/* How long to wait for TX_DONE: 2 x airtime + 1 s, never below TX_TIMEOUT_MS. + * A flat 5 s is shorter than a long packet on slow presets (255 B at + * SF12/BW62.5 is 28.6 s). lora_airtime() is pure math on the cached config. */ uint32_t LoRaRadioBase::txWaitBudgetMs() const { - /* _tx_len is published by startSendRaw() before it releases - * _tx_start_sem, so by the time this thread runs it is always the length - * of the transmit in flight — the fallback is defensive only, and uses - * the protocol maximum rather than a literal so it tracks MAX_TRANS_UNIT - * if the FIFO bound ever moves. */ + /* _tx_len is set by startSendRaw() before the handoff; 0 is defensive. */ uint32_t air = lora_airtime(_dev, _tx_len ? _tx_len : MAX_TRANS_UNIT); - /* All four drivers behind this class implement .airtime (native sx126x, - * lr11xx, lr20xx, loramac-node sx127x), and lora_airtime() dereferences - * the op without a NULL check, so there is no missing-op case to handle. - * A zero can still come back from a degenerate modem config; fall back - * to the flat budget rather than to no wait at all. */ + /* Every driver implements .airtime; 0 means a degenerate modem config. */ if (air == 0) { return TX_TIMEOUT_MS; } @@ -163,18 +135,8 @@ void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3) int result; k_poll_signal_check(&self->_tx_signal, &signaled, &result); if (signaled) { - /* The result carries the driver's verdict, and it has to - * be honoured: a driver that reports a failed transmit - * by raising the signal with a negative result (the - * SX126x does exactly this on a chip Tx timeout, - * -ETIMEDOUT) was previously counted here as a - * successful send. Latent rather than live — that - * SX126x path is unreachable while an RX callback is - * registered, which it always is — but it is the reason - * the LR11xx and LR20xx timeout handlers deliberately do - * NOT raise the signal. With the result honoured, a - * driver reporting failure is now the correct thing to - * do on all three. */ + /* A negative result is the driver reporting a lost transmit + * (e.g. the SX126x chip Tx timeout), not a completed one. */ if (result < 0) { LOG_ERR("TX wait: driver reported failure (%d) — packet lost", result); @@ -182,13 +144,9 @@ void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3) LOG_DBG("TX wait: signal already raised (result=%d)", result); } k_poll_signal_reset(&self->_tx_signal); - /* Latch the verdict BEFORE the RX re-arm below, not after: - * onAfterTransmit() + startReceive() is a full modem - * reconfigure over SPI, and any loop() pass that lands - * inside it used to see "not complete yet" and abandon a - * transmit that had in fact finished. startSendRaw()'s - * _tx_active CAS is what makes publishing the completion - * this early safe. */ + /* Latch before the RX re-arm (a full SPI reconfigure) so no + * dispatcher pass sees a finished TX as pending; the _tx_active + * CAS in startSendRaw() makes publishing this early safe. */ if (result >= 0) { atomic_set(&self->_tx_complete, 1); } @@ -217,17 +175,14 @@ void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3) } if (ret == 0 && events[0].state == K_POLL_STATE_SIGNALED) { - /* Same rule as the already-raised path above: a negative - * result is the driver reporting a lost transmit, not a - * completed one. */ + /* Same verdict rule as the already-raised path above. */ int sig_result = 0; unsigned int sig_state = 0; k_poll_signal_check(&self->_tx_signal, &sig_state, &sig_result); k_poll_signal_reset(&self->_tx_signal); - /* Latched ahead of the RX re-arm — see the equivalent - * comment on the already-raised path above. */ + /* Latched before the RX re-arm, as above. */ if (sig_result >= 0) { atomic_set(&self->_tx_complete, 1); } @@ -259,12 +214,16 @@ void LoRaRadioBase::txWaitThreadFn(void *p1, void *p2, void *p3) } } -void LoRaRadioBase::startTxThread(k_thread_stack_t *stack, size_t stack_size) +/* One radio per build, so one stack. */ +K_THREAD_STACK_DEFINE(lora_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); + +void LoRaRadioBase::startTxThread() { if (_tx_thread_running) { return; } - k_thread_create(&_tx_wait_thread, stack, stack_size, + k_thread_create(&_tx_wait_thread, lora_tx_wait_stack, + K_THREAD_STACK_SIZEOF(lora_tx_wait_stack), txWaitThreadFn, this, NULL, NULL, TX_WAIT_THREAD_PRIORITY, 0, K_NO_WAIT); k_thread_name_set(&_tx_wait_thread, "lora_tx_wait"); @@ -315,10 +274,7 @@ void LoRaRadioBase::rxCallbackStatic(const struct device *dev, uint8_t *data, self->_last_snr = (float)snr; atomic_inc(&self->_packets_recv); - /* Activity LED ("set leds.radio rx|all"). Deliberately below the CRC and - * header-error early return above, so the blink means a valid packet - * landed rather than that something was heard on the channel. Cheap and - * non-blocking: the board raises a GPIO and arms a one-shot. */ + /* Activity LED: after the error return, so it blinks on valid packets only. */ self->_board->onPacketReceived(); if (self->_rx_cb) { @@ -333,21 +289,16 @@ void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) memset(&cfg, 0, sizeof(cfg)); /* Override wins for freq/bw/sf/cr (tempradio). Power, preamble, and * other fields still come from _prefs. */ - float freq_mhz = _has_radio_override ? _override_freq - : (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f)); - float bw_khz = _has_radio_override ? _override_bw - : (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH); - uint8_t sf = _has_radio_override ? _override_sf - : (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR); - uint8_t cr = _has_radio_override ? _override_cr - : (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE); + float freq_mhz = _has_radio_override ? _override_freq : _prefs->freq; + float bw_khz = _has_radio_override ? _override_bw : _prefs->bw; + uint8_t sf = _has_radio_override ? _override_sf : _prefs->sf; + uint8_t cr = _has_radio_override ? _override_cr : _prefs->cr; cfg.frequency = (uint32_t)(freq_mhz * 1000000.0f); cfg.bandwidth = bw_khz_to_enum((uint16_t)bw_khz); cfg.datarate = (enum lora_datarate)sf; cfg.coding_rate = cr_to_enum(cr); cfg.preamble_len = preambleLengthForSF(sf); - cfg.tx_power = _prefs ? (int8_t)_prefs->tx_power_dbm - : LoRaConfig::TX_POWER_DBM; + cfg.tx_power = (int8_t)_prefs->tx_power_dbm; #ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM if (cfg.tx_power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) { cfg.tx_power = CONFIG_ZEPHCORE_MAX_TX_POWER_DBM; @@ -378,30 +329,26 @@ void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) uint32_t LoRaRadioBase::getActiveFrequencyHz() const { - float freq_mhz = _has_radio_override ? _override_freq - : (_prefs ? _prefs->freq : (LoRaConfig::FREQ_HZ / 1000000.0f)); + float freq_mhz = _has_radio_override ? _override_freq : _prefs->freq; return (uint32_t)(freq_mhz * 1000000.0f + 0.5f); } uint16_t LoRaRadioBase::getActiveBandwidthKHzX10() const { - float bw_khz = _has_radio_override ? _override_bw - : (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH); + float bw_khz = _has_radio_override ? _override_bw : _prefs->bw; return (uint16_t)(bw_khz * 10.0f + 0.5f); } uint8_t LoRaRadioBase::getActiveSpreadingFactor() const { - return _has_radio_override ? _override_sf - : (_prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR); + return _has_radio_override ? _override_sf : _prefs->sf; } uint8_t LoRaRadioBase::getActiveCodingRate() const { - return _has_radio_override ? _override_cr - : (_prefs ? _prefs->cr : LoRaConfig::CODING_RATE); + return _has_radio_override ? _override_cr : _prefs->cr; } uint16_t LoRaRadioBase::getActivePreambleLength() const @@ -418,7 +365,7 @@ uint8_t LoRaRadioBase::getActiveSyncWord() const int8_t LoRaRadioBase::getConfiguredTxPower() const { - int power = _prefs ? _prefs->tx_power_dbm : LoRaConfig::TX_POWER_DBM; + int power = _prefs->tx_power_dbm; #ifdef CONFIG_ZEPHCORE_MAX_TX_POWER_DBM if (power > CONFIG_ZEPHCORE_MAX_TX_POWER_DBM) { @@ -524,36 +471,30 @@ void LoRaRadioBase::configureTx() { configure(true); } void LoRaRadioBase::begin() { + if (_prefs == nullptr) { + LOG_ERR("radio begin() without setPrefs()"); + return; + } + + startTxThread(); + if (!device_is_ready(_dev)) { LOG_ERR("LoRa device not ready"); return; } - /* Subclass begin() calls startTxThread() before calling us. - * - * RX boost and duty cycle are set via constructor defaults: - * _rx_boost_enabled = true (boosted +3dB, overridable via setRxBoost()) - * _rx_duty_cycle_enabled = CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE - * Callers can override after begin() via setRxBoost() / enableRxDutyCycle(). - */ startReceive(); - /* Sync _rx_boost_enabled to the driver. The driver initialises its own - * rx_boost_enabled flag from DTS (rx-boosted property), which may differ - * from our constructor default (true). Push our intent now so the - * hardware state matches _rx_boost_enabled from the moment begin() - * returns, before the caller applies prefs via setRxBoost(). */ + /* The driver's RX-boost flag starts from DTS; push ours so the + * hardware matches _rx_boost_enabled before prefs are applied. */ hwSetRxBoost(_rx_boost_enabled); - uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) - : LoRaConfig::FREQ_HZ; - uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; - uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw) - : (uint16_t)LoRaConfig::BANDWIDTH; - uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; - int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm - : LoRaConfig::TX_POWER_DBM; + uint32_t freq = (uint32_t)(_prefs->freq * 1000000.0f); + uint8_t sf = _prefs->sf; + uint16_t bw_khz = (uint16_t)(_prefs->bw); + uint8_t cr = _prefs->cr; + int8_t tx_pwr = (int8_t)_prefs->tx_power_dbm; LOG_INF("radio started: freq=%u bw=%u sf=%u cr=%u pwr=%d", freq, bw_khz, sf, cr, tx_pwr); @@ -568,29 +509,16 @@ void LoRaRadioBase::reconfigure() resetCadStats(); startReceive(); - uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) - : LoRaConfig::FREQ_HZ; - uint8_t sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; - uint16_t bw_khz = _prefs ? (uint16_t)(_prefs->bw) - : (uint16_t)LoRaConfig::BANDWIDTH; - uint8_t cr = _prefs ? _prefs->cr : LoRaConfig::CODING_RATE; - int8_t tx_pwr = _prefs ? (int8_t)_prefs->tx_power_dbm - : LoRaConfig::TX_POWER_DBM; + uint32_t freq = (uint32_t)(_prefs->freq * 1000000.0f); + uint8_t sf = _prefs->sf; + uint16_t bw_khz = (uint16_t)(_prefs->bw); + uint8_t cr = _prefs->cr; + int8_t tx_pwr = (int8_t)_prefs->tx_power_dbm; LOG_INF("radio reconfigured: freq=%u bw=%u sf=%u cr=%u pwr=%d", freq, bw_khz, sf, cr, tx_pwr); } -void LoRaRadioBase::reconfigureWithParams(float freq, float bw, uint8_t sf, uint8_t cr) -{ - /* Callers (ObserverMesh CLI handlers) write to _prefs and call - * savePrefs() before invoking us — the radio just needs to pick up - * the new params. Tempradio uses setRadioOverride() instead so it - * never touches _prefs. */ - (void)freq; (void)bw; (void)sf; (void)cr; - reconfigure(); -} - void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr, bool visiting_new_preset) { @@ -601,18 +529,9 @@ void LoRaRadioBase::setRadioOverride(float freq, float bw, uint8_t sf, uint8_t c _has_radio_override = true; reconfigure(); - /* Visit a new preset at its own base detPeak. The learned offset belongs - * to the preset it was measured on and the base table it addresses is - * per-SF/per-bandwidth, so carrying it across programs a threshold nobody - * measured (SF7->SF12 is 5 counts on SX126x, 16 on LR11xx). A freeze - * changes nothing on air, so it keeps the offset that is already right. - * - * _cad_offset is left untouched — not snapshotted — so the configured - * preset gets its offset back verbatim in clearRadioOverride(), and no - * visitor value can reach prefs in between. Adaptation is suspended for - * the duration; see cadMaintenance(). */ - _cad_temp_offset = visiting_new_preset ? 0 : _cad_offset; - hwCadSetPeakOffset(cadEffectiveOffset()); + /* The base detPeak is per-SF/per-BW, so a new preset starts at its own + * base; a freeze keeps the offset already running. */ + _cad.beginVisit(visiting_new_preset); } void LoRaRadioBase::clearRadioOverride() @@ -622,7 +541,7 @@ void LoRaRadioBase::clearRadioOverride() } _has_radio_override = false; reconfigure(); - hwCadSetPeakOffset(cadEffectiveOffset()); + _cad.endVisit(); } void LoRaRadioBase::startReceive() @@ -632,29 +551,11 @@ void LoRaRadioBase::startReceive() int ret; if (_rx_duty_cycle_enabled) { - /* Duty-cycle window sizing. All constraints primary-sourced - * (SX1261/2 DS rev 2.2 §13.1.7 + AN1200.36): - * - * 1. Catch — worst case is a preamble starting D−ε symbols - * before an RX window closes (detection aborts, must - * complete in the NEXT window), so the total deaf time per - * cycle (programmed sleep + wake transition) must satisfy - * sleep + trans ≤ (P − 2D − 1)·Tsym - * with 1 symbol margin for the chip's RC64k sleep timer. - * Stricter than AN1200.36's own "P ≥ sleep + D" model, - * which ignores the window-tail arrival case. - * 2. Complete — DS: "Tpreamble + Theader ≤ 2·rxPeriod + - * sleepPeriod". A preamble detected at its first symbols - * restarts the chip timer with 2R+S; that budget must cover - * the rest of the preamble + sync (4.25) + header (~8), - * rounded up to P + 14 symbols. The driver also sets - * StopTimerOnPreamble, but this sizing keeps packets safe - * under either documented timer behaviour. - * 3. Floor — rxPeriod ≥ (D+1)·Tsym so any single window can - * detect on its own. - * - * No viable sleep budget (short preamble, or the TCXO restart - * eats it) → honest fall-through to continuous RX. */ + /* Duty-cycle window sizing (SX1261/2 DS §13.1.7, AN1200.36; LLD 03 §13): + * catch: sleep + wake <= (P - 2D - 1) * Tsym + * complete: 2R + S >= (P + 14) * Tsym + * floor: R >= (D + 1) * Tsym + * No viable sleep budget -> continuous RX. */ struct lora_modem_config cfg; buildModemConfig(cfg, false); @@ -668,16 +569,9 @@ void LoRaRadioBase::startReceive() (((uint64_t)(1U << sf) * 1000000ULL) / bw_hz); const uint32_t trans_us = hwWakeupTimeUs(); - /* Theoretical per-cycle deaf budget, then derate by - * CONFIG_..._MARGIN_PCT. The budget assumes the sleep - * clock and wake transition are exact; in reality the - * chip sleep timer runs on an RC oscillator that drifts - * several % over temperature and the wake transition is a - * "may vary" datasheet figure. Either overshoot pushes - * real deaf time past the budget and drops phase-edge - * packets (strength-independent DC loss). Deraging the - * whole budget is slightly stricter than deraging sleep - * alone, which is the safe direction. */ + /* Derate the budget by DC_MARGIN_PCT: the RC sleep timer + * drifts and the wake time "may vary"; an overshoot drops + * phase-edge packets. */ const uint32_t deaf_budget_us = (uint32_t)(P - 2 * D - 1) * sym_us; const uint32_t deaf_us = deaf_budget_us - @@ -717,17 +611,8 @@ void LoRaRadioBase::startReceive() return; } if (ret == -EBUSY) { - /* A concurrent TX owns the chip. Not the - * CAD-busy case: when the LBT branch of - * send_async restores RX in-driver it - * leaves the chip in RX, and the driver's - * idempotent fast-path (patch 0003) - * re-arms duty cycle from there — AGC - * reset included — rather than refusing. - * So -EBUSY here means the radio is - * genuinely mid-transmit; the fall-through - * to lora_recv_async will fail the same - * way and report it. */ + /* A live TX owns the chip (CAD-busy leaves it in + * RX, which the driver's fast path re-arms). */ LOG_DBG("rxduty: busy (TX in progress) — continuous RX"); } else if (ret != -ENOSYS) { LOG_ERR("lora_recv_duty_cycle_async failed: %d", ret); @@ -788,63 +673,30 @@ bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) return false; } - /* Last-moment software check before killing active RX. Uses the full - * isReceiving() (latch + non-destructive raw bits) so the final gate - * honors the same source of truth as the dispatcher's earlier gates. - * Closes the serialisation/logging gap between the dispatcher's check - * and the TX-state transition below. */ + /* Final gate before leaving RX: the same isReceiving() as the dispatcher. */ if (isReceiving()) { return false; } - /* CAS, not a bare set: the wait thread publishes _tx_complete before it - * re-arms RX, so the dispatcher can legitimately collect a completion - * and come straight back here while that thread is still inside - * startReceive(). A plain set would let this transmit be started and - * then have its _tx_active cleared out from under it moments later -- - * and would leave the driver re-entering RX on top of a live TX. - * Refusing instead is correct and cheap: the dispatcher re-queues and - * the wind-down finishes in microseconds. Placed before - * onBeforeTransmit() so a refusal does not light the TX LED. */ + /* CAS: the wait thread publishes completion before re-arming RX, so a + * new send can arrive while it winds down; refuse and let the dispatcher + * re-queue. Before onBeforeTransmit() so a refusal lights no LED. */ if (!atomic_cas(&_tx_active, 0, 1)) { LOG_DBG("startSendRaw: previous transmit still winding down"); return false; } - /* A completion nobody collected belongs to the packet that just went - * out, never to this one -- upstream's STATE_IDLE reset in the same - * place. */ + /* An uncollected completion belongs to the previous packet. */ atomic_set(&_tx_complete, 0); - /* No SoC light sleep from here until the wait thread has concluded this - * transmit (it releases the lock), or until the failure path below. - * Covers the blocking LBT CAD inside hwSendAsync() as well as the - * airtime. With nothing else runnable during a transmit, an ESP32 - * repeater otherwise sleeps through it and meets TX_DONE as a wake - * event rather than an interrupt, and the completion lands late or not - * at all (see the EXT1 note in the sx126x HAL). Costs nothing worth - * counting: the PA dominates the current draw while keyed. A no-op - * without CONFIG_PM. */ + /* No SoC light sleep until the wait thread concludes this transmit + * (LBT CAD + airtime): an ESP32 would otherwise sleep through TX_DONE. + * Released on every path. No-op without CONFIG_PM. */ zc_pm_block_sleep(); _board->onBeforeTransmit(); _last_tx_start_ms = k_uptime_get_32(); - /* Phase 2: when LBT is enabled, skip the pre-emptive hwCancelReceive() - * and keep _in_recv_mode = 1 so the driver's send_async sees state == RX - * (the Phase-2 entry CAS path). On CAD-busy the driver restores RX - * internally; on success the chip transitions cleanly into TX without - * the redundant ~1–3 ms C++ cancel-then-restart round-trip. - * isReceiving() returns false during the CAD window because _tx_active - * is set above — no extra gating needed. - * - * cad.mode = LBT is set unconditionally in buildModemConfig() today; - * the `lbt` flag is a placeholder for any future Kconfig that toggles - * the behaviour. */ - const bool lbt = true; - - if (!lbt) { - atomic_set(&_in_recv_mode, 0); - hwCancelReceive(); - } + /* LBT: stay in RX (no cancel) so the driver's send_async takes its + * RX->TX entry and restores RX itself on CAD-busy. */ configureTx(); memcpy(_tx_buf, bytes, len); @@ -856,11 +708,8 @@ bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) int ret = hwSendAsync(_tx_buf, (uint32_t)len, &_tx_signal); if (ret < 0) { if (ret == -EBUSY) { - /* LBT refused the transmit because the channel is busy - * — the designed outcome, not a fault. The dispatcher - * re-queues and retries. On a busy site this fires - * constantly, and at ERR it buries real faults and - * makes a healthy repeater look broken. */ + /* LBT refused (channel busy): the designed outcome, the + * dispatcher re-queues. Not an error. */ LOG_DBG("hwSendAsync: channel busy (LBT), re-queuing"); } else { LOG_ERR("hwSendAsync failed: %d", ret); @@ -868,13 +717,8 @@ bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) _board->onAfterTransmit(); atomic_set(&_tx_active, 0); zc_pm_unblock_sleep(); - /* startReceive() is safe to call here regardless of failure - * cause: on SX126x, recv_async early-returns if the driver - * already restored RX on CAD-busy (Phase 2 idempotent fast - * path); on LR11xx/LR20xx, the LBT branch restores RX before - * returning -EBUSY (Phase 2 mirror), so start_rx is also a - * no-op there. On other failure modes the chip is in REST, - * recv_async transitions normally. */ + /* Safe after any failure: on CAD-busy every driver has already + * restored RX and recv_async is a no-op. */ startReceive(); return false; } @@ -889,18 +733,9 @@ bool LoRaRadioBase::startSendRaw(const uint8_t *bytes, int len) bool LoRaRadioBase::isSendComplete() { - /* One-shot, and it owns _packets_sent — the same contract as upstream's - * RadioLibWrapper::isSendComplete(), which self-clears STATE_INT_READY - * and does n_sent++ in the same breath. Incrementing here rather than - * in the wait thread is what keeps the radio's "packets sent" tally and - * the dispatcher's flood/direct tallies in lockstep: both advance on - * this one call, so a completion the dispatcher never collects (it hit - * outbound_expiry first) is missed by both, exactly as upstream misses - * it. They used to be independent counters on independent threads, - * which let "Total" and "Flood + Direct" disagree by thousands. - * - * This is a consuming call. Anything that wants to know whether a - * transmit is in flight must use isTxActive() instead. */ + /* One-shot, and it owns _packets_sent, like upstream's + * RadioLibWrapper::isSendComplete(): the radio and dispatcher tallies + * move together. Use isTxActive() to ask whether a TX is in flight. */ if (atomic_cas(&_tx_complete, 1, 0)) { atomic_inc(&_packets_sent); return true; @@ -953,19 +788,10 @@ bool LoRaRadioBase::isRadioReady() uint32_t LoRaRadioBase::getEstAirtimeFor(int len_bytes) { - /* Read the params the radio is ACTUALLY running, not the saved prefs: - * buildModemConfig() honours _has_radio_override, so a node under - * `tempradio` transmits on the override preset while this used to - * estimate for the stored one. Everything downstream of the estimate - * drifts with it — reported RX airtime, the TX airtime and duty-cycle - * budget that now derive from it, and outbound_expiry. Upstream cannot - * drift this way because it asks the radio (getTimeOnAir()); these - * accessors are our equivalent. Resolve bandwidth through the same - * enum as buildModemConfig(): a saved 62 or 62.5 both program 62.5 kHz, - * and a rounded setting must not change the airtime calculation. */ + /* The params the radio is actually running (a tempradio override + * included), resolved through the same BW enum as buildModemConfig(). */ uint8_t sf = getActiveSpreadingFactor(); - float bw = _has_radio_override ? _override_bw - : (_prefs ? _prefs->bw : (float)LoRaConfig::BANDWIDTH); + float bw = _has_radio_override ? _override_bw : _prefs->bw; uint8_t cr_val = getActiveCodingRate(); uint8_t min_sf = _loramac_node ? 6 : 5; @@ -1001,7 +827,7 @@ uint32_t LoRaRadioBase::getEstAirtimeFor(int len_bytes) float LoRaRadioBase::packetScore(float snr, int packet_len) { - int sf = _prefs ? _prefs->sf : LoRaConfig::SPREADING_FACTOR; + int sf = _prefs->sf; if (sf < 7 || sf > 12) return 0.0f; if (snr < lora_snr_threshold[sf - 7]) return 0.0f; @@ -1017,7 +843,7 @@ float LoRaRadioBase::packetScore(float snr, int packet_len) int LoRaRadioBase::getNoiseFloor() const { - return _noise_floor; + return _floor_est.floor(); } void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) @@ -1028,22 +854,16 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) * calls are a no-op, so this is safe to invoke from any wake. */ int64_t now = k_uptime_get(); - /* Invalidate first: "fresh" must mean a sample landed in THIS pass, not - * merely at some point in the past. cadMaintenance() runs immediately - * after us and treats the verdict as current-channel ground truth, so a - * carried-over sample would let it probe on a reading taken a full - * interval ago — on a different channel state entirely. */ + /* "Fresh" means sampled in THIS pass: cadMaintenance() runs right + * after and treats the verdict as current. */ _sample_fresh = false; if (_noise_floor_next_ms != 0 && now < _noise_floor_next_ms) { return; } - /* Due. Any bail-out below is a blocked attempt, not a completed one — - * push the deadline out by the retry so msUntilNextMaintenance() cannot - * report "due now" on a loop. Bounded for the same reason as the CAD - * probe: an unbounded retry grid makes the retry period the de-facto - * wake period whenever the radio is persistently busy. */ + /* Due. A bail-out below is a blocked attempt: retry soon, a bounded + * number of times, so the wake schedule never spins on "due now". */ if (_noise_floor_retries >= NOISE_FLOOR_MAX_RETRIES) { _noise_floor_retries = 0; _noise_floor_next_ms = now + _measure_interval_ms; @@ -1056,16 +876,8 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) return; } - /* Skip when the radio cannot accept commands right now - * (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. */ + /* Chip BUSY (duty-cycle sleep). Counted (d in `get cad`): the only + * record of how often the sampler is turned away. */ if (!isRadioReady()) { _rssi_dc_blocked++; return; @@ -1076,15 +888,9 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) return; } - /* GetRssiInst needs time after RX entry before the first value is - * valid (DS Table 13-82). isRadioReady() only clears BUSY, and the - * delay is measured *from* the BUSY falling edge, so BUSY alone does - * not prove the reading has settled. Only host-driven RX entries are - * stamped: under RX duty cycle the sleep->RX wakes are chip-internal - * and invisible to us. That is acceptable rather than ideal — the - * delay is ~0.25 ms at BW 62.5 against an RX window orders of - * magnitude longer, so the odds of a duty-cycle sample landing inside - * an unsettled window are small, and the median absorbs the odd one. */ + /* GetRssiInst needs settling after RX entry (DS Table 13-82). Only + * host-driven entries are stamped; duty-cycle wakes are chip-internal + * and the median absorbs the rare unsettled read. */ uint16_t bw_khz = (uint16_t)(getActiveBandwidthKHzX10() / 10); uint32_t since_rx_us = k_cyc_to_us_floor32(k_cycle_get_32() - _rx_entry_cyc); @@ -1093,63 +899,24 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) return; } - /* Median of multiple RSSI reads: no downward bias of min, no spike - * sensitivity of average. Insertion sort is fine for N=8 (28 - * comparisons worst case, all in registers). - * - * Scope, measured on-air 2026-07-29 (`get cad` sp field, BW 62.5): - * 84-90%% of bursts return N identical values, and the rest average - * ~6 dB of spread. The reads ARE independent -- the degenerate share - * falls and the spread rises when ambient comes up, exactly as it - * should. The burst is simply short: ~300 us against a ~200 ms - * SF8/BW62.5 packet, about 0.15%% of one transmission. So a - * neighbour's packet is either wholly inside the burst or wholly - * outside it, every read sees the same level, and the median returns - * it rather than rejecting it. - * - * What this median actually buys is rejection of sub-300 us glitches - * and single bad SPI reads. That is worth its ~300 us every 15 s, but - * it is NOT the defence against interference -- that is the - * isReceiving() guard above and the floor + SAMPLING_THRESHOLD filter - * below. An earlier comment here claimed "rejects up to N/2-1 - * outliers", which credited the median with their work. - * - * Reads are spaced by the RSSI averaging window, without which they - * can all fall inside one window and return the same underlying - * sample N times — a median of N copies of one read. At BW 62.5 the - * spacing (~16 us) is already covered by the SPI transaction itself; - * it matters at the narrow presets, where the window grows past the - * whole burst. */ + /* Median of N reads, spaced by the RSSI averaging window so they are + * independent. It rejects glitches and bad SPI reads; interference is + * kept out by isReceiving() and the estimator's outlier gate (LLD 03 §13). */ uint32_t window_us = rssi_avg_window_us(bw_khz); int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; 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. */ + /* A preamble/header landed mid-burst: the samples measured a + * signal. Abandoned, not a read fault. The clear _sample_fresh also + * keeps the CAD probe off the air on the LR families. */ _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. */ + /* Partial burst (chip busy / contended read): counted, retried soon. */ _rssi_reads_busy++; _rssi_bursts_abandoned++; return; @@ -1159,71 +926,37 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) _noise_floor_next_ms = now + _measure_interval_ms; _noise_floor_retries = 0; - /* Insertion sort — tiny array, branch-friendly on Cortex-M */ - for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { - int16_t key = samples[i]; - int j = i - 1; - while (j >= 0 && samples[j] > key) { - samples[j + 1] = samples[j]; - j--; - } - samples[j + 1] = key; - } - int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + - samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; + int16_t rssi = sortAndMedian(samples, NOISE_FLOOR_SAMPLES_PER_TICK); - /* Burst quality, reported by `get cad`. Sorted, so max-min is the - * spread. Kept as running totals rather than an EMA so the numbers - * stay readable and the degenerate share is a true proportion. - * - * Reading it: a high zero-spread share on its own is NOT a fault — a - * quiet or steadily-occupied channel genuinely reads the same value - * N times at integer-dB resolution. What would indict the sampler is - * a high share together with a mean of 0.0, i.e. no burst ever spans - * anything: that is reads landing inside one RSSI averaging window and - * returning one sample N times over. A non-zero mean proves the reads - * are independent however high the share climbs. */ + /* Burst quality for `get cad` (sp:). Sorted, so max - min is the spread. + * A high zero-spread share is only a fault together with a mean of 0.0. */ _rssi_bursts++; _rssi_spread_sum += (uint32_t)(samples[NOISE_FLOOR_SAMPLES_PER_TICK - 1] - samples[0]); if (samples[NOISE_FLOOR_SAMPLES_PER_TICK - 1] == samples[0]) { _rssi_degenerate++; } - /* Rescale together so both derived figures survive untouched, and the - * printed count stays four digits however long the node is up. */ + /* Rescale every counter together so the printed ratios stay right + * and the count stays four digits. */ if (_rssi_bursts >= RSSI_BURST_STATS_CAP) { _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 - * the same fact we just established — "is the channel at its floor right - * now?" — and used to answer it with its own single hwGetCurrentRSSI() on - * its own deadline. That cost a second wake per interval (measured: two - * 15 s grids ~3 s apart) and made the worse decision, since one raw read - * is precisely what the median-of-8 exists to defend against. - * - * The verdict is taken against the floor BEFORE this sample is folded in, - * so it compares a new observation to the established floor rather than - * to one already dragged toward it. */ + /* Publish for cadMaintenance(): the CAD probe rides on this sample + * (one wake per interval, median-of-N ground truth). Judged against + * the floor before this sample is folded in. */ _sample_rssi = rssi; - _sample_channel_quiet = (_noise_floor == DEFAULT_NOISE_FLOOR) || - (rssi <= _noise_floor + CAD_PROBE_RSSI_GUARD); + _sample_channel_quiet = _floor_est.isQuiet(rssi); _sample_fresh = true; - /* Stuck-AGC evidence, gathered from a reading we already took. A live - * front end dithers by a dB or two between samples even on a quiet - * channel; a desensitised one returns the same number forever. Costs - * nothing and needs no chip command of its own. */ + /* Stuck-AGC evidence for agcIdleMaintenance(): a live front end + * dithers; a desensitised one reads the same value forever. */ if (rssi == _agc_rssi_last) { if (_agc_rssi_frozen < 0xFF) { _agc_rssi_frozen++; @@ -1233,57 +966,21 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) _agc_rssi_frozen = 0; } - /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. - * The lower clamp tracks the active bandwidth — thermal noise is - * 10*log10(BW) so a fixed rail pins narrow-BW presets several dB high - * (BW 31.25 kHz sits ~3 dB below BW 62.5) and never engages at all on - * wide ones. */ + /* The lower clamp tracks the active bandwidth (thermal noise is + * 10*log10(BW)); a fixed rail pins narrow presets high. */ int16_t floor_min = noise_floor_min_dbm(getActiveBandwidthKHzX10() / 10); - if (_noise_floor == DEFAULT_NOISE_FLOOR) { - _noise_floor = rssi; - if (_noise_floor < floor_min) _noise_floor = floor_min; - if (_noise_floor > -50) _noise_floor = -50; - _ema_unguarded = 0; - LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); - return; + switch (_floor_est.update(rssi, floor_min)) { + case NoiseFloorEstimator::SEEDED: + LOG_DBG("noise_floor_cal: seed=%d", _floor_est.floor()); + break; + case NoiseFloorEstimator::UPDATED: + LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", + rssi, _floor_est.floor(), _floor_est.tick() - 1); + break; + case NoiseFloorEstimator::REJECTED: + break; } - - /* Threshold filter with warmup and periodic bypass. - * - * _ema_unguarded counts up from 0 on every tick. - * Ticks 0..W-1 (warmup): all samples accepted for fast convergence - * after seed/reset — prevents a bad seed from locking out the - * real noise floor via a too-tight threshold. - * Ticks W+: threshold filter active. Every Pth tick one sample - * bypasses the filter so the floor can track sustained upward - * shifts (new interference, antenna change). - * The EMA's 1/8 weight naturally dampens isolated spikes. */ - const int W = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 — warmup ticks */ - const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; /* 16 — periodic interval */ - bool warmup = (_ema_unguarded < W); - bool periodic = (!warmup && (_ema_unguarded & (P - 1)) == 0); - _ema_unguarded++; /* wraps at 255 — harmless */ - - if (!warmup && !periodic && - rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { - return; - } - - /* EMA: floor += round_nearest((sample - floor) / W). - * Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0). - * Plain / has a ±7 dead zone (small drifts ignored). - * Round-to-nearest: add half the divisor before dividing, - * with sign-aware bias so both directions are symmetric. */ - int diff = rssi - _noise_floor; - int half = W / 2; /* 4 */ - int step = (diff + (diff > 0 ? half : -half)) / W; - _noise_floor += step; - if (_noise_floor < floor_min) _noise_floor = floor_min; - if (_noise_floor > -50) _noise_floor = -50; - - LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", - rssi, _noise_floor, _ema_unguarded - 1); } bool LoRaRadioBase::isReceiving() @@ -1291,13 +988,9 @@ bool LoRaRadioBase::isReceiving() if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { return false; } - /* Driver-side latch + non-destructive IRQ read covers the full - * payload phase. hwIsReceiving() never clears IRQ bits on the poll - * path itself; foreign preambles are released by the driver in - * software, on an SF-aware grace plus a max-airtime header deadline - * (SX126x patch 0013, LR11xx, LR20xx alike). It is not a hardware - * release — the chips latch these bits until ClearIrq, and continuous - * RX has no timeout to do it for them. */ + /* Driver latch + non-destructive IRQ read over the whole payload; + * hwIsReceiving() never clears IRQ bits. Foreign preambles are released + * by the driver's SF-aware grace and header deadline. */ if (hwIsReceiving()) { return true; } @@ -1306,55 +999,16 @@ bool LoRaRadioBase::isReceiving() /* ── Receiver hygiene ───────────────────────────────────────────────── * - * Two independent faults, two independent triggers, neither on the packet path. - * - * 1. STUCK AGC. Semtech's remedy is a warm sleep plus recalibration; it is not - * in the datasheets, so it is not up for removal on datasheet reasoning. - * What IS a design choice is when to fire it, and the honest answer is that - * a periodic reset gets it backwards: a timer fires most often on a busy - * channel, which is exactly where a received packet has just PROVED the AGC - * is working, and it fires no more often on a silent one, where a - * desensitised receiver is invisible and nothing else will reveal it. - * - * So trigger on silence instead. Arduino MeshCore's periodic - * `agc_reset_interval` addressed the same fault and shipped defaulted to 0 - * (off), which is a fair summary of how well a plain timer serves it. - * - * This used to claim the reset was free because an idle node "has no traffic - * to miss". That is false, and hardware disproved it: the reset fires at the - * END of a silent stretch, which is precisely when traffic resumes, and - * hwResetAgc() takes the radio out of RX for the whole warm-sleep + - * Calibrate(ALL) + image-cal + re-entry sequence. A packet was measured lost - * to exactly that window on 2026-08-23 (T1000-E, 92 ms after the re-arm). - * - * Silence is therefore treated as a prerequisite, not as proof. Firing also - * requires corroboration from evidence already on hand: the noise-floor - * sampler reads RSSI every interval regardless, and a desensitised front end - * reports a frozen value. Both conditions together, and the operation - * essentially never runs on a healthy node — which is the only acceptable - * cost for a watchdog guarding a fault nobody has observed in the field. - * - * Deliberately NOT reset here: the noise floor. The previous periodic - * implementation (removed in fe6e585) zeroed it on every fire, forcing a - * fresh seed and a full EMA warmup each time — that, not the AGC work, is - * what made it a net loss. Arduino zeroes it too; we do not. - * - * 2. CALIBRATION DRIFT. Image/front-end and PLL/AAF calibration are valid for - * a temperature range, not forever: "Image calibration is necessary if there - * is a frequency change > 10MHz, or a temperature change > 10 C", and the - * LR2021 additionally advises redoing PLL and AAF beyond +/-20 C. A node - * that boots on a hot afternoon and runs into a cold night crosses both. - * Nothing does this automatically: the LR2021's temperature-compensation - * block (DS 6.12) only corrects crystal drift from TX self-heating, and it - * refuses to run at all when a TCXO is fitted. - * - * IMAGE_CAL_TEMP_DELTA_C is deliberately tighter than either datasheet - * figure — cheap insurance, and the reading is a junction temperature that - * lags ambient. */ -/* Silence alone is NOT evidence of a fault — see the rationale block above. - * Ten minutes, not one: a genuinely deaf receiver stays deaf, so waiting costs - * nothing, whereas a 60 s threshold fired 4-8 times per quarter hour on a - * perfectly healthy node (measured 2026-08-23). */ + * Two unrelated faults, neither handled on the packet path (rationale and + * measurements: LLD 03 §13): + * 1. Stuck AGC (SX126x): reset only after long silence AND a frozen + * noise-floor reading; the reset costs an RX window, so silence alone + * is not proof. The noise floor is deliberately not reset. + * 2. Calibration drift (LR families): recalibrate once the board + * temperature has moved IMAGE_CAL_TEMP_DELTA_C, confirmed twice, + * outside the post-TX window. */ +/* Ten minutes: at 60 s it fired 4-8 times per quarter hour on a healthy + * node, and a genuinely deaf receiver stays deaf. */ #define AGC_IDLE_RESET_MS 600000U /* Consecutive identical noise-floor readings before silence is believed. At @@ -1364,55 +1018,29 @@ bool LoRaRadioBase::isReceiving() /* How often to report an ongoing RX silence. Diagnostic only. */ #define SILENCE_REPORT_MS 120000U -/* Image-calibration drift threshold. Nothing to do with the AGC reset above — - * this is the front end. SX126x DS §9.2.1 / LR11xx UM: image calibration is - * required after a frequency change > 10 MHz or a temperature change > 10 C. - * Half the datasheet figure is used so drift is corrected before it reaches the - * point where the datasheet says the calibration is already stale. */ +/* Half the datasheet's 10 C (SX126x DS §9.2.1, LR11xx UM), so drift is + * corrected before the calibration is stale. */ #define IMAGE_CAL_TEMP_DELTA_C 5 -/* Temperature-drift poll cadence. The maintenance pass itself runs every - * CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS (15 s by default); reading the - * junction temperature that often is 240 chip commands an hour to watch a - * quantity that physically cannot move IMAGE_CAL_TEMP_DELTA_C in minutes. - * Every one of those commands is an opportunity to collide with a duty-cycled - * radio's autonomous sleep transition, so the cheapest read is the one not - * issued. The driver-side BUSY guards make the collision safe; this makes it - * rare. */ +/* Temperature is read hourly: it cannot move IMAGE_CAL_TEMP_DELTA_C in + * minutes, and each chip command risks a duty-cycle sleep collision. */ #define IMAGE_CAL_POLL_MS 3600000U /* 1 h between routine reads */ #define IMAGE_CAL_CONFIRM_MS 15000U /* re-read before acting on a delta */ #define IMAGE_CAL_TX_QUIET_MS 60000U /* let PA self-heating decay first */ -/* Two independent jobs share this hook because they share one precondition — - * the chip must be idle enough to accept a command — and one cadence source, - * the maintenance loop. They are otherwise unrelated: agcIdleMaintenance() - * unsticks a receiver that has stopped hearing anything, imageCalMaintenance() - * corrects front-end image calibration against temperature drift. Keep them in - * separate functions so neither's thresholds read as if they governed the - * other. */ +/* Two unrelated jobs that share one precondition (chip idle) and one + * cadence; kept in separate functions. */ void LoRaRadioBase::radioMaintenance() { - /* Never mid-transmit or mid-receive: both operations warm-sleep the - * chip, which aborts a TX and destroys an in-flight packet. The RX - * half cannot be inferred from the activity counters below — those - * only move at RX_DONE/CRC_ERR, so a packet whose preamble is landing - * right now still reads as silence. isReceiving() is the latch that - * knows (HEADER_VALID promotion + preamble grace); it is the same gate - * checkSend() and the noise-floor sampler use before touching the chip. - * Bailing here just defers the work to the next maintenance pass. */ + /* Never mid-TX or mid-RX: both jobs warm-sleep the chip. The RX counters + * cannot see a packet landing now; isReceiving() can. */ if (atomic_get(&_tx_active) || isReceiving()) { return; } uint32_t now = (uint32_t)k_uptime_get_32(); - /* Deafness telemetry, every family, no action taken. - * - * agcIdleMaintenance() used to be the only thing that noticed a long - * silence, and it is now family-gated — so on the LR parts nothing - * reports it at all. A silence marker on both boards is what lets a - * side-by-side capture say WHICH radio stopped hearing, and the - * accompanying counters say what the sampler was seeing at the time. */ + /* Silence telemetry on every family (diagnostic only). */ uint32_t rx_now = (uint32_t)atomic_get(&_packets_recv) + (uint32_t)atomic_get(&_packets_recv_errors); @@ -1433,7 +1061,7 @@ void LoRaRadioBase::radioMaintenance() (unsigned)_rssi_reads_busy, (unsigned)_rssi_bursts_abandoned, (unsigned)_rssi_dc_blocked, - (int)_noise_floor); + _floor_est.floor()); } } @@ -1441,19 +1069,15 @@ void LoRaRadioBase::radioMaintenance() imageCalMaintenance(now); } -/* Receiver watchdog: a stuck AGC stops the demodulator hearing anything at all, - * so prolonged total silence is the symptom. Nothing here concerns the front - * end or temperature. */ +/* Receiver watchdog: a stuck AGC shows up as total silence. */ void LoRaRadioBase::agcIdleMaintenance(uint32_t now) { if (!hwNeedsAgcReset()) { return; } - /* Any demodulation activity counts as proof of life, errored frames - * included — a CRC failure still means RF reached the demodulator, which - * is precisely what a stuck AGC would prevent. Counting only good - * packets would fire resets on a node that is merely out of range. */ + /* Errored frames count as proof of life: RF reached the demodulator. + * Counting only good packets would reset a node that is out of range. */ uint32_t rx_total = (uint32_t)atomic_get(&_packets_recv) + (uint32_t)atomic_get(&_packets_recv_errors); @@ -1473,53 +1097,31 @@ void LoRaRadioBase::agcIdleMaintenance(uint32_t now) } } -/* Front-end image calibration against temperature drift. Separate from the AGC - * reset above in every respect: different symptom (degraded image rejection, not - * a deaf demodulator), different datasheet section, different remedy - * (hwRecalibrate(), not hwResetAgc()). */ +/* Front-end image calibration against temperature drift. */ void LoRaRadioBase::imageCalMaintenance(uint32_t now) { if (!hwHasDriftRecal()) { return; } - /* Polled on its own slow cadence rather than once per - * maintenance pass — see IMAGE_CAL_POLL_MS. Backends that cannot measure - * (SX126x/SX127x) answer INT16_MIN from a plain inline with no bus - * traffic, so the early return below costs them nothing. */ + /* Own slow cadence, see IMAGE_CAL_POLL_MS. */ if (_image_cal_started && (now - _image_cal_last_ms) < _image_cal_wait_ms) { return; } - /* Wait out our own PA. The junction is still warm for a while after a - * transmit, and that self-heating is not the ambient drift the - * recalibration exists to track — acting on it would recalibrate against - * a temperature the chip will not be at a minute later. Deferring - * returns here without stamping, so the retry is the next pass (seconds) - * rather than the next poll window (an hour). */ + /* Wait out our own PA self-heating. Returns without stamping, so the + * retry is the next pass, not the next poll window. */ if (_last_tx_start_ms != 0 && (now - _last_tx_start_ms) < IMAGE_CAL_TX_QUIET_MS) { return; } - /* Temperature comes from the BOARD, never from the radio. - * - * Only the delta matters here, never the absolute value, and the MCU die - * sensor tracks the same ambient the front end sits in — so it answers - * the question just as well as the radio's junction sensor while costing - * the radio nothing at all. Reading it off the chip meant a periodic SPI - * command aimed at a part that spends most of its time in an autonomous - * duty-cycle sleep phase; that read wedged the LR1110 BUSY-high 7 times - * in 47 minutes of measurement on 2026-08-23, each costing 12-18 s of - * deafness, and it cost a packet. The junction sensor was also the worse - * instrument for the job: it sees PA self-heating, which is exactly the - * transient this path must not react to. */ + /* Board (MCU) temperature, never the radio's: only the delta matters, + * and a radio read during duty-cycle sleep wedged the LR1110 (LLD 03 §13). */ float board_temp = _board ? _board->getMCUTemperature() : NAN; if (isnan(board_temp)) { - /* No board temperature source — drift handling simply does not run - * on this hardware, as it did not before on families without a - * junction sensor either. */ + /* No board temperature source: drift handling does not run. */ return; } @@ -1544,11 +1146,7 @@ void LoRaRadioBase::imageCalMaintenance(uint32_t now) return; } - /* Measure twice before acting. A single reading over the threshold can - * be a transient — residual self-heating the quiet window did not fully - * cover, or a one-off bad sample — and hwRecalibrate() takes the radio - * out of receive. Re-read shortly and act only if the second reading - * agrees. */ + /* Measure twice: hwRecalibrate() costs an RX window. */ if (!_image_cal_confirming) { _image_cal_confirming = true; _image_cal_wait_ms = IMAGE_CAL_CONFIRM_MS; @@ -1567,17 +1165,9 @@ void LoRaRadioBase::imageCalMaintenance(uint32_t now) void LoRaRadioBase::recoverRxState() { - /* Called by the Dispatcher on CAD timeout when isReceiving() has been - * pinned true past the recovery threshold (4 s). We must escape a - * stuck driver state == RX — a bare startReceive() can't do this - * because the driver's lora_recv_async entry CAS is REST_STATE → RX, - * which fails when state is already RX and would set _in_recv_mode = 0 - * on the -EBUSY return. Walk the chip back through REST first. - * - * The RX-restart sites in the driver (recv_async, recv_duty_cycle, - * restart_rx) all bulk-clear IRQ status and reset the rx_packet_active - * latch as part of their entry, so this sequence cleanly flushes a - * stuck PREAMBLE_DETECTED bit or a stale latch. */ + /* Dispatcher CAD-timeout recovery (isReceiving() pinned > 4 s). Walk + * the chip through REST: a bare startReceive() fails the driver's + * REST->RX CAS. The RX restart clears the IRQs and the latch. */ hwCancelReceive(); atomic_set(&_in_recv_mode, 0); _config_cached = false; @@ -1593,114 +1183,26 @@ bool LoRaRadioBase::isChannelActive(int threshold) return false; } int16_t rssi = hwGetCurrentRSSI(); - return rssi > (_noise_floor + threshold); + return rssi > (_floor_est.floor() + threshold); } /* ── Adaptive CAD (LBT detPeak calibration) ───────────────────────────── */ -/* The offset window is [CAD_LEVEL_MIN, CAD_LEVEL_MAX], but an offset is only - * meaningful while base+offset still lands somewhere the driver will actually - * program. Past the hardware clamp several offsets collapse onto one peak, and - * the staircase cannot tell them apart — it reads sampling noise as curvature. - * Narrow the window so every level it can reach is a distinct configuration. - * - * Radios that report no clamp (hwCadPeakMin/Max == 0) keep the static window, - * which is also what a radio with no adaptive CAD at all gets. */ -int8_t LoRaRadioBase::cadLevelMinEff() -{ - uint8_t base = hwCadBasePeak(); - uint8_t pmin = hwCadPeakMin(); - - /* The clamp only binds when the lowest peak the static window can reach, - * base + CAD_LEVEL_MIN, would land below it. Note the sign: this was - * written `base - CAD_LEVEL_MIN` once, which with CAD_LEVEL_MIN negative - * evaluates to base + 8 — always above pmin, so the narrowing never - * happened and the whole function was inert. */ - if (base == 0 || pmin == 0 || - (int)base + CAD_LEVEL_MIN >= (int)pmin) { - return CAD_LEVEL_MIN; - } - return (int8_t)((int)pmin - (int)base); -} - -int8_t LoRaRadioBase::cadLevelMaxEff() -{ - uint8_t base = hwCadBasePeak(); - uint8_t pmax = hwCadPeakMax(); - - if (base == 0 || pmax == 0 || (int)pmax - (int)base >= CAD_LEVEL_MAX) { - return CAD_LEVEL_MAX; - } - return (int8_t)((int)pmax - (int)base); -} - void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset, 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; - - _cad_auto = auto_enabled; - _cad_offset = offset; + _cad.configure(auto_enabled, offset, busycap_pct, stored_base); _probe_interval_s = probe_interval_s; - _cad_busycap_pct = busycap_pct; - /* One interval governs every periodic radio measurement, because there - * is only one measurement: the noise-floor sampler takes a median-of-8 - * and the CAD probe consumes that same reading (see cadMaintenance). - * Splitting them into two knobs could only ever express a rate the - * hardware does not actually run at. - * - * 0 means "CAD probing off" — the floor sampler still has to run, so it - * falls back to the build-time default. */ + /* One interval for the one periodic measurement: the CAD probe rides on + * the floor sample. 0 = probing off, but the floor still needs sampling. */ _measure_interval_ms = probe_interval_s ? (uint32_t)probe_interval_s * 1000U : (uint32_t)CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS; - /* Effective, not _cad_offset: applyCadPrefs() reaches here from ordinary - * `set cad.*` commands, which an operator can issue while a `tempradio` - * window is open. Programming the configured offset then would put the - * visited preset back on a threshold that does not belong to it. */ - hwCadSetPeakOffset(cadEffectiveOffset()); - LOG_INF("cad: auto=%d offset=%d base=%u measure_interval=%ums busycap=%u%%", - (int)auto_enabled, (int)offset, (unsigned)base, + (int)auto_enabled, (int)_cad.offset(), (unsigned)hwCadBasePeak(), (unsigned)_measure_interval_ms, (unsigned)busycap_pct); } @@ -1709,260 +1211,15 @@ uint8_t LoRaRadioBase::cadBasePeak() return hwCadBasePeak(); } -void LoRaRadioBase::resetCadStats() -{ - memset(_cad_stats, 0, sizeof(_cad_stats)); - _cad_probe_rr = 0; - - /* Drop any probe still in flight with the table it belongs to. - * cadMaintenance() books a verdict into whatever stats exist when it - * resolves, not the ones that were current when it was armed — so a - * probe left pending across reconfigure() writes an old-preset sample - * into the fresh table, and across `set cad.reset` writes a pre-reset - * sample into a table the operator just cleared. */ - _cad_pending_level = INT8_MIN; - _cad_pending_deadline_ms = 0; -} - -void LoRaRadioBase::decayCadStats() -{ - for (int i = 0; i < CAD_NUM_LEVELS; i++) { - _cad_stats[i].probes >>= 1; - _cad_stats[i].busy >>= 1; - _cad_stats[i].fp >>= 1; - _cad_stats[i].tp >>= 1; - } -} - -int8_t LoRaRadioBase::pickCadProbeLevel() -{ - _cad_probe_rr++; - - if (!_cad_auto) { - /* The sweep window is ABSOLUTE, so an operating offset parked - * outside it is never probed at all — measured: 49 minutes at - * offset -8 produced 0 probes at -8, which left cadSafetyStep() - * with no evidence and made it a no-op in precisely the - * configuration it exists for (auto off + an offset that cannot - * clear). - * - * When the operator has parked outside the window, probe where - * they actually are instead: the swept curve does not contain - * their operating point, so it cannot inform the hand-tuning it - * was built for either. Inside the window the sweep already - * covers the operating level and is left exactly as it was. */ - if (_cad_offset < CAD_SWEEP_MIN || _cad_offset > CAD_SWEEP_MAX) { - return _cad_offset; - } - - /* Dry-run: even sweep across the observation window so the - * user sees the whole FP-vs-detPeak curve in `get cad`. */ - int span = CAD_SWEEP_MAX - CAD_SWEEP_MIN + 1; - - return (int8_t)(CAD_SWEEP_MIN + (_cad_probe_rr % span)); - } - - /* Auto: sample the operating level AND both neighbours so the staircase - * can read the local FP curvature (slope below vs. above) and seek the - * knee. op is the shared term of both slopes → weight it half; each - * neighbour a quarter. Out-of-range neighbours fall back to op. */ - int8_t lvl; - switch (_cad_probe_rr & 3) { - case 1: lvl = (int8_t)(_cad_offset - 1); break; /* more sensitive */ - case 3: lvl = (int8_t)(_cad_offset + 1); break; /* less sensitive */ - default: lvl = _cad_offset; break; /* operating (0, 2) */ - } - if (lvl < cadLevelMinEff() || lvl > cadLevelMaxEff()) { - lvl = _cad_offset; - } - return lvl; -} - -void LoRaRadioBase::cadStaircaseStep() -{ - /* Knee-seeking controller — see the CAD_KNEE_SLOPE / CAD_PLATEAU_CLEAN - * notes in radio_common.h. Reads local curvature from three rungs and - * steps toward the knee (the most sensitive detPeak whose FP has already - * bottomed out), using slopes so the decision is site-floor-independent. */ - int oi = _cad_offset - CAD_LEVEL_MIN; - - auto warm = [&](int idx) -> bool { - return idx >= 0 && idx < CAD_NUM_LEVELS && - _cad_stats[idx].probes >= CAD_STEP_MIN_PROBES; - }; - /* Per-level FALSE-positive rate in permille, or -1 when too few samples. */ - auto fp_rate = [&](int idx) -> int { - if (!warm(idx)) { - return -1; - } - return (int)(((uint32_t)_cad_stats[idx].fp * 1000U) - / _cad_stats[idx].probes); - }; - /* Per-level TOTAL busy (defer) rate in permille — false + real traffic. */ - auto busy_rate = [&](int idx) -> int { - if (!warm(idx)) { - return -1; - } - return (int)(((uint32_t)_cad_stats[idx].busy * 1000U) - / _cad_stats[idx].probes); - }; - - int r_op = fp_rate(oi); - if (r_op < 0) { - return; /* operating level not warm yet — no basis to step */ - } - int r_up = fp_rate(oi + 1); /* one step less sensitive */ - int r_dn = fp_rate(oi - 1); /* frontier, one step more sensitive */ - - /* Airtime protection used to live here as the highest-priority rung. It - * has moved to cadSafetyStep(), which the callers run BEFORE this and - * without the _cad_auto gate — it is a safety, not an optimisation, and - * gating it meant a node whose detPeak could never clear had no way back - * once the operator turned auto off. What remains here is purely the - * knee-seeking optimiser. */ - int cap_permille = (int)_cad_busycap_pct * 10; - - /* Step UP (less sensitive) when the level above is markedly cleaner — - * we're on the steep part of the curve, below the knee. */ - if (_cad_offset < cadLevelMaxEff() && r_up >= 0 && - r_op - r_up >= CAD_KNEE_SLOPE_PERMILLE) { - _cad_offset++; - hwCadSetPeakOffset(_cad_offset); - LOG_INF("cad: step up -> offset %d (op %d dn->up %d)", - (int)_cad_offset, r_op, r_up); - return; - } - - /* Step DOWN (more sensitive) only on a flat plateau that is already - * clean: the frontier is no worse than operating (nothing to lose) AND - * FP here is low enough that reclaiming sensitivity is cheap. The clean - * guard keeps a flat-but-noisy curve from descending to the sensitive - * rail; the busy-hysteresis guard keeps us from descending into the - * airtime cap and bouncing straight back up. */ - int b_dn = busy_rate(oi - 1); - bool busy_ok = (cap_permille == 0) || - (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) { - _cad_offset--; - hwCadSetPeakOffset(_cad_offset); - LOG_INF("cad: step down -> offset %d (op %d dn %d busy %d)", - (int)_cad_offset, r_op, r_dn, b_dn); - return; - } - - /* Otherwise: at the knee (steep below, flat above) or a noisy flat - * plateau — hold. */ -} - -bool LoRaRadioBase::cadSafetyStep() -{ - /* The airtime-protection rung, run unconditionally — see the constant - * block in radio_common.h for why it is not gated on _cad_auto. - * - * This is the proactive half of the pair. cadRelaxOnTxStarvation() only - * fires once the node actually has traffic it cannot send, which on a - * silent mesh may be up to flood_advert_interval away (47 h by default); - * this one works off probe statistics, which accumulate whether or not - * there is anything to transmit. */ - if (_cad_offset >= cadLevelMaxEff()) { - return false; - } - - int oi = _cad_offset - CAD_LEVEL_MIN; - if (oi < 0 || oi >= CAD_NUM_LEVELS) { - return false; - } - - uint16_t probes = _cad_stats[oi].probes; - if (probes == 0) { - return false; - } - - int b_op = (int)(((uint32_t)_cad_stats[oi].busy * 1000U) / probes); - int cap_permille = (int)_cad_busycap_pct * 10; - - /* Unambiguous: this level trips on nearly every probe, so it cannot - * clear for a transmit either. Acts on few samples and ignores the - * cap. */ - bool pathological = probes >= CAD_SAFETY_MIN_PROBES && - b_op >= CAD_SAFETY_PATHOLOGICAL_PERMILLE; - /* Marginal: a real airtime-vs-capture tradeoff. Keeps the original - * evidence bar and honours `cad.busycap 0` as the operator's choice. */ - bool over_cap = cap_permille && probes >= CAD_STEP_MIN_PROBES && - b_op > cap_permille; - - if (!pathological && !over_cap) { - return false; - } - - _cad_offset++; - hwCadSetPeakOffset(_cad_offset); - if (pathological) { - LOG_WRN("cad: safety step up -> offset %d (busy %d permille over " - "%u probes — detector too sensitive to clear, auto=%d)", - (int)_cad_offset, b_op, (unsigned)probes, (int)_cad_auto); - } else { - LOG_INF("cad: step up -> offset %d (airtime, busy %d cap %d)", - (int)_cad_offset, b_op, cap_permille); - } - return true; -} - -bool LoRaRadioBase::cadRelaxOnTxStarvation() -{ - /* Deliberately does NOT consult _cad_auto. Every other mover of - * _cad_offset is an optimiser and correctly stays out of the way when - * the operator has taken manual control; this one exists precisely for - * the case where manual control produced a node that cannot transmit, - * so honouring `cad.auto off` here would disable the safety exactly - * where it is needed. It also ignores cad.busycap and the per-level - * probe statistics: 120 warm probes are half an hour away, and the - * caller has already established the harm directly. - * - * One step at a time, never a jump to base: on a genuinely congested - * site the operator's sensitive setting may be almost right, and the - * smallest change that restores transmission is the one to make. - * - * This one safety DOES run during a temporary override — unlike the - * staircase, which cadMaintenance() suspends there. A node muted for - * the length of a `tempradio` window, or until the reboot a `set radio` - * is waiting for, is muted for real. It steps the visitor's offset, so - * the recovery lasts exactly as long as the visit and still cannot reach - * prefs: getCadOffset() keeps reporting the configured value. */ - int8_t &off = _has_radio_override ? _cad_temp_offset : _cad_offset; - - if (off >= cadLevelMaxEff()) { - return false; - } - - off++; - hwCadSetPeakOffset(cadEffectiveOffset()); - LOG_WRN("cad: TX starvation override -> offset %d (auto=%d%s) — LBT was " - "refusing every transmit", - (int)off, (int)_cad_auto, - _has_radio_override ? ", temp preset" : ""); - return true; -} - void LoRaRadioBase::cadMaintenance() { if (_probe_interval_s == 0) { return; } - /* Nothing to learn on a preset the node is only visiting: the statistics - * would describe that preset, and every mover of _cad_offset below is - * persisted by Dispatcher::maintenanceLoop() -> onCadOffsetChanged() -> - * prefs, so letting the staircase run inside a `tempradio` window would - * write a visitor's offset to flash and leave it there after the revert. - * The chip runs at the visited preset's own base (setRadioOverride). - * - * The pending sample is dropped rather than carried: it was taken under - * the other preset, and consuming it after the revert would admit a - * probe on a quiet verdict that is a whole interval stale. */ + /* Nothing to learn on a preset the node is only visiting, and any move + * would be persisted by onCadOffsetChanged(). A sample from the visit is + * stale after the revert, so drop it. */ if (_has_radio_override) { _sample_fresh = false; return; @@ -1974,105 +1231,34 @@ void LoRaRadioBase::cadMaintenance() if (_cad_last_decay_ms == 0) { _cad_last_decay_ms = now; } else if (now - _cad_last_decay_ms > (int64_t)CAD_STATS_DECAY_MS) { - decayCadStats(); + _cad.decayStats(); _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; - - /* Safety first, and outside the _cad_auto gate. When it acts, - * skip the optimiser this pass — it has just moved the operating - * level and the three-rung window it reads is stale. */ - if (!cadSafetyStep() && _cad_auto) { - cadStaircaseStep(); - } + /* A CAD_RX probe from an earlier pass: past the chip's own deadline a + * terminal interrupt (packet or cadTimeout) is guaranteed, so the + * answer is read once, with no chip access. */ + if (_cad.hasPending() && now >= _cad.pendingDeadline()) { + _cad.resolvePending(hwCadRxOutcome()); + _cad.adapt(); } - /* 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. */ - - /* Ride on the noise-floor sampler rather than measuring independently. - * - * A fresh sample means the sampler ran THIS pass, which already proves - * everything the probe needs: the radio was idle in RX, not transmitting, - * not mid-packet, and out of its duty-cycle sleep window — the sampler - * applies exactly those guards before it reads. So there is nothing left - * to re-check, no separate deadline, and no retry budget: if no sample - * landed this pass, the probe simply waits for the next one. - * - * This is what makes the wake cost one per interval instead of two. It - * also upgrades the ground-truth prefilter from a single raw RSSI read to - * the sampler's median-of-8 — the probe is trying to establish that the - * channel is quiet, and a busy verdict taken over real traffic teaches - * nothing about false positives, so the outlier rejection matters here. */ + /* The probe rides on the floor sampler: a fresh sample means this pass + * already proved idle RX, no TX, no packet, out of duty-cycle sleep, and + * gave a median-of-N quiet verdict. No sample, no probe. */ if (!_sample_fresh) { return; } _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) { + /* One probe in flight: a second CAD would abort the first one's RX. */ + if (_cad.hasPending()) { 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.) */ + /* No CAD_RX ground truth, no probing: an unresolvable busy reads as + * FP 0 everywhere and walks the staircase to the sensitive rail. Today + * that is the LR2021, whose 524 ms cad_timeout would truncate packets. */ if (hwCadRxTimeoutMs() == 0) { return; } @@ -2081,32 +1267,20 @@ void LoRaRadioBase::cadMaintenance() 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. */ + /* The sampler's isReceiving() is a burst old; a calibration probe must + * never cost a reception, and skipping one is free. */ if (isReceiving()) { return; } - _cad_last_probe_ms = now; - - int8_t level = pickCadProbeLevel(); + int8_t level = _cad.pickProbeLevel(); 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. */ + /* hwCadProbe() blocks for the whole CAD: take the time afterwards. */ int64_t probe_done_ms = k_uptime_get(); - /* 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. */ + /* free (0), busy on CAD_ONLY (1), error: the chip is in standby, so + * re-enter RX. busy (2): the chip is already in RX on the signal it + * found; re-entering would destroy the reception being measured. */ if (ret != 2) { atomic_set(&_in_recv_mode, 0); startReceive(); @@ -2119,45 +1293,17 @@ void LoRaRadioBase::cadMaintenance() return; } - CadLevelStats &s = _cad_stats[level - CAD_LEVEL_MIN]; - - if (s.probes >= 0xFFF0) { - decayCadStats(); - } - s.probes++; - - if (ret > 0) { - s.busy++; - } + _cad.recordProbe(level, ret > 0); 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; + /* The chip's cadTimeout starts when the blocking probe returns; + * +100 ms is interrupt-to-work-queue latency. */ + _cad.setPending(level, + probe_done_ms + (int64_t)hwCadRxTimeoutMs() + 100); return; } - if (!cadSafetyStep() && _cad_auto) { - cadStaircaseStep(); - } + _cad.adapt(); } /* int64 uptime delta → the uint32 "ms from now" the maintenance contract wants. @@ -2193,20 +1339,11 @@ uint32_t LoRaRadioBase::msUntilNextMaintenance() return next; } - /* The CAD probe deliberately contributes NO deadline of its own. It runs - * 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. - * - * 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) { + /* The CAD probe has no deadline of its own (it rides on the floor + * sample). Only a pending CAD_RX verdict adds one wake. */ + if (_cad.hasPending()) { next = mesh::maintenanceSooner( - next, clampDeadline(_cad_pending_deadline_ms - now)); + next, clampDeadline(_cad.pendingDeadline() - now)); } /* Stats decay. _cad_last_decay_ms == 0 means the first call latches it @@ -2227,37 +1364,10 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) return snprintf(buf, cap, "cad n/a"); } - /* Terse on purpose — remote replies are capped at ~160 B over LoRa. - * Header: a:on o:1 pk:22(b21/4s) sp:0.9/84%(312) bc:25% - * a auto on/off o offset pk operating peak - * b family base 4s symbols bc busy cap - * a:tmp a temporary radio override is running a preset the node is - * only visiting: adaptation is suspended and o/pk describe that - * visit, not the configured preset (see setRadioOverride). - * sp RSSI burst quality: mean spread in dB across the median-of-N - * reads, the share of bursts whose spread was 0, and the burst - * count. The count is not decoration: a share without its - * denominator cannot be read, and the burst rate is not - * derivable from uptime because the sampler's guards (TX, mid-RX, - * duty-cycle sleep) block an unknown fraction of attempts. - * Level: *+1(22) 22p 18b 16f 2t 72% - * '*' = operating rung level(peak) probes busy fp tp fp-rate%%. - * - * sp replaced the probe interval here because the interval is a pref - * you already set and can read back with `get probe.interval`, whereas - * burst spread is only observable from inside the sampler. - * - * It answers one question: are the N reads independent? A non-zero - * mean proves they are, whatever the zero-spread share — a steady - * channel reads identically at integer-dB resolution, which is correct - * rather than broken. Only mean 0.0 with a high share indicts the - * sampler: that is N copies of one sample from inside a single RSSI - * averaging window (see rssi_avg_window_us() in radio_common.h). - * Measured on-air 2026-07-29 at BW 62.5: 0.6/90% quiet, 0.9/84% with - * the floor at -103 — independent, and responding the right way. - * Mean is tenths of a dB. Counters halve at RSSI_BURST_STATS_CAP, so - * the count is bounded to four digits and the figures describe a - * recent window rather than everything since boot. */ + /* Terse: remote replies are capped at ~160 B. Fields (docs/ADAPTIVE_CAD.md): + * a auto on/off/tmp (visiting a preset), o offset, pk peak (b base / 4 + * symbols), sp mean burst spread in dB / zero-spread share, bc busy cap. + * Levels: *+1(22) 22p 18b 16f 2t 72% = rung(peak) probes busy fp tp fp%. */ unsigned spread_mean10 = _rssi_bursts ? (unsigned)((_rssi_spread_sum * 10U + _rssi_bursts / 2U) / _rssi_bursts) @@ -2267,28 +1377,19 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) _rssi_bursts) : 0; - /* The burst count is bench diagnostics, and the header competes with - * the three level rows for a 161 B remote reply — with wide level - * counters the full header pushes the last row into truncation. So - * print it only into the roomy local-console buffer; a remote reader - * still gets the mean and the share, which is the actual verdict. */ + /* The burst counters fit only the local console buffer; a remote reader + * still gets the mean and the share. */ bool room_for_count = (cap >= 200); - const int eff_off = cadEffectiveOffset(); + const int eff_off = _cad.effectiveOffset(); n += snprintf(buf + n, cap > n ? cap - n : 0, "a:%s o:%d pk:%d(b%u/4s) sp:%u.%u/%u%%", - _has_radio_override ? "tmp" : (_cad_auto ? "on" : "off"), + _cad.visiting() ? "tmp" : (_cad.autoEnabled() ? "on" : "off"), eff_off, (int)base + eff_off, base, spread_mean10 / 10U, spread_mean10 % 10U, degen_pct); if (room_for_count) { - /* 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. */ + /* (bursts r/b/a/d) */ n += snprintf(buf + n, cap > n ? cap - n : 0, "(%u r%u/b%u/a%u/d%u)", (unsigned)_rssi_bursts, @@ -2298,26 +1399,16 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) (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. */ + (unsigned)_cad.busycapPct()); + /* No CAD_RX ground truth on this radio: probing is off. */ 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 - * clamped to the EFFECTIVE range while still showing 3 rungs, so at either - * end it slides inward rather than dropping a line. - * - * Effective, not the static constants: past the hardware detPeak clamp - * several offsets program the same peak, and showing them as separate - * rungs invited exactly the wrong reading — three lines of distinct - * statistics for one physical configuration. With the range narrowed, - * every rung printed is a real one and `pk` below is what the chip got. */ - const int lmin = cadLevelMinEff(); - const int lmax = cadLevelMaxEff(); + /* The 3 rungs around the operating level, inside the effective window + * (past the driver clamp several rungs would repeat one peak). */ + const int lmin = _cad.levelMinEff(); + const int lmax = _cad.levelMaxEff(); int cur = eff_off; if (cur < lmin) cur = lmin; if (cur > lmax) cur = lmax; @@ -2328,7 +1419,7 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) if (hi > CAD_LEVEL_MAX) hi = CAD_LEVEL_MAX; for (int lvl = lo; lvl <= hi; lvl++) { - CadLevelStats &s = _cad_stats[lvl - CAD_LEVEL_MIN]; + const CadController::LevelStats &s = _cad.stats(lvl); /* Integer FP rate, rounded to nearest percent (0 when unprobed). */ unsigned fp_pct = s.probes diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 4a540df..e1abfa6 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -12,14 +12,17 @@ #include #include #include "radio_common.h" +#include "CadController.h" +#include "NoiseFloorEstimator.h" namespace mesh { -class LoRaRadioBase : public Radio { +class LoRaRadioBase : public Radio, private CadHw { public: - LoRaRadioBase(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); + LoRaRadioBase(const struct device *lora_dev, MainBoard &board); + /* Mandatory, before begin(): the radio reads freq/bw/sf/cr/power + * through this pointer from then on (LoRaConfig only seeds prefs). */ void setPrefs(NodePrefs *prefs) { _prefs = prefs; } /* Callbacks */ @@ -35,21 +38,11 @@ public: /* Radio interface (all implemented in base) */ void begin() override; void reconfigure(); - void reconfigureWithParams(float freq, float bw, uint8_t sf, uint8_t cr); - /* Temporary radio override — applies freq/bw/sf/cr without mutating - * _prefs. Used by tempradio so the saved prefs survive intact and - * concurrent savePrefs() calls don't poison flash. clearRadioOverride() - * reverts to whatever _prefs holds at that moment. */ - /* visiting_new_preset = the override moves the radio onto a preset it - * was not already running (`tempradio`), so the learned detPeak offset - * does not apply there and the visit starts at that preset's own base. - * Pass false when the override exists only to HOLD the radio on the - * preset it is already running while _prefs move ahead of it - * (freezeRadioParams, i.e. `set radio` / `set freq` + reboot): nothing - * changed on air, so the offset that was learned for it keeps running - * until the reboot re-reads prefs. Either way CAD adaptation is - * suspended while the override is up — see cadMaintenance(). */ + /* Temporary radio override: freq/bw/sf/cr without touching _prefs + * (tempradio, and the `set radio` freeze until reboot). visiting_new_preset: + * the radio moves to a preset it was not running, so CAD starts at that + * preset's base; false holds the running one. CAD adaptation is suspended. */ void setRadioOverride(float freq, float bw, uint8_t sf, uint8_t cr, bool visiting_new_preset = true); void clearRadioOverride(); @@ -69,10 +62,8 @@ public: uint32_t getPacketsRecv() const override { return (uint32_t)atomic_get(&_packets_recv); } uint32_t getPacketsSent() const override { return (uint32_t)atomic_get(&_packets_sent); } uint32_t getPacketsRecvErrors() const override { return (uint32_t)atomic_get(&_packets_recv_errors); } - /* Virtual so radios with extra accumulators can clear them on the same - * `clear stats`. Reached through a LoRaRadioBase& from - * RepeaterMesh::clearStats() via getRadioDriver(), so a shadowing - * non-virtual override would silently never run. */ + /* Virtual: reached through LoRaRadioBase& (RepeaterMesh::clearStats()), + * so radios with extra accumulators clear them on the same `clear stats`. */ virtual void resetStats() { atomic_set(&_packets_recv, 0); atomic_set(&_packets_sent, 0); @@ -95,22 +86,13 @@ public: virtual bool setRxBoost(bool enable); bool isRxBoostEnabled() const { return _rx_boost_enabled; } - /* External FEM/LNA gain during RX. Boards whose front-end module has a - * software-selectable receive path wire that select line to the radio - * (lna-bypass-gpios); disabling this routes RX around the FEM's LNA, - * trading its gain for its supply current while leaving the antenna - * connected. The transmit path and the driver's idle gating are - * unaffected. Returns false on radios that do not implement the knob - * (everything but the native SX126x today) and on SX126x boards that - * wire no such line -- including boards whose only FEM control is the - * chip enable, where there is no gain to trade, only a path to cut. */ + /* External FEM LNA in RX (lna-bypass-gpios): false routes RX around the + * LNA, trading its gain for its current. Returns false where the radio + * or the board has no such select line. */ virtual bool setFemRxEnable(bool enable) { (void)enable; return false; } - /* Multi-SF receive via LoRa side detectors. Only the LR2021 has them; - * every other radio reports the feature as unsupported. `num` = 0 - * disables. Returns false if the radio has no side detectors or the - * requested set violates a chip constraint (see - * lr20xx_configure_side_detectors). */ + /* Multi-SF receive via side detectors (LR2021 only). num = 0 disables. + * False when unsupported or the set violates a chip constraint. */ virtual bool configSideDetectors(const uint8_t *sfs, uint8_t num) { (void)sfs; (void)num; return false; @@ -128,13 +110,8 @@ public: int8_t getConfiguredTxPower() const; bool isTxActive() const override { return atomic_get(&_tx_active) != 0; } - /* Duty-cycle preamble false-positive counter. - * Incremented by the driver whenever RX_TX_TIMEOUT fires in - * duty-cycle mode and the chip is silently re-armed. High - * values indicate a noisy RF environment or too-loose preamble - * detection — each event extends real RX time past the nominal - * duty cycle, inflating current draw. - * Default returns 0 on radios that don't support the stat. */ + /* Duty-cycle false-preamble re-arms, counted by the driver (each one + * stretches RX time past the nominal cycle). 0 where unsupported. */ virtual uint32_t getDutyCycleTimeoutRestarts() const { return 0; } virtual void resetDutyCycleTimeoutRestarts() {} @@ -154,20 +131,9 @@ private: void imageCalMaintenance(uint32_t now); public: uint32_t msUntilNextMaintenance() override; - int8_t getCadOffset() const override { return _cad_offset; } - - /* Offset bounds the controller may actually use: the static - * [CAD_LEVEL_MIN, CAD_LEVEL_MAX] window narrowed to whatever the - * hardware clamp leaves distinguishable at the current base. Every - * range decision goes through these; the raw constants stay in use only - * for indexing _cad_stats[], which is sized to the static window. */ - int8_t cadLevelMinEff(); - int8_t cadLevelMaxEff(); - void resetCadStats() override; - bool cadRelaxOnTxStarvation() override; - /* Airtime-protection / stuck-detector rung, run on every maintenance pass - * regardless of _cad_auto. Returns true when it moved the offset. */ - bool cadSafetyStep(); + int8_t getCadOffset() const override { return _cad.offset(); } + void resetCadStats() override { _cad.resetStats(); } + bool cadRelaxOnTxStarvation() override { return _cad.relaxOnTxStarvation(); } int formatCadStatus(char *buf, int cap) override; protected: @@ -178,30 +144,11 @@ 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. */ + /* Read `n` RSSI samples `spacing_us` apart, bracketed once (on the LR + * families each single read tears the duty cycle down and back up). + * Returns samples written (< n: refused partway, abandon), or -EAGAIN + * when a preamble/header landed in the window (a busy channel, not a + * failing bus). The default per-sample loop suits SX126x and SX127x. */ 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(). */ @@ -233,32 +180,19 @@ protected: /** 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). */ - virtual uint8_t hwCadBasePeak() { return 0; } - - /** Absolute detPeak range the driver will actually program, inclusive. - * 0/0 means "no known limit" and the offset range stays as-is. - * - * This exists because the offset window and the hardware clamp are two - * different things, and when they disagree the controller explores - * levels that are physically identical: on the LR2021 at SF7 the base is - * 51 and the driver clamps to 48, so offsets -3 through -8 all programmed - * the same peak. The staircase then compared three rungs of the same - * configuration, found only sampling noise between them, and random-walked - * into the floor with nothing to climb back out on. */ - virtual uint8_t hwCadPeakMin() { return 0; } - virtual uint8_t hwCadPeakMax() { return 0; } + /* CadHw (see CadController.h): offset, base detPeak (0 = unsupported) + * and the driver's detPeak clamp (0/0 = none). Report the clamp: past it + * several offsets program one peak and the staircase random-walks. */ + void hwCadSetPeakOffset(int8_t offset) override { (void)offset; } + uint8_t hwCadBasePeak() override { return 0; } + uint8_t hwCadPeakMin() override { return 0; } + uint8_t hwCadPeakMax() override { return 0; } /* ── Receiver hygiene — see LoRaRadioBase::radioMaintenance() ─── */ - /** Unstick a jammed AGC: warm sleep to drop the analog front end, then - * recalibrate on the way back up. Semtech's stated remedy; the - * datasheets do not describe it, so do not "simplify" it away on - * datasheet grounds alone. Must leave the driver out of RX so the - * caller's startReceive() performs a real re-entry rather than hitting - * an idempotent fast path. Default: unsupported, no-op. */ + /* Unstick a jammed AGC: warm sleep + recalibrate (Semtech's remedy, not in + * the datasheets). Must leave the driver out of RX so the caller's + * startReceive() is a real re-entry. Default: no-op. */ virtual void hwResetAgc() {} /** Redo the frequency-dependent calibrations (image / front end, and @@ -267,21 +201,8 @@ protected: * Same RX-state contract as hwResetAgc(). Default: unsupported. */ virtual void hwRecalibrate() {} - /** Chip junction temperature in whole degrees C, or INT16_MIN when the - * backend cannot measure it (which disables drift recalibration). */ - /* Does this family actually suffer the jammed-AGC fault the reset is a - * remedy for? Only the SX126x does. Semtech prescribe warm sleep plus - * recalibration for it, ZephCore inherited the idea from Arduino - * MeshCore's `agc_reset_interval`, and both are SX126x-era. Neither the - * LR11xx UM nor the LR2021 DS describes such a fault; those parts need - * image/front-end recalibration on temperature drift, which is a - * different operation on a different trigger (hwRecalibrate()). - * - * Running it anyway is not free. Measured on a T1000-E 2026-08-23: the - * 60 s after an AGC reset carried a 7.4% packet-miss rate against 0.6% - * elsewhere, and one deaf stretch began at one reset and ended at the - * next — the reset was recovering damage it had caused, on a part with - * no AGC fault to fix. */ + /* Only the SX126x has the jammed-AGC fault. Elsewhere the reset is pure + * cost (7.4% packet miss in the following minute on a T1000-E, LLD 03 §13). */ virtual bool hwNeedsAgcReset() { return false; } /* Does this family specify a temperature threshold for image/front-end @@ -289,26 +210,13 @@ protected: * and drift recalibration stays inactive there exactly as before. */ virtual bool hwHasDriftRecal() { return false; } - /** Radio deaf time per duty-cycle wake transition (context restore + - * PLL lock + TCXO startup where fitted), in microseconds. Counts - * against the duty-cycle preamble-catch budget: per SX126x DS rev 2.2 - * §13.1.7 the TCXO startup delay is inserted between the sleep and RX - * periods, outside both. - * - * This default is a fallback for backends that expose no per-device - * figure; it suits XTAL parts only. SX126x, LR11xx and LR2021 all - * override it, because a board with a TCXO powers the regulator down - * during duty-cycle sleep and pays the oscillator restart on every - * wake — several milliseconds, dwarfing this number. Leaving a TCXO - * board on the default oversizes the sleep window and drops - * window-edge preambles regardless of signal strength. */ + /* Deaf time per duty-cycle wake (context restore + PLL + TCXO start), in us, + * counted against the catch budget (DS §13.1.7). The default suits XTAL + * parts only: a TCXO board must override it or window-edge preambles drop. */ virtual uint32_t hwWakeupTimeUs() { return 1500; } - /* Set to true by subclasses using the loramac-node driver backend. - * Disables the direction-only fast path in configureTx()/configureRx(): - * loramac-node calls Radio.SetTxConfig() and Radio.SetRxConfig() which - * configure completely disjoint internal state — skipping either leaves - * TxTimeout/RxConfig uninitialized in the loramac-node library. */ + /* loramac-node backend: its TX and RX configs are disjoint state, so the + * direction-only fast path in configure() must stay off. */ bool _loramac_node; /* ── Shared helpers available to subclasses ────────────────── */ @@ -321,20 +229,16 @@ protected: void configureRx(); void configureTx(); void startReceive(); - void startTxThread(k_thread_stack_t *stack, size_t stack_size); + void startTxThread(); const struct device *_dev; NodePrefs *_prefs; MainBoard *_board; atomic_t _in_recv_mode; atomic_t _tx_active; - /* Completion latch: raised by the TX wait thread only on an affirmative - * success verdict, consumed once by isSendComplete(), and cleared by - * startSendRaw() so a completion the dispatcher never collected (it gave - * up on outbound_expiry first) cannot leak into the next packet. This - * is the Zephyr equivalent of upstream's STATE_INT_READY -> STATE_IDLE - * transition; _tx_active alone cannot serve, because it is also cleared - * on every failure path and is read as a plain state query elsewhere. */ + /* Completion latch: set by the wait thread on success only, consumed once + * by isSendComplete(), cleared by startSendRaw() (upstream's + * STATE_INT_READY). _tx_active cannot serve: failures clear it too. */ atomic_t _tx_complete; volatile float _last_rssi; /* word-aligned: atomic on ARM */ volatile float _last_snr; /* word-aligned: atomic on ARM */ @@ -355,15 +259,10 @@ protected: struct k_poll_signal _tx_signal; /* Noise floor calibration state */ - int _noise_floor; + NoiseFloorEstimator _floor_est; int _calibration_threshold; - uint8_t _ema_unguarded; /* tick counter for warmup + periodic bypass */ - /* Absolute uptime deadline of the next floor sample. The sampler used - * to run on every housekeeping tick, which pinned its cadence to the - * 5 s timer; owning its own deadline is what lets that timer go away. - * Advanced by NOISE_FLOOR_INTERVAL_MS after a sample lands, and by the - * shorter retry when an attempt is turned away because the radio was - * mid-packet / transmitting / in its duty-cycle sleep window. */ + /* Deadline of the next floor sample: an interval after a sample, the + * short retry after a blocked attempt. */ int64_t _noise_floor_next_ms; uint8_t _noise_floor_retries; /* consecutive blocked attempts, capped */ /* Shared cadence for every periodic radio measurement (floor sample + @@ -378,61 +277,15 @@ protected: /* Cycle stamp of the last host-driven RX entry, used to skip a floor * sample taken before GetRssiInst has settled (DS Table 13-82). */ uint32_t _rx_entry_cyc; - /* Median-of-N quality accounting, surfaced by `get cad` as sp:/<%>. - * The median only rejects outliers if the N reads are independent; if - * they land inside one RSSI averaging window they are the same sample - * N times over and the median is decorative. Spread (max-min of the - * burst) and the share of zero-spread bursts make that visible without - * a debug build. */ + /* Burst quality for `get cad` sp:/<%>: are the N reads independent? */ uint32_t _rssi_bursts; uint32_t _rssi_spread_sum; uint32_t _rssi_degenerate; - /* Adaptive CAD state */ - struct CadLevelStats { - uint16_t probes; /* probes run at this level */ - uint16_t busy; /* raw busy verdicts */ - uint16_t fp; /* busy that passed the ground-truth filter (suspected false positive) */ - uint16_t tp; /* busy confirmed by RX activity right after */ - }; - CadLevelStats _cad_stats[CAD_NUM_LEVELS]; - bool _cad_auto; /* staircase acts on the stats */ - int8_t _cad_offset; /* operating detPeak offset (levels) */ - /* The offset in force while a temporary radio override is active, i.e. - * on a preset the node is only visiting (`tempradio`, and the frozen - * old preset a `set radio` leaves running until reboot). detPeak's base - * table is per-SF and per-bandwidth, so the offset learned for the - * configured preset addresses a different ladder there — on the LR11xx - * the SF7->SF12 base step alone is 16 counts. Kept separate rather than - * saved-and-restored so _cad_offset never moves during the visit, which - * is what keeps it out of prefs: Dispatcher::maintenanceLoop() persists - * any change it sees in getCadOffset(). */ - int8_t _cad_temp_offset; - /* The offset the chip is actually programmed with. Every hwCadSetPeakOffset() - * goes through this; getCadOffset() deliberately does not, because that is - * the value the app persists. */ - int8_t cadEffectiveOffset() const { - return _has_radio_override ? _cad_temp_offset : _cad_offset; - } + /* Adaptive CAD: the controller, plus the probe schedule that stays here. */ + CadController _cad; 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 - * interval check in cadMaintenance() is against _cad_last_probe_ms, - * which only advances on a probe that actually ran — without this a - * blocked probe would report "due now" forever and spin the wake. */ - uint8_t _cad_probe_rr; /* round-robin index (sweep) / frontier mix counter */ - - int8_t pickCadProbeLevel(); - void decayCadStats(); - void cadStaircaseStep(); /* Power saving */ bool _rx_duty_cycle_enabled; @@ -442,7 +295,6 @@ protected: * changes once at INF instead of on every RX restart. 0/0 = never * computed; UINT32_MAX rx = continuous-RX fallback active. */ uint32_t _dc_last_rx_us; - uint32_t _dc_last_sleep_us; /* agcIdleMaintenance() bookkeeping. RX activity is inferred by sampling the @@ -457,25 +309,12 @@ protected: int16_t _agc_rssi_last; uint8_t _agc_rssi_frozen; - /* Diagnostics only — no behaviour depends on these. - * - * The RSSI sampler discards a whole burst if any read comes back busy, - * and on a duty-cycled LR1110 it completes ~0.2% of its attempts - * (measured 5 bursts in 9 h against 1007 on an SX1262 beside it). That - * is either "a few reads land in the sleep phase" or "essentially every - * read is refused", and the two want opposite fixes. These counters say - * which, and are reported by `get cad`. */ + /* Sampler diagnostics for `get cad` (no behaviour depends on them): reads + * ok/busy, bursts abandoned, and attempts refused before a burst started + * (duty-cycle sleep), without which "refused" and "fine" both read b0/a0. */ 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 @@ -490,12 +329,8 @@ protected: bool _image_cal_started; bool _image_cal_confirming; - /* Start of the most recent transmit, for the post-TX quiet window the - * temperature poll waits out. Start-referenced rather than end- - * referenced so it can be stamped at the single startSendRaw() site - * instead of all five _tx_active clear paths; airtime is bounded by - * seconds and the quiet window by a minute, so the difference does not - * matter. */ + /* Start of the last TX, for the drift poll's post-TX quiet window + * (start-referenced: one stamp site; seconds against a minute). */ uint32_t _last_tx_start_ms; /* Config cache — skip redundant hwConfigure() */ @@ -531,10 +366,7 @@ private: struct k_thread _tx_wait_thread; struct k_sem _tx_start_sem; bool _tx_thread_running; - /* Length of the transmit in flight, for txWaitBudgetMs(). Written by - * startSendRaw() before it releases _tx_start_sem, read by the wait - * thread after it takes that sem — the sem is the handoff, so no - * additional synchronisation is needed. */ + /* Length of the TX in flight; _tx_start_sem is the handoff. */ uint16_t _tx_len; /* Packet statistics */ diff --git a/zephcore/adapters/radio/NoiseFloorEstimator.h b/zephcore/adapters/radio/NoiseFloorEstimator.h new file mode 100644 index 0000000..1eb73f9 --- /dev/null +++ b/zephcore/adapters/radio/NoiseFloorEstimator.h @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: MIT + * Noise-floor estimator: an EMA over idle-channel RSSI samples. + * + * Pure algorithm. LoRaRadioBase owns the sampler (when to read, the + * idle-RX guards, the median-of-N burst) and feeds each median in here. + */ + +#pragma once + +#include +#include "radio_tuning.h" + +namespace mesh { + +/* Sorts `s` in place and returns the median (mean of the two middle values + * for even n). Insertion sort: n is 8. */ +inline int16_t sortAndMedian(int16_t *s, int n) +{ + for (int i = 1; i < n; i++) { + int16_t key = s[i]; + int j = i - 1; + while (j >= 0 && s[j] > key) { + s[j + 1] = s[j]; + j--; + } + s[j + 1] = key; + } + return (int16_t)((s[n / 2 - 1] + s[n / 2]) / 2); +} + +class NoiseFloorEstimator { +public: + enum Result { SEEDED, REJECTED, UPDATED }; + + /* DEFAULT_NOISE_FLOOR (0) until the first sample. */ + int floor() const { return _floor; } + bool seeded() const { return _floor != DEFAULT_NOISE_FLOOR; } + /* Ticks since seeding (wraps at 255; only the low bits matter). */ + uint8_t tick() const { return _unguarded; } + + /* Is `rssi` at the floor? Judged against the floor before the sample is + * folded in; an unseeded estimator calls everything quiet. */ + bool isQuiet(int rssi) const + { + return !seeded() || rssi <= _floor + CAD_PROBE_RSSI_GUARD; + } + + /* Fold one sample in, clamped to [floor_min, NOISE_FLOOR_CEILING_DBM]. + * floor_min tracks the bandwidth (thermal noise is 10*log10(BW)). + * + * The first W = 2^EMA_SHIFT ticks accept every sample (a bad seed must + * not lock the real floor out); after that, samples more than + * SAMPLING_THRESHOLD above the floor are rejected as interference, + * except every Pth tick, so a sustained rise is still tracked. */ + Result update(int rssi, int floor_min) + { + if (!seeded()) { + _floor = clamp(rssi, floor_min); + _unguarded = 0; + return SEEDED; + } + + const int W = (1 << NOISE_FLOOR_EMA_SHIFT); + const int P = NOISE_FLOOR_UNGUARDED_INTERVAL; + bool warmup = (_unguarded < W); + bool periodic = (!warmup && (_unguarded & (P - 1)) == 0); + _unguarded++; + + if (!warmup && !periodic && + rssi >= _floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { + return REJECTED; + } + + /* floor += round_nearest((sample - floor) / W), symmetric in sign: + * plain >> biases down and plain / has a +-(W-1) dead zone. */ + int diff = rssi - _floor; + int half = W / 2; + _floor = clamp(_floor + (diff + (diff > 0 ? half : -half)) / W, floor_min); + return UPDATED; + } + +private: + static int clamp(int v, int floor_min) + { + if (v < floor_min) v = floor_min; + if (v > NOISE_FLOOR_CEILING_DBM) v = NOISE_FLOOR_CEILING_DBM; + return v; + } + + int _floor = DEFAULT_NOISE_FLOOR; + uint8_t _unguarded = 0; +}; + +} /* namespace mesh */ diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index 646a459..910c162 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -8,7 +8,7 @@ /* Native SX126x driver extension API */ extern "C" { -#include "sx126x_ext.h" +#include } #include @@ -16,18 +16,13 @@ LOG_MODULE_REGISTER(sx126x_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); namespace mesh { -K_THREAD_STACK_DEFINE(sx126x_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -SX126xRadio::SX126xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) +SX126xRadio::SX126xRadio(const struct device *lora_dev, MainBoard &board) + : LoRaRadioBase(lora_dev, board) { } void SX126xRadio::begin() { - startTxThread(sx126x_tx_wait_stack, - K_THREAD_STACK_SIZEOF(sx126x_tx_wait_stack)); LoRaRadioBase::begin(); #if IS_ENABLED(CONFIG_ZEPHCORE_SX126X_HELTEC_REG_PATCH) diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 9728069..aa81e02 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -11,8 +11,7 @@ namespace mesh { class SX126xRadio : public LoRaRadioBase { public: - SX126xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); + SX126xRadio(const struct device *lora_dev, MainBoard &board); void begin() override; diff --git a/zephcore/adapters/radio/SX127xRadio.cpp b/zephcore/adapters/radio/SX127xRadio.cpp index c96539c..a12a829 100644 --- a/zephcore/adapters/radio/SX127xRadio.cpp +++ b/zephcore/adapters/radio/SX127xRadio.cpp @@ -23,11 +23,8 @@ LOG_MODULE_REGISTER(sx127x_radio, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); namespace mesh { -K_THREAD_STACK_DEFINE(sx127x_tx_wait_stack, TX_WAIT_THREAD_STACK_SIZE); - -SX127xRadio::SX127xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs) - : LoRaRadioBase(lora_dev, board, prefs) +SX127xRadio::SX127xRadio(const struct device *lora_dev, MainBoard &board) + : LoRaRadioBase(lora_dev, board) { /* SX127x has no RX boost feature — start with boost disabled */ _rx_boost_enabled = false; @@ -37,13 +34,6 @@ SX127xRadio::SX127xRadio(const struct device *lora_dev, MainBoard &board, _loramac_node = true; } -void SX127xRadio::begin() -{ - startTxThread(sx127x_tx_wait_stack, - K_THREAD_STACK_SIZEOF(sx127x_tx_wait_stack)); - LoRaRadioBase::begin(); -} - /* ── Hardware primitives ──────────────────────────────────────────────── */ bool SX127xRadio::hwConfigure(const struct lora_modem_config &cfg) diff --git a/zephcore/adapters/radio/SX127xRadio.h b/zephcore/adapters/radio/SX127xRadio.h index 1787f49..775bfa3 100644 --- a/zephcore/adapters/radio/SX127xRadio.h +++ b/zephcore/adapters/radio/SX127xRadio.h @@ -16,10 +16,7 @@ namespace mesh { class SX127xRadio : public LoRaRadioBase { public: - SX127xRadio(const struct device *lora_dev, MainBoard &board, - NodePrefs *prefs = nullptr); - - void begin() override; + SX127xRadio(const struct device *lora_dev, MainBoard &board); /* SX127x has no RX boost feature — report unsupported so the * radio.rxgain CLI can reply "Error: unsupported". */ diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index 6b20c77..20d9926 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -11,14 +11,7 @@ #include -/* --- Noise floor calibration (EMA) --- - * Median-of-N RSSI reads → EMA. alpha = 1/8, converges in ~8 ticks. - * Samples above floor + SAMPLING_THRESHOLD rejected as interference. */ -#define NOISE_FLOOR_EMA_SHIFT 3 /* alpha = 1/(1<<3) = 1/8 */ -#define NOISE_FLOOR_SAMPLES_PER_TICK 8 /* median of 8 reads per tick */ -#define NOISE_FLOOR_UNGUARDED_INTERVAL 16 /* ticks between unfiltered samples (power of 2) */ -#define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */ -#define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */ +#include "radio_tuning.h" /* --- RSSI read timing (SX1261/2 DS rev 2.2 Table 13-82) --- * @@ -92,94 +85,6 @@ static inline uint32_t rssi_settle_delay_us(uint16_t bw_khz) /* Blocked attempts allowed before standing down to the next full interval. */ #define NOISE_FLOOR_MAX_RETRIES 2 -/* --- Adaptive CAD (LBT detPeak calibration) --- - * 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, 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 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 */ -#define CAD_NUM_LEVELS (CAD_LEVEL_MAX - CAD_LEVEL_MIN + 1) -#define CAD_SWEEP_MIN (-4) /* dry-run sweep window (get cad with auto off) */ -#define CAD_SWEEP_MAX 4 -/* Knee-seeking staircase (replaces the earlier absolute-FP-target band). The - * FP-vs-detPeak curve falls as detPeak rises (less sensitive → fewer false - * detects) and flattens past a knee; the sweet spot is the knee — the most - * sensitive detPeak whose FP has already bottomed out. The controller reads - * the local curve SLOPE from three rungs (frontier op-1, operating op, op+1) - * rather than an absolute FP level, so it converges the same way regardless of - * a site's FP floor (which varies with traffic and classifier residual). - * - KNEE_SLOPE: the per-level FP change (permille) that counts as "steep". - * Below the knee the curve drops fast (step up toward the knee); at/above it - * the curve is flat (slope < KNEE_SLOPE). - * - PLATEAU_CLEAN: on a flat plateau, only reclaim sensitivity (step down) if - * FP is already this low — the guard that stops a flat-but-noisy curve from - * walking to the sensitive rail (there, holding is the least-bad move; a - * genuinely quiet flat-low site descends to the floor, which is correct). */ -#define CAD_KNEE_SLOPE_PERMILLE 50 /* >=5%/level FP change = steep */ -#define CAD_PLATEAU_CLEAN_PERMILLE 50 /* <=5% FP = clean enough to descend */ -#define CAD_STEP_MIN_PROBES 120 /* per-level samples before a step call */ -/* Airtime-protection cap on the TOTAL busy (defer) rate — false positives AND - * real traffic. The knee controller only minimises *false* busy, but on a - * congested hilltop most busy verdicts are real distant traffic we'd never - * actually collide with (capture effect), and deferring for all of it starves - * the node's own airtime. When the operating level's busy rate exceeds the - * 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 15, 0 = off) since it is - * a policy call (airtime vs. collision/capture), not a physical constant. */ -/* 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 */ -/* Safety rung thresholds — see LoRaRadioBase::cadSafetyStep(). - * - * The airtime cap above is a SAFETY, not an optimisation, so it runs whether or - * not `cad.auto` is on. A detPeak so sensitive that CAD never clears leaves the - * node unable to transmit at all, and the three settings that used to gate the - * whole staircase (`cad.auto off`, `cad.busycap 0`, and a level not yet warm to - * CAD_STEP_MIN_PROBES) are exactly what a hand-tuning operator turns off — the - * CLI help for `set cad.auto` recommends that workflow by name. - * - * PATHOLOGICAL is the fast path for the unambiguous case. Proving a MARGINAL - * busy rate against the cap needs the full 120 samples, but a level that trips - * on essentially every probe needs far fewer to be certain, and it is precisely - * the case where waiting half an hour is unacceptable. It also ignores - * `cad.busycap` entirely: a cap of 0 means "do not trade airtime for - * sensitivity", which is a policy about a working detector, not permission to - * sit mute. */ -#define CAD_SAFETY_MIN_PROBES 20 /* samples before the fast path may act */ -#define CAD_SAFETY_PATHOLOGICAL_PERMILLE 900 /* >=90% busy = detector, not channel */ -#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 - * the noise-floor sampler's measurement (LoRaRadioBase::cadMaintenance), which - * already applies the idle-RX guards and yields a median-of-8. Consequently - * the effective probe rate is quantised to NOISE_FLOOR_INTERVAL_MS: setting - * probe_interval below that just gets one probe per floor sample. */ - /* --- RX ring buffer --- */ #define RX_RING_SIZE 8 /* ~2 KB; buffers burst arrivals at SF7/BW500 */ diff --git a/zephcore/adapters/radio/radio_tuning.h b/zephcore/adapters/radio/radio_tuning.h new file mode 100644 index 0000000..b8a3cd1 --- /dev/null +++ b/zephcore/adapters/radio/radio_tuning.h @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: MIT + * Tuning constants of the radio algorithms (noise-floor estimator, adaptive + * CAD controller). Zephyr-free so the algorithms build on the host for unit + * tests. Rationale and measurements: devdocs/lld/03-radio-contract.md §13. + */ + +#pragma once + +/* --- Noise floor (EMA of median-of-N idle RSSI) --- */ +#define NOISE_FLOOR_EMA_SHIFT 3 /* alpha = 1/(1<<3) = 1/8; also the warm-up tick count */ +#define NOISE_FLOOR_SAMPLES_PER_TICK 8 /* median of 8 reads per tick */ +#define NOISE_FLOOR_UNGUARDED_INTERVAL 16 /* ticks between unfiltered samples (power of 2) */ +#define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */ +#define NOISE_FLOOR_CEILING_DBM (-50) /* upper clamp */ +#define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */ + +/* --- Adaptive CAD (LBT detPeak calibration) --- + * Levels are signed offsets from the chip family's per-SF/per-BW base detPeak. + * The window 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 */ +#define CAD_NUM_LEVELS (CAD_LEVEL_MAX - CAD_LEVEL_MIN + 1) +#define CAD_SWEEP_MIN (-4) /* dry-run sweep window (auto off) */ +#define CAD_SWEEP_MAX 4 +/* Knee-seeking staircase: read the FP-vs-level slope from three rungs. */ +#define CAD_KNEE_SLOPE_PERMILLE 50 /* >=5%/level FP change = steep */ +#define CAD_PLATEAU_CLEAN_PERMILLE 50 /* <=5% FP = clean enough to descend */ +#define CAD_STEP_MIN_PROBES 120 /* per-level samples before a step call */ +/* Descend only if the frontier's busy rate is <= (100 - HYST)% of the cap. */ +#define CAD_BUSY_DEFER_HYST_PCT 40 +/* Safety rung (runs regardless of cad.auto; see CadController::safetyStep). */ +#define CAD_SAFETY_MIN_PROBES 20 /* samples before the fast path may act */ +#define CAD_SAFETY_PATHOLOGICAL_PERMILLE 900 /* >=90% busy = detector, not channel */ +#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 */ diff --git a/zephcore/app/ObserverMesh.cpp b/zephcore/app/ObserverMesh.cpp index 485d608..6daefb6 100644 --- a/zephcore/app/ObserverMesh.cpp +++ b/zephcore/app/ObserverMesh.cpp @@ -7,7 +7,6 @@ #include "observer_creds.h" #include -#include #include #include /* generateFirstBootIdentity (hardened keygen) */ #include @@ -447,8 +446,7 @@ bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size) if (f >= 300.0f && f <= 1000.0f) { _prefs.freq = f; _store->savePrefs(_prefs); - ((LoRaRadioBase *)_radio)->reconfigureWithParams( - _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + ((LoRaRadioBase *)_radio)->reconfigure(); snprintf(reply, reply_size, "freq=%.3f MHz", (double)_prefs.freq); } else { snprintf(reply, reply_size, "ERR freq must be 300-1000 MHz"); @@ -459,8 +457,7 @@ bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size) if (sf >= 7 && sf <= 12) { _prefs.sf = (uint8_t)sf; _store->savePrefs(_prefs); - ((LoRaRadioBase *)_radio)->reconfigureWithParams( - _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + ((LoRaRadioBase *)_radio)->reconfigure(); snprintf(reply, reply_size, "sf=%u", _prefs.sf); } else { snprintf(reply, reply_size, "ERR sf must be 7-12"); @@ -484,8 +481,7 @@ bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size) if (bw >= ZC_RADIO_BW_MIN_KHZ && bw <= ZC_RADIO_BW_MAX_KHZ) { _prefs.bw = bw; _store->savePrefs(_prefs); - ((LoRaRadioBase *)_radio)->reconfigureWithParams( - _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + ((LoRaRadioBase *)_radio)->reconfigure(); snprintf(reply, reply_size, "bw=%.2f kHz", (double)_prefs.bw); } else { snprintf(reply, reply_size, "ERR bw must be 7-500 kHz (or index 0-5)"); @@ -496,8 +492,7 @@ bool ObserverMesh::handleCLI(const char *command, char *reply, int reply_size) if (cr >= 5 && cr <= 8) { _prefs.cr = (uint8_t)cr; _store->savePrefs(_prefs); - ((LoRaRadioBase *)_radio)->reconfigureWithParams( - _prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr); + ((LoRaRadioBase *)_radio)->reconfigure(); snprintf(reply, reply_size, "cr=%u", _prefs.cr); } else { snprintf(reply, reply_size, "ERR cr must be 5-8"); diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index f60ec32..50ba53e 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -5,6 +5,7 @@ #include "RepeaterDataStore.h" #include "../adapters/datastore/ZephyrFsFormat.h" +#include #include #include #include @@ -325,11 +326,11 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { prefs.bw < ZC_RADIO_BW_MIN_KHZ || prefs.bw > ZC_RADIO_BW_MAX_KHZ) { LOG_WRN("Invalid radio params in prefs, using defaults: freq=%.3f sf=%u bw=%.1f", (double)prefs.freq, prefs.sf, (double)prefs.bw); - prefs.freq = 869.618f; - prefs.bw = 62.5f; - prefs.sf = 7; - prefs.cr = 5; - prefs.tx_power_dbm = 22; + prefs.freq = mesh::LoRaConfig::FREQ_MHZ; + prefs.bw = mesh::LoRaConfig::BANDWIDTH_KHZ; + prefs.sf = mesh::LoRaConfig::SPREADING_FACTOR; + prefs.cr = mesh::LoRaConfig::CODING_RATE; + prefs.tx_power_dbm = mesh::LoRaConfig::TX_POWER_DBM; } /* Everything else that came off flash — bounds, NaNs, and the char fields, * which the file format stores without terminators. */ diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 38cb633..49bf917 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -1075,12 +1075,6 @@ void RepeaterMesh::begin(RepeaterDataStore* store) { } } - /* NOTE: Radio configuration is handled by SX126xRadio adapter using - * LoRaConfig defaults. The repeater uses the same radio params as companion. - * Dynamic radio reconfiguration (via CLI) is not yet supported - radio - * uses compile-time defaults from LoRaConfig. This avoids EBUSY errors - * from trying to reconfigure while radio is in async RX mode. */ - updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/zephcore/app/RoomServerMesh.cpp b/zephcore/app/RoomServerMesh.cpp index 7854998..33dd6df 100644 --- a/zephcore/app/RoomServerMesh.cpp +++ b/zephcore/app/RoomServerMesh.cpp @@ -816,12 +816,6 @@ void RoomServerMesh::begin(RepeaterDataStore* store) { } } - /* NOTE: Radio configuration is handled by SX126xRadio adapter using - * LoRaConfig defaults. The room server uses the same radio params as companion. - * Dynamic radio reconfiguration (via CLI) is not yet supported - radio - * uses compile-time defaults from LoRaConfig. This avoids EBUSY errors - * from trying to reconfigure while radio is in async RX mode. */ - updateAdvertTimer(); updateFloodAdvertTimer(); diff --git a/zephcore/app/main_observer.cpp b/zephcore/app/main_observer.cpp index ea880fa..768b7bc 100644 --- a/zephcore/app/main_observer.cpp +++ b/zephcore/app/main_observer.cpp @@ -274,19 +274,17 @@ static mesh::ZephyrMillisecondClock s_ms_clock; static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -/* Static-init placeholder only. The radio is rebound to the mesh's own - * NodePrefs via setPrefs() in main() before observer_mesh.begin() — this object - * is not the live radio configuration and is never loaded from flash. */ -static NodePrefs s_radio_prefs; +/* Radio is constructed with no prefs pointer; main() binds it to the mesh's + * NodePrefs via setPrefs() before observer_mesh.begin(). */ #if IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR1110) -static mesh::LR1110Radio lora_radio(lora_dev, s_board, &s_radio_prefs); +static mesh::LR1110Radio lora_radio(lora_dev, s_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR2021) -static mesh::LR2021Radio lora_radio(lora_dev, s_board, &s_radio_prefs); +static mesh::LR2021Radio lora_radio(lora_dev, s_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_SX127X) -static mesh::SX127xRadio lora_radio(lora_dev, s_board, &s_radio_prefs); +static mesh::SX127xRadio lora_radio(lora_dev, s_board); #else -static mesh::SX126xRadio lora_radio(lora_dev, s_board, &s_radio_prefs); +static mesh::SX126xRadio lora_radio(lora_dev, s_board); #endif static mesh::ObserverMesh observer_mesh(lora_radio, s_ms_clock, s_rtc_clock); @@ -296,13 +294,6 @@ static RepeaterDataStore data_store; int main(void) { - /* Initialize radio prefs with observer-specific defaults */ - initNodePrefs(&s_radio_prefs); - s_radio_prefs.cr = 5; /* CR 4/5 (same as initNodePrefs; kept explicit) */ - s_radio_prefs.tx_power_dbm = 0; /* observer never TXes */ - strncpy(s_radio_prefs.node_name, "Observer", - sizeof(s_radio_prefs.node_name) - 1); - /* Brief boot delay — lets USB enumerate before first log */ k_sleep(K_MSEC(1500)); LOG_INF("=== ZephCore Observer starting ==="); @@ -352,10 +343,8 @@ int main(void) * * The radio reads freq/bw/sf/cr through this pointer, both during * begin() → Dispatcher::begin() → Radio::begin() and on every later - * reconfigure() (LoRaRadioBase::reconfigureWithParams() ignores its - * arguments and re-reads the pointer). Constructed against - * s_radio_prefs, which is never loaded from flash, the radio stayed on - * the compiled-in defaults forever: `set freq/sf/bw/cr` wrote flash and + * reconfigure(). Bound to a placeholder that was never loaded from + * flash, the radio stayed on the compiled-in defaults forever: `set freq/sf/bw/cr` wrote flash and * updated the CLI/MQTT readback but never reached the hardware, so the * setting looked accepted and then "reverted" on reboot. * diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index 529a562..aaa4ea4 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -252,8 +252,8 @@ void CommonCLI::savePrefs() { * base costs an hour or two; carrying a stale offset across can leave the node * either transmitting over live receptions or unable to transmit at all. * - * The operating offset lives in two places — _prefs->cad_offset, and _cad_offset - * inside the radio, which applyCadPrefs() reloads through setCadParams(). + * The operating offset lives in two places — _prefs->cad_offset, and the radio's + * CadController, which applyCadPrefs() reloads through setCadParams(). * * preset_pending = the caller has already written the NEW freq/bw/sf/cr to * _prefs but the radio is still running the OLD preset (frozen by diff --git a/zephcore/helpers/LoRaConfig.h b/zephcore/helpers/LoRaConfig.h index 524b567..cc443bf 100644 --- a/zephcore/helpers/LoRaConfig.h +++ b/zephcore/helpers/LoRaConfig.h @@ -1,6 +1,10 @@ /* * SPDX-License-Identifier: MIT * ZephCore LoRa default parameters + * + * The one source of radio defaults. They only seed NodePrefs (initNodePrefs(), + * RepeaterDataStore's invalid-prefs fallback); the radio itself always reads + * prefs. Units match NodePrefs. */ #pragma once @@ -10,12 +14,15 @@ namespace mesh { struct LoRaConfig { - static constexpr uint32_t FREQ_HZ = 869618000; /* MeshCore default channel (EU 869 MHz ISM) */ - static constexpr uint16_t BANDWIDTH = 62; /* BW_62_KHZ — MeshCore default */ + static constexpr float FREQ_MHZ = 869.618f; /* MeshCore default channel (EU 869 MHz ISM) */ + static constexpr float BANDWIDTH_KHZ = 62.5f; /* MeshCore default */ static constexpr uint8_t SPREADING_FACTOR = 7; /* SF7: HU regional default since 2026-08 */ - static constexpr uint8_t CODING_RATE = 5; /* CR_4_5: lowest overhead */ - static constexpr uint16_t PREAMBLE_LEN = 16; /* MeshCore default; longer aids RX sync */ + static constexpr uint8_t CODING_RATE = 5; /* CR 4/5: lowest overhead */ +#ifdef CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM + static constexpr int8_t TX_POWER_DBM = CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM; /* per board (external PAs) */ +#else static constexpr int8_t TX_POWER_DBM = 22; /* SX1262 max */ +#endif }; } /* namespace mesh */ diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 68e1db4..8326bf0 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -10,6 +10,7 @@ #include #include +#include "LoRaConfig.h" /* LEDS_RADIO_* / LEDS_HB_* mode values for the leds_radio_mode and leds_hb_mode * fields below. They live in led_gate.h because the heartbeat consumers are C @@ -241,7 +242,6 @@ struct NodePrefs { uint8_t v_contact_flags; }; -/* Default prefs -- must match LoRaConfig.h defaults for radio interop. */ /* Range guards for prefs that came off flash. * * The atomic replace in every savePrefs() plus littlefs's own CRCs make a torn @@ -391,16 +391,12 @@ static inline void initNodePrefs(NodePrefs* prefs) { #ifdef CONFIG_ZEPHCORE_GUEST_PASSWORD strncpy(prefs->guest_password, CONFIG_ZEPHCORE_GUEST_PASSWORD, sizeof(prefs->guest_password) - 1); #endif - /* Radio params - MUST match LoRaConfig.h for interop with companion nodes */ - prefs->freq = 869.618f; // LoRaConfig::FREQ_HZ / 1000000.0 - prefs->bw = 62.5f; // LoRaConfig::BANDWIDTH - prefs->sf = 7; // LoRaConfig::SPREADING_FACTOR - prefs->cr = 5; // CR 4/5 (MeshCore uses 5-8 for CR 4/5 through 4/8) -#ifdef CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM - prefs->tx_power_dbm = CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM; -#else - prefs->tx_power_dbm = 22; // LoRaConfig::TX_POWER_DBM -#endif + /* Radio params */ + prefs->freq = mesh::LoRaConfig::FREQ_MHZ; + prefs->bw = mesh::LoRaConfig::BANDWIDTH_KHZ; + prefs->sf = mesh::LoRaConfig::SPREADING_FACTOR; + prefs->cr = mesh::LoRaConfig::CODING_RATE; /* 5-8 = CR 4/5 through 4/8 */ + prefs->tx_power_dbm = mesh::LoRaConfig::TX_POWER_DBM; prefs->disable_fwd = 0; prefs->advert_interval = 0; // 0 = periodic local advert off; else minutes = value * 2 prefs->flood_advert_interval = 47; // hours diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index 5dbbc7d..0bc6947 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -63,18 +63,16 @@ public: */ virtual void onSendFinished() = 0; - /** - * \brief do any processing needed on each loop cycle - */ - virtual void loop() { } + // ZEPHCORE: no loop() -- the Dispatcher never polls the radio; periodic radio + // work is deadline-driven through msUntilNextMaintenance()/radioMaintenance(). virtual int getNoiseFloor() const { return 0; } virtual void triggerNoiseFloorCalibrate(int threshold) { } - virtual void setCADEnabled(bool enable) { } - - virtual void resetAGC() { } + // ZEPHCORE: no setCADEnabled() (CAD is always LBT inside the driver) and no + // resetAGC() (the AGC unstick is part of radioMaintenance()). Left out so a + // port calling them fails to compile instead of silently doing nothing. virtual bool isInRecvMode() const = 0; @@ -86,9 +84,7 @@ public: virtual float getLastRSSI() const { return 0; } virtual float getLastSNR() const { return 0; } - // ZEPHCORE: radio extensions used by the Dispatcher and the roles. CAD is - // always LBT inside the driver (setCADEnabled unused) and the AGC unstick is - // part of radioMaintenance() (resetAGC unused). + // ZEPHCORE: radio extensions used by the Dispatcher and the roles. // Radio not command-ready (e.g. BUSY high); the Dispatcher defers TX. virtual bool isRadioReady() { return true; } diff --git a/zephcore/include/mesh/MeshCore.h b/zephcore/include/mesh/MeshCore.h index 91d14f6..c220853 100644 --- a/zephcore/include/mesh/MeshCore.h +++ b/zephcore/include/mesh/MeshCore.h @@ -70,11 +70,8 @@ public: virtual void onPacketReceived() { } virtual void reboot() = 0; virtual void powerOff() { /* no op */ } - // Called by example setup() functions to signal that boot is complete. - // Boards may override to stop a boot-indicator LED sequence or similar. - // Default no-op: boards that don't care need not implement anything. - virtual void onBootComplete() { /* no op */ } - virtual uint32_t getIRQGpio() { return -1; } // not supported. Returns DIO1 (SX1262) and DIO0 (SX127x) + // ZEPHCORE: no onBootComplete() (Arduino setup() hook) and no getIRQGpio() + // (Arduino sleep-wake pin) -- nothing on Zephyr would call them. virtual void sleep(uint32_t secs) { /* no op */ } virtual uint32_t getGpio() { return 0; } virtual void setGpio(uint32_t values) {} @@ -83,10 +80,10 @@ public: virtual bool startOTAUpdate(const char* id, char reply[]) { return false; } // not supported // Power management interface (boards with power management override these) - virtual bool isPwrMgtInitialised() const { return false; } + // ZEPHCORE: no isPwrMgtInitialised() / getWakeLpcompSupported() -- upstream's + // nRF52 Arduino power manager; Zephyr PM covers this. virtual bool isExternalPowered() { return false; } virtual uint16_t getBootVoltage() { return 0; } - virtual bool getWakeLpcompSupported() const { return false; } virtual uint32_t getResetReason() const { return 0; } virtual const char* getResetReasonString(uint32_t reason) { return "Not available"; } virtual uint8_t getShutdownReason() const { return 0; } @@ -94,9 +91,10 @@ public: virtual bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { return false; } - // Called from the example main loops. Lets a board feed its watchdog and - // run periodic housekeeping. Default no-op. - virtual void loop() { /* no op */ } + // ZEPHCORE: no loop() -- upstream polls it from the Arduino main loop. The mesh + // thread here is event-driven and never polls; a board with periodic work owns + // a timer or work item. Left out so a port calling board.loop() fails to + // compile instead of silently doing nothing. }; /** 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 a9efcfa..3ec7905 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c @@ -17,7 +17,7 @@ #include #include -#include "lr11xx_lora.h" +#include #include "lr11xx_hal_zephyr.h" #include "lr11xx_radio.h" #include "lr11xx_radio_types.h" @@ -2191,7 +2191,7 @@ static uint8_t lr11xx_cad_detect_peak(uint8_t sf, uint16_t bw_khz, uint8_t symb_ * * 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 + * 44 + CAD_LEVEL_MIN(-8) = 36 is below it, so CadController::levelMinEff() 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. * 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 d4a0848..eb726b2 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -17,7 +17,7 @@ #include #include -#include "lr20xx_lora.h" +#include #include "lr20xx_hal_zephyr.h" #include "lr20xx_radio_common.h" #include "lr20xx_radio_common_types.h" diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h b/zephcore/patches/zephyr-new/include/zephyr/drivers/lora/lr11xx_lora.h similarity index 100% rename from zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h rename to zephcore/patches/zephyr-new/include/zephyr/drivers/lora/lr11xx_lora.h diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h b/zephcore/patches/zephyr-new/include/zephyr/drivers/lora/lr20xx_lora.h similarity index 100% rename from zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h rename to zephcore/patches/zephyr-new/include/zephyr/drivers/lora/lr20xx_lora.h diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/include/zephyr/drivers/lora/sx126x_ext.h similarity index 100% rename from zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h rename to zephcore/patches/zephyr-new/include/zephyr/drivers/lora/sx126x_ext.h diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index 744d3c4..3b49a3d 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -6,8 +6,8 @@ sx126x_hal_stm32wl.c and sx126x_regs.h -- plus the driver's devicetree binding dts/bindings/lora/semtech,sx126x-base.yaml, which it extends with lna-bypass-gpios (see "FEM receive-path select" below). No other patch in this directory touches those files; see "Why this is one patch" at the end. The -extension-API header sx126x_ext.h is not patched in, it is copied from -patches/zephyr-new/. +extension-API header is not patched in: it is copied from patches/zephyr-new/ +to the public include/zephyr/drivers/lora/sx126x_ext.h. == Base driver == @@ -696,7 +696,7 @@ index c8e682ee8..b5ab10c4e 100644 #include #include "sx126x.h" -+#include "sx126x_ext.h" ++#include #include LOG_MODULE_REGISTER(sx126x, CONFIG_LORA_LOG_LEVEL); diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 95562fd..10f4d70 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -731,25 +731,25 @@ static ZephyrDataStore data_store(rtc_clock); #ifdef ZEPHCORE_LORA static mesh::ZephyrBoard zephyr_board; -/* NodePrefs placeholder - will be set from CompanionMesh */ -static NodePrefs temp_prefs; +/* Radio is constructed with no prefs pointer; main() binds it to + * companion_mesh.prefs via setPrefs() before companion_mesh.begin(). */ #if IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR1110) /* LR1110 via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board, &temp_prefs); +static mesh::LR1110Radio lora_radio(lora_dev, zephyr_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_LR2021) /* LR2021 via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::LR2021Radio lora_radio(lora_dev, zephyr_board, &temp_prefs); +static mesh::LR2021Radio lora_radio(lora_dev, zephyr_board); #elif IS_ENABLED(CONFIG_ZEPHCORE_RADIO_SX127X) /* SX127x via Zephyr loramac-node driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::SX127xRadio lora_radio(lora_dev, zephyr_board, &temp_prefs); +static mesh::SX127xRadio lora_radio(lora_dev, zephyr_board); #else /* SX126x via Zephyr LoRa driver */ static const struct device *const lora_dev = DEVICE_DT_GET(DT_ALIAS(lora0)); -static mesh::SX126xRadio lora_radio(lora_dev, zephyr_board, &temp_prefs); +static mesh::SX126xRadio lora_radio(lora_dev, zephyr_board); #endif static uint16_t get_battery_mv(void) @@ -1554,8 +1554,9 @@ int main(void) LOG_INF("BLE passkey loaded from prefs: %06u", companion_mesh.prefs.ble_pin); } - /* Copy prefs to temp_prefs for radio (radio was initialized before companion_mesh) */ - temp_prefs = companion_mesh.prefs; + /* Bind the radio to the live prefs before begin(): the radio reads + * freq/bw/sf/cr/power through this pointer from Radio::begin() on. */ + lora_radio.setPrefs(&companion_mesh.prefs); /* Load contacts and channels */ data_store.loadContacts(&companion_mesh); @@ -1616,12 +1617,6 @@ int main(void) /* Start mesh */ companion_mesh.begin(); - /* Redirect radio prefs pointer to the live companion_mesh.prefs. - * The radio was constructed with &temp_prefs (a one-time copy needed - * for static init). After begin(), point at the real prefs so that - * CMD_SET_RADIO_PARAMS changes are visible to lora_radio.reconfigure(). */ - lora_radio.setPrefs(&companion_mesh.prefs); - /* Queue the restart-reason notice from the v-contact. Deferred to here so * begin() has derived the v-contact key, prefs (enable flag) are loaded, * and the hardware-RTC restore above gives the message a sane timestamp. diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index e8d5f26..30aaffc 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -756,7 +756,7 @@ int main(void) * Dispatcher::begin() → Radio::begin(). Without this, the radio would * configure on NodePrefs defaults (869.618 MHz) regardless of saved * settings: CLI readback looked correct but the hardware stayed on EU. - * Mirrors the temp_prefs pattern in main_companion.cpp. */ + * Every role binds this way; begin() refuses to start without it. */ data_store.loadPrefs(*repeater_mesh.getNodePrefs()); lora_radio.setPrefs(repeater_mesh.getNodePrefs()); diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index d7c760e..5aea5dc 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -641,7 +641,7 @@ int main(void) * Dispatcher::begin() → Radio::begin(). Without this, the radio would * configure on NodePrefs defaults (869.618 MHz) regardless of saved * settings: CLI readback looked correct but the hardware stayed on EU. - * Mirrors the temp_prefs pattern in main_companion.cpp. */ + * Every role binds this way; begin() refuses to start without it. */ data_store.loadPrefs(*room_mesh.getNodePrefs()); lora_radio.setPrefs(room_mesh.getNodePrefs());