diff --git a/zephcore/ADAPTIVE_CAD.md b/zephcore/ADAPTIVE_CAD.md new file mode 100644 index 0000000..97de517 --- /dev/null +++ b/zephcore/ADAPTIVE_CAD.md @@ -0,0 +1,179 @@ +# Adaptive CAD — Site-Calibrated Listen-Before-Talk + +ZephCore's listen-before-talk (LBT) runs a hardware Channel Activity +Detection (CAD) before every transmission: the radio's LoRa correlator +searches the channel for chirps, and TX is deferred while activity is +detected. How sensitive that search is comes down to one register value, +`cadDetPeak` — and the right value depends on where the node lives. + +- **Too sensitive** (detPeak too low): chirp-like interference — other + LoRa networks, other spreading factors on the same frequency — triggers + *false positives*. The node keeps deferring TX for nothing, wasting + 100–200 ms retry cycles and adding latency; in bad cases it hits the + 4-second CAD watchdog. +- **Not sensitive enough** (detPeak too high): real transmissions are + *missed* and the node transmits over ongoing packets, causing on-air + collisions. + +A quiet valley node and a 50-network hilltop node need different values. +Adaptive CAD measures the site and finds the value, instead of shipping +one hardcoded number to everyone. + +Important physics note: `cadDetPeak` is a correlation peak-to-noise +threshold inside the radio's despreader — **not** a dBm level. The RSSI +noise floor cannot be converted into a detPeak value, which is why this +feature measures CAD behaviour directly instead of deriving it from RSSI. + +## How it works + +Every `cad.probe.interval` seconds (default 60), when the radio is idle +in receive mode, the firmware runs one **calibration CAD probe** and +immediately re-arms RX. A probe takes one CAD duration — about 4 ms at +SF7/250 kHz, up to ~130 ms at SF12/125 kHz — so the added radio deaf time +is roughly 0.1%. Probes also run in RX duty-cycle (sniff) mode: the +duty cycle is briefly interrupted and re-armed, within the same +preamble-catch budget philosophy the sniff-mode math already accepts. + +Each probe tests one **level**: a signed offset from the chip family's +per-SF base detPeak (SX126x: `SF+13`; LR11xx/LR2021: the 56–68 table). +Results accumulate per level: + +- `probes` — how many CADs ran at this level +- `busy` — raw "activity detected" verdicts +- `fp` — suspected **false positives**: busy verdicts where no packet + materialised right after (see filtering below) +- `tp` — **true positives**: busy verdicts confirmed by actual RX + activity immediately after the probe + +Filtering, because a "busy" verdict during apparent silence may be a real +below-noise-floor packet (detecting those is CAD's whole purpose): + +1. Probes are skipped entirely when the channel is visibly busy (RSSI + more than 7 dB above the learned noise floor) or a packet is being + received — those teach nothing about false positives. +2. After a busy verdict, RX is restarted and the firmware waits ~8 symbol + times: a real transmitter is still on the air and trips the receive + path. If nothing shows up, the verdict counts as a suspected false + positive. Residual contamination by real traffic biases the estimate + *conservative* (higher detPeak), which is the safe direction. + +Statistics decay (halve) every 6 hours so the picture stays fresh, and +reset completely whenever the radio parameters change (frequency, SF, BW +— the data is only valid for one configuration). + +### Auto mode (staircase) + +With `cad.auto on`, a one-sided staircase controller acts on the stats: + +- Probes concentrate on the **frontier** — one level more sensitive than + the current operating point (3 of every 4 probes), with the remainder + self-checking the operating level. +- **Step down** (more sensitive) when the frontier has ≥300 samples and + its false-positive rate is ≤1%. +- **Step up** (less sensitive) quickly when the operating level itself + shows a false-positive rate above 2% over ≥50 samples — false positives + at the operating point cost real transmissions. +- The offset is clamped to −4…+4 around the family base and persisted to + flash whenever it steps (steps are hours apart). + +Quiet sites converge *below* the standard value — more sensitive LBT than +any fixed-config firmware, meaning fewer TX-over-RX stomps. Noisy sites +ratchet up until false positives vanish. + +## Recommended workflow (dry-run first) + +Everything ships **observing but not acting**: probes run and counters +accumulate from first boot, but the operating detPeak stays at the +family default until you enable auto mode or set an offset by hand. + +1. **Let it observe.** Optionally speed up data collection during the + observation window: + + ``` + set cad.probe.interval 15 + ``` + +2. **Read the curve.** After some hours: + + ``` + get cad + ``` + + Example output: + + ``` + > auto:off offset:0 peak:21 (base 21, 4 sym) probe:15s + lvl -3 (peak 18): 240 probes, 31 busy, 28 fp, 3 tp (fp 11.6%) + lvl -2 (peak 19): 241 probes, 9 busy, 7 fp, 2 tp (fp 2.9%) + lvl -1 (peak 20): 240 probes, 3 busy, 1 fp, 2 tp (fp 0.4%) + lvl +0 (peak 21): 241 probes, 2 busy, 0 fp, 2 tp (fp 0.0%) + lvl +1 (peak 22): 240 probes, 2 busy, 0 fp, 2 tp (fp 0.0%) + lvl +2 (peak 23): 240 probes, 1 busy, 0 fp, 1 tp (fp 0.0%) + ``` + + Read it bottom-up: the false-positive rate jumps somewhere (here + between −1 and −2). The lowest clean level (−1) is your site's knee. + In dry-run mode the sweep covers levels −3…+2 evenly. + + How long to wait (at the default 60 s interval; divide by 4 at 15 s): + + | After | You get | + |---|---| + | 12–24 h | Knee location, coarsely — enough for a manual offset | + | 2–3 days | Per-level rates at ~1% resolution, incl. day/night variation | + | 1 week | Solid, weekday/weekend-proof picture | + +3. **Act.** Either pin it manually: + + ``` + set cad.offset -1 + ``` + + or let the staircase manage it from here on: + + ``` + set cad.auto on + set cad.probe.interval 60 + ``` + + With auto on, `get cad` keeps showing the live state; offset changes + appear in the log as `cad: step down/up -> offset N` and are saved to + flash automatically. + +## Command reference + +| Command | Default | Description | +|---|---|---| +| `get cad` | | Status + per-level statistics (see above). | +| `set cad.auto ` | off | Staircase controller acts on the stats. | +| `set cad.offset ` | 0 | Operating offset, −4…4. Negative = more sensitive. Applied live. | +| `set cad.probe.interval ` | 60 | Probe cadence; 0 disables probing (and freezes auto), 10–255 otherwise. | +| `set cad.reset` | | Clear accumulated statistics (RAM only). | + +All settings persist in prefs and apply to every role — repeater, room +server, and companion (companions reach the CLI via the v-contact admin +chat or USB serial). + +## Notes and limits + +- **SX127x boards** (TTGO LoRa32, T-Beam classic) have no hardware CAD; + `get cad` reports `not available` and the settings are inert. Their LBT + remains the RSSI-based `int.thresh` gate. +- **The false-positive side is measured; the miss side mostly is not.** + A too-high detPeak shows up as on-air collisions, which a single node + cannot observe. That is why the workflow is "find the lowest clean + level and sit at it", never "raise it until problems stop being + visible". The `tp` column is the one local miss-side signal: probes + that detected real below-noise-floor traffic. +- **detPeak is the only adaptive knob.** `cadDetMin` stays at Semtech's + universal 10; the symbol count is fixed at 4 (better mid-payload + detection than 2 — most of a mesh packet's airtime is payload, and + that is where a pre-TX CAD usually lands). The drivers scale their + CAD timeout with symbol count and preset automatically. +- Statistics are RAM-only and restart after a reboot; the learned + *offset* is persisted. Mixed fleets are fine — this feature changes + only when *this* node decides the channel is busy, not anything on + the air. +- Related but separate: `int.thresh` (RSSI-above-floor software gate) + and `dc.restarts` (sniff-mode false-preamble counter) still work as + before; adaptive CAD complements them. diff --git a/zephcore/ARCHITECTURE.md b/zephcore/ARCHITECTURE.md index e428927..8c8cc19 100644 --- a/zephcore/ARCHITECTURE.md +++ b/zephcore/ARCHITECTURE.md @@ -425,6 +425,26 @@ Algorithm in `triggerNoiseFloorCalibrate()`: - Periodic bypass: every 16th tick accepts unconditionally - EMA: `floor += round_nearest((sample - floor) / 8)`, clamped to [-120, -50] dBm +### 5.3.1 Adaptive CAD (LBT detPeak calibration) + +`cadDetPeak` is a correlation peak-to-noise threshold in the despreader (not +dBm), so the right LBT sensitivity is site-dependent (chirp-like interference +varies) and cannot be derived from the RSSI floor. `LoRaRadioBase::cadMaintenance()` +(housekeeping tick) runs one calibration CAD probe per `cad.probe.interval` +(default 60 s) at a signed **level** relative to the family's per-SF base +detPeak, restarts RX, and classifies busy verdicts via a ground-truth filter +(pre-probe RSSI near floor; post-busy ~8-symbol wait for real RX activity → +`tp`, else suspected `fp`). Per-level counters decay 6-hourly and reset on any +RF param change. With `cad.auto on`, a one-sided staircase steps the operating +offset down when the frontier level shows FP ≤ 1% over ≥300 probes, up quickly +when the operating level exceeds 2× target over ≥50 probes; offset clamped +−4…+4, persisted via `Dispatcher::onCadOffsetChanged()`. Probe + offset plumbing +is per-driver extension API (`*_cad_probe`, `*_cad_set_peak_offset`, +`*_cad_base_peak`); LBT CAD runs 4 symbols (set in `buildModemConfig`), and the +drivers scale their blocking-CAD timeout to `nSym·Tsym + margin`. CLI: `get cad`, +`set cad.auto/offset/probe.interval/reset`. SX127x: unsupported (no HW CAD). +User doc: `ADAPTIVE_CAD.md`. + ### 5.4 LR1110 Driver Errata Workarounds The custom `lr11xx_lora.c` driver handles several LR1110 firmware bugs: diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index 3afc8dd..ef0df39 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -687,6 +687,11 @@ else() helpers/TransportKeyStore.cpp helpers/ui/ui_mesh_actions.cpp app/CompanionMesh.cpp + # CommonCLI backs the v-contact loopback admin chat (companion_cli_exec in + # main_companion.cpp), which is NOT gated on the wired-USB stack — so it + # must be compiled unconditionally. Builds without CONFIG_LOG/COMPANION_USB/ + # COMPANION_SERIAL (e.g. plain ESP32/S3 companions) failed to link otherwise. + helpers/CommonCLI.cpp ) # Companion transport: TCP socket on native Linux, serial (UART) on boards # with no Bluetooth controller (e.g. STM32WL), BLE NUS everywhere else. @@ -724,7 +729,6 @@ else() if(CONFIG_LOG OR CONFIG_ZEPHCORE_COMPANION_USB OR CONFIG_ZEPHCORE_COMPANION_SERIAL) target_sources(app PRIVATE adapters/usb/ZephyrCompanionUSB.cpp - helpers/CommonCLI.cpp # backs the wired text CLI dispatch ) if(NOT CONFIG_CDC_ACM_SERIAL_INITIALIZE_AT_BOOT AND (CONFIG_USB_CDC_ACM OR CONFIG_USBD_CDC_ACM_CLASS)) target_sources(app PRIVATE diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 8e2ecec..4654bee 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -208,6 +208,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `get gps duty` | Now-effective GPS duty interval in seconds (`always on (0)` when continuous) | | `get meshtimesync` | Mesh time-sync state + live dry-run: on/off, eligible voter count, votes for/against, consensus skew and radius, would-be verdict (`ok`/`in-band`/`step±N`/`abstain (reason)`/`hold (reason)`; a recent clock set — manual or GPS — shows as `hold (suppressed)`, and a backward step a forward-only role would refuse is annotated `(skipped: forward-only)`), step counters, suppression countdown, and a per-sender evidence table (`prefix hops count skew E`, `E` = counted toward the verdict above). Entries that count print first, so a size-capped reply never hides the ones that explain the summary; if the table doesn't fully fit, a trailing `+N more` shows how many were left out. Sensing runs even while off, so this works as a dry-run before enabling. Over remote admin the reply is truncated to the packet size (summary always fits); the full table needs the USB CLI. | | `get dc.restarts` | Duty-cycle preamble false-positive re-arm counter (RxTimeout re-arms + parked-RX watchdog recoveries). High values mean the preamble detector is tripping on noise/interference without real packets arriving — inflates RX-on time and drains battery; packets are never lost to it. Reset by `clear stats`. | +| `get cad` | Adaptive-CAD status + per-level probe statistics: auto on/off, operating detPeak offset and absolute peak (with family base), probe interval, then one line per probed level with probe/busy/fp/tp counts and false-positive rate. Probing runs even while `cad.auto` is off (dry-run), so this is the observation tool for picking a site-appropriate detPeak. See `ADAPTIVE_CAD.md`. Not available on SX127x boards (no hardware CAD). | | `get adc.multiplier` | Battery voltage ADC calibration multiplier | | `get bootloader.ver` | Bootloader version string | | `get public.key` | *(USB only)* Node's public key as hex | @@ -252,6 +253,10 @@ Changes are persisted immediately unless noted. Some require a reboot. | `set rxduty <0\|1\|on\|off>` | | RX duty cycle mode *(reboot required)*. Window timing auto-sized per SF/BW/preamble from the SX126x datasheet constraints (boot log line `rxduty:` shows the result). Zero-loss guarantee assumes senders on preamble-32 firmware (current MeshCore at SF≤8); legacy preamble-16 senders are only caught ~50% worst-phase — keep off until the local mesh has converted. Presets with 16-symbol preambles (SF≥9) fall back to continuous RX automatically. | | `set adc.multiplier ` | (0 = use board default) | Battery voltage ADC calibration multiplier | | `set meshtimesync ` | default **off** | Mesh time sync: automatically correct this node's clock from the consensus of Ed25519-signed advert timestamps heard on the mesh. Steps at most ±1 h per step, one step per 6 h; abstains without a quorum (default 6) of tenured agreeing senders; never overrides a clock set in the last 7 days, whether from GPS (re-armed on every fix) or a manual set. See `MESHTIMESYNC.md`. | +| `set cad.auto ` | default **off** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. Leave off (dry-run) until `get cad` has accumulated a few days of per-level data. See `ADAPTIVE_CAD.md`. | +| `set cad.offset ` | −4 to 4, default 0 | Operating detPeak offset from the chip family's per-SF base (SX126x: SF+13; LR11xx/LR20xx: 56–68 table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Applied live; the auto staircase may move it later if `cad.auto` is on. | +| `set cad.probe.interval ` | 0 (off) or 10–255, default **60** | Seconds between calibration CAD probes. Lower (15–20 s) during a dry-run observation window to accumulate statistics faster; 0 disables probing entirely (also freezes auto adaptation). | +| `set cad.reset` | | Clear the accumulated per-level CAD probe statistics (RAM only; also cleared automatically on any radio parameter change). | | `set prv.key ` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* | --- diff --git a/zephcore/adapters/datastore/ZephyrDataStore.cpp b/zephcore/adapters/datastore/ZephyrDataStore.cpp index 3ec1f1a..84666bb 100644 --- a/zephcore/adapters/datastore/ZephyrDataStore.cpp +++ b/zephcore/adapters/datastore/ZephyrDataStore.cpp @@ -693,6 +693,31 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs) } else { prefs.v_battery_alert_mv = 0xFFFF; } + + /* Offset 155: cad_auto (ZephCore extension, default 0 = dry-run) */ + if (off < len) { + prefs.cad_auto = buf[off++]; + if (prefs.cad_auto > 1) { + prefs.cad_auto = 0; + } + } + + /* Offset 156: cad_offset (ZephCore extension, signed -4..4, default 0) */ + if (off < len) { + prefs.cad_offset = (int8_t)buf[off++]; + if (prefs.cad_offset < -4 || prefs.cad_offset > 4) { + prefs.cad_offset = 0; + } + } + + /* Offset 157: cad_probe_interval (ZephCore extension, seconds; 0 = off). + * Absent in pre-existing files → keep the in-RAM default (60). */ + if (off < len) { + prefs.cad_probe_interval = buf[off++]; + if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) { + prefs.cad_probe_interval = 10; + } + } } void ZephyrDataStore::savePrefs(const NodePrefs &prefs) @@ -774,7 +799,13 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs) /* Offset 153: v_battery_alert_mv (ZephCore extension, 2 bytes LE) */ buf[off++] = prefs.v_battery_alert_mv & 0xFF; buf[off++] = (prefs.v_battery_alert_mv >> 8) & 0xFF; - /* Total: 155 bytes */ + /* Offset 155: cad_auto (ZephCore extension) */ + buf[off++] = prefs.cad_auto; + /* Offset 156: cad_offset (ZephCore extension, signed) */ + buf[off++] = (uint8_t)prefs.cad_offset; + /* Offset 157: cad_probe_interval (ZephCore extension, seconds) */ + buf[off++] = prefs.cad_probe_interval; + /* Total: 158 bytes */ bool ok = atomicReplaceFile(PREFS_FILE, buf, off); LOG_DBG("savePrefs: wrote %s, ok=%d (%d bytes), name='%.16s'", diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index f82f76c..2a4f8a0 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -84,4 +84,19 @@ uint32_t LR1110Radio::hwWakeupTimeUs() return lr11xx_get_wakeup_time_us(_dev); } +int LR1110Radio::hwCadProbe(int8_t level) +{ + return lr11xx_cad_probe(_dev, level); +} + +void LR1110Radio::hwCadSetPeakOffset(int8_t offset) +{ + lr11xx_cad_set_peak_offset(_dev, offset); +} + +uint8_t LR1110Radio::hwCadBasePeak() +{ + return lr11xx_cad_base_peak(_dev); +} + } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index 5497a10..e5eeba6 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -29,6 +29,9 @@ protected: void hwSetRxBoost(bool enable) override; void hwResetAGC() override; uint32_t hwWakeupTimeUs() override; + int hwCadProbe(int8_t level) override; + void hwCadSetPeakOffset(int8_t offset) override; + uint8_t hwCadBasePeak() override; }; } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index b435008..014d1b6 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -78,4 +78,19 @@ void LR2021Radio::hwResetAGC() lr20xx_reset_agc(_dev); } +int LR2021Radio::hwCadProbe(int8_t level) +{ + return lr20xx_cad_probe(_dev, level); +} + +void LR2021Radio::hwCadSetPeakOffset(int8_t offset) +{ + lr20xx_cad_set_peak_offset(_dev, offset); +} + +uint8_t LR2021Radio::hwCadBasePeak() +{ + return lr20xx_cad_base_peak(_dev); +} + } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index 00e1fe8..916728e 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -28,6 +28,9 @@ protected: bool hwIsReceiving() override; void hwSetRxBoost(bool enable) override; void hwResetAGC() override; + int hwCadProbe(int8_t level) override; + void hwCadSetPeakOffset(int8_t offset) override; + uint8_t hwCadBasePeak() override; }; } /* namespace mesh */ diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index bb74b97..0d76ca0 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include @@ -46,6 +47,8 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _last_rssi(0), _last_snr(0), _rx_head(0), _rx_tail(0), _noise_floor(DEFAULT_NOISE_FLOOR), _calibration_threshold(0), _ema_unguarded(0), + _cad_auto(false), _cad_offset(0), _cad_probe_interval_s(0), + _cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_probe_rr(0), _rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)), _rx_boost_enabled(true), _tx_power_reduction_db(0), @@ -62,6 +65,7 @@ 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 ──────────────────────────────────────────── */ @@ -246,6 +250,14 @@ void LoRaRadioBase::buildModemConfig(struct lora_modem_config &cfg, bool tx) * direction-only fast path (which skips hwConfigure). RX paths * never read cad.mode, so this is harmless during receive. */ cfg.cad.mode = LORA_CAD_MODE_LBT; + + /* 4-symbol CAD at every SF (drivers default to 2 when this is 0). + * Our LBT runs against mesh packets that are mostly payload airtime; + * payload chirps correlate less reliably per symbol than preamble + * upchirps, so the extra looks matter — AN1200.48 itself recommends + * 4 symbols at SF9+. The drivers scale their blocking-CAD timeout + * from this value, so slow presets stay covered. */ + cfg.cad.symbol_num = LORA_CAD_SYMB_4; } uint32_t LoRaRadioBase::getActiveFrequencyHz() const @@ -446,6 +458,8 @@ void LoRaRadioBase::reconfigure() hwCancelReceive(); atomic_set(&_in_recv_mode, 0); _config_cached = false; /* Force full reconfigure */ + /* CAD probe statistics are only valid for one freq/SF/BW config. */ + resetCadStats(); startReceive(); uint32_t freq = _prefs ? (uint32_t)(_prefs->freq * 1000000.0f) @@ -931,6 +945,230 @@ bool LoRaRadioBase::isChannelActive(int threshold) return rssi > (_noise_floor + threshold); } +/* ── Adaptive CAD (LBT detPeak calibration) ───────────────────────────── */ + +void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset, + uint16_t probe_interval_s) +{ + if (offset < CAD_LEVEL_MIN) offset = CAD_LEVEL_MIN; + if (offset > CAD_LEVEL_MAX) offset = CAD_LEVEL_MAX; + + _cad_auto = auto_enabled; + _cad_offset = offset; + _cad_probe_interval_s = probe_interval_s; + hwCadSetPeakOffset(_cad_offset); + + LOG_INF("cad: auto=%d offset=%d probe_interval=%us", + (int)auto_enabled, (int)offset, (unsigned)probe_interval_s); +} + +void LoRaRadioBase::resetCadStats() +{ + memset(_cad_stats, 0, sizeof(_cad_stats)); + _cad_probe_rr = 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) { + /* 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: concentrate samples on the frontier (one step more sensitive + * than the operating point); every 4th probe self-checks the + * operating level. */ + int8_t frontier = _cad_offset > CAD_LEVEL_MIN ? (int8_t)(_cad_offset - 1) + : _cad_offset; + + return ((_cad_probe_rr & 3) == 0) ? _cad_offset : frontier; +} + +void LoRaRadioBase::cadStaircaseStep() +{ + /* Step down (more sensitive) when the frontier level has enough + * samples and its suspected-FP rate is at or under target. */ + if (_cad_offset > CAD_LEVEL_MIN) { + CadLevelStats &f = _cad_stats[(_cad_offset - 1) - CAD_LEVEL_MIN]; + + if (f.probes >= CAD_STEP_DOWN_MIN_PROBES && + (uint32_t)f.fp * 1000U <= + (uint32_t)f.probes * CAD_FP_TARGET_PERMILLE) { + _cad_offset--; + hwCadSetPeakOffset(_cad_offset); + LOG_INF("cad: step down -> offset %d (frontier %up/%ufp)", + (int)_cad_offset, f.probes, f.fp); + return; + } + } + + /* Step up (less sensitive) when the operating level itself shows + * FPs well above target. Lower sample bar: FPs at the operating + * point cost real TX opportunities, react quickly. */ + if (_cad_offset < CAD_LEVEL_MAX) { + CadLevelStats &o = _cad_stats[_cad_offset - CAD_LEVEL_MIN]; + + if (o.probes >= CAD_STEP_UP_MIN_PROBES && + (uint32_t)o.fp * 1000U > + (uint32_t)o.probes * 2U * CAD_FP_TARGET_PERMILLE) { + _cad_offset++; + hwCadSetPeakOffset(_cad_offset); + LOG_INF("cad: step up -> offset %d (operating %up/%ufp)", + (int)_cad_offset, o.probes, o.fp); + } + } +} + +void LoRaRadioBase::cadMaintenance() +{ + if (_cad_probe_interval_s == 0) { + return; + } + + int64_t now = k_uptime_get(); + + /* Periodic decay keeps the stats fresh (and counters bounded). */ + 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_last_decay_ms = now; + } + + if (now - _cad_last_probe_ms < (int64_t)_cad_probe_interval_s * 1000) { + return; + } + + /* Same guards as the noise-floor calibrator: only probe from idle + * continuous/duty-cycle RX, never during TX or an active packet, + * never while the chip is in its duty-cycle sleep (BUSY) phase. */ + if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) { + return; + } + if (!isRadioReady() || isReceiving()) { + return; + } + + /* Ground-truth prefilter: skip when the channel is visibly busy — + * a busy verdict against strong traffic teaches us nothing about + * false positives. (Below-noise-floor LoRa can't be excluded here; + * the post-probe RX check below handles that side.) */ + int16_t rssi = hwGetCurrentRSSI(); + + if (rssi == -128) { + return; + } + if (_noise_floor != DEFAULT_NOISE_FLOOR && + rssi > _noise_floor + CAD_PROBE_RSSI_GUARD) { + return; + } + + _cad_last_probe_ms = now; + + int8_t level = pickCadProbeLevel(); + int ret = hwCadProbe(level); + + /* The probe leaves the chip in STANDBY (driver state REST) — re-arm + * RX immediately so an incoming packet isn't lost while we classify. + * In duty-cycle mode this re-enters the DC cycle (same path as the + * parked-RX watchdog re-arm). */ + atomic_set(&_in_recv_mode, 0); + startReceive(); + + if (ret < 0) { + if (ret != -ENOSYS) { + LOG_WRN("cad: probe failed (%d)", ret); + } + return; + } + + CadLevelStats &s = _cad_stats[level - CAD_LEVEL_MIN]; + + if (s.probes >= 0xFFF0) { + decayCadStats(); + } + s.probes++; + + if (ret > 0) { + s.busy++; + + /* Ground-truth post-check: a real LoRa signal that tripped + * CAD keeps transmitting — after RX restart its preamble or + * header trips the receive path within a few symbols. Wait + * ~8 symbols, then classify. RX is already armed, so the + * packet itself is not at risk during this sleep. */ + uint8_t sf = getActiveSpreadingFactor(); + uint16_t bw_x10 = getActiveBandwidthKHzX10(); + uint32_t tsym_us = bw_x10 ? (uint32_t)(((1UL << sf) * 10000UL) + / bw_x10) : 1024; + uint32_t wait_ms = (8U * tsym_us) / 1000U; + + if (wait_ms < 20) wait_ms = 20; + if (wait_ms > 400) wait_ms = 400; + k_sleep(K_MSEC(wait_ms)); + + if (isReceiving()) { + s.tp++; + } else { + s.fp++; + } + } + + if (_cad_auto) { + cadStaircaseStep(); + } +} + +int LoRaRadioBase::formatCadStatus(char *buf, int cap) +{ + uint8_t base = hwCadBasePeak(); + int n = 0; + + if (base == 0) { + return snprintf(buf, cap, "CAD: not supported by this radio"); + } + + int peak = (int)base + _cad_offset; + + n += snprintf(buf + n, cap > n ? cap - n : 0, + "auto:%s offset:%d peak:%d (base %u, 4 sym) probe:%us", + _cad_auto ? "on" : "off", (int)_cad_offset, peak, + base, (unsigned)_cad_probe_interval_s); + + for (int i = 0; i < CAD_NUM_LEVELS; i++) { + CadLevelStats &s = _cad_stats[i]; + + if (s.probes == 0) { + continue; + } + /* FP rate in tenths of a percent */ + uint32_t fp_pm = ((uint32_t)s.fp * 1000U) / s.probes; + + n += snprintf(buf + n, cap > n ? cap - n : 0, + "\nlvl %+d (peak %d): %u probes, %u busy, %u fp, %u tp (fp %u.%u%%)", + i + CAD_LEVEL_MIN, (int)base + i + CAD_LEVEL_MIN, + s.probes, s.busy, s.fp, s.tp, + (unsigned)(fp_pm / 10), (unsigned)(fp_pm % 10)); + } + + return n; +} + /* ── Power saving ─────────────────────────────────────────────────────── */ void LoRaRadioBase::enableRxDutyCycle(bool enable) diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index e6265dc..ca62815 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -109,6 +109,14 @@ public: void setTxPowerReduction(int8_t reduction_db) override { _tx_power_reduction_db = reduction_db; } int8_t getTxPowerReduction() const override { return _tx_power_reduction_db; } + /* Adaptive CAD (LBT detPeak calibration) */ + void setCadParams(bool auto_enabled, int8_t offset, + uint16_t probe_interval_s) override; + void cadMaintenance() override; + int8_t getCadOffset() const override { return _cad_offset; } + void resetCadStats() override; + int formatCadStatus(char *buf, int cap) override; + protected: /* ── Hardware primitives — subclass MUST implement ─────────── */ @@ -126,6 +134,18 @@ protected: /** GPIO-only BUSY check (no SPI). Default false for chips without duty-cycle sleep. */ virtual bool hwIsChipBusy() { return false; } + /* ── Adaptive-CAD primitives — defaults suit chips without hardware + * CAD (SX127x): probing unsupported, offset ignored. ───────────── */ + + /** Blocking calibration CAD at (family base detPeak + level). + * Leaves the chip in STANDBY; caller restarts RX. + * Returns 1 = busy, 0 = free, <0 = error / unsupported. */ + virtual int hwCadProbe(int8_t level) { (void)level; return -ENOSYS; } + /** 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; } + /** 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 @@ -180,6 +200,25 @@ protected: int _calibration_threshold; uint8_t _ema_unguarded; /* tick counter for warmup + periodic bypass */ + /* 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) */ + uint16_t _cad_probe_interval_s; /* 0 = probing disabled */ + int64_t _cad_last_probe_ms; + int64_t _cad_last_decay_ms; + 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; bool _rx_boost_enabled; diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index f4cd9e7..7501600 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -96,6 +96,21 @@ uint32_t SX126xRadio::hwWakeupTimeUs() return sx126x_get_wakeup_time_us(_dev); } +int SX126xRadio::hwCadProbe(int8_t level) +{ + return sx126x_cad_probe(_dev, level); +} + +void SX126xRadio::hwCadSetPeakOffset(int8_t offset) +{ + sx126x_cad_set_peak_offset(_dev, offset); +} + +uint8_t SX126xRadio::hwCadBasePeak() +{ + return sx126x_cad_base_peak(_dev); +} + uint32_t SX126xRadio::getDutyCycleTimeoutRestarts() const { return sx126x_get_dc_timeout_restarts(_dev); diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 15d727a..645e26e 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -32,6 +32,9 @@ protected: void hwResetAGC() override; bool hwIsChipBusy() override; uint32_t hwWakeupTimeUs() override; + int hwCadProbe(int8_t level) override; + void hwCadSetPeakOffset(int8_t offset) override; + uint8_t hwCadBasePeak() override; }; } /* namespace mesh */ diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index 5b02489..ce94915 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -20,6 +20,23 @@ #define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */ #define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */ +/* --- 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 (SX126x: SF+13; LR11xx/LR20xx: + * 56-68 table) so the C++ layer stays scale-independent. */ +#define CAD_LEVEL_MIN (-4) /* most sensitive probe level */ +#define CAD_LEVEL_MAX 4 /* least sensitive probe level */ +#define CAD_NUM_LEVELS (CAD_LEVEL_MAX - CAD_LEVEL_MIN + 1) +#define CAD_SWEEP_MIN (-3) /* dry-run sweep window */ +#define CAD_SWEEP_MAX 2 +#define CAD_FP_TARGET_PERMILLE 10 /* step-down needs FP rate <= 1% */ +#define CAD_STEP_DOWN_MIN_PROBES 300 /* samples before a down-step call */ +#define CAD_STEP_UP_MIN_PROBES 50 /* samples before an up-step call */ +#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 */ + /* --- RX ring buffer --- */ #define RX_RING_SIZE 8 /* ~2 KB; buffers burst arrivals at SF7/BW500 */ diff --git a/zephcore/app/CompanionMesh.h b/zephcore/app/CompanionMesh.h index f99e509..87761fb 100644 --- a/zephcore/app/CompanionMesh.h +++ b/zephcore/app/CompanionMesh.h @@ -353,6 +353,14 @@ protected: uint8_t getDutyCyclePercent() const override; uint8_t getExtraAckTransmitCount() const override; + /* Adaptive CAD: persist the staircase's learned offset */ + void onCadOffsetChanged(int8_t offset) override { + prefs.cad_offset = offset; + if (_store) { + _store->savePrefs(prefs); + } + } + /* Auto-add filtering overrides */ bool isAutoAddEnabled() const override; bool shouldAutoAddContactType(uint8_t type) const override; diff --git a/zephcore/app/RepeaterDataStore.cpp b/zephcore/app/RepeaterDataStore.cpp index 308643e..95b9523 100644 --- a/zephcore/app/RepeaterDataStore.cpp +++ b/zephcore/app/RepeaterDataStore.cpp @@ -203,6 +203,11 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { fs_read(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); /* Mesh time sync (absent in <297-byte files; no-op EOF read keeps default 0 = off) */ fs_read(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync)); + /* Adaptive CAD (absent in <300-byte files; no-op EOF reads keep defaults + * auto=0, offset=0, probe_interval=60) */ + fs_read(&file, &prefs.cad_auto, sizeof(prefs.cad_auto)); + fs_read(&file, &prefs.cad_offset, sizeof(prefs.cad_offset)); + fs_read(&file, &prefs.cad_probe_interval, sizeof(prefs.cad_probe_interval)); fs_close(&file); @@ -234,6 +239,9 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) { if (prefs.apc_enabled > 1) prefs.apc_enabled = 0; if (prefs.apc_margin < 6 || prefs.apc_margin > 30) prefs.apc_margin = 16; if (prefs.meshtimesync > 1) prefs.meshtimesync = 0; + if (prefs.cad_auto > 1) prefs.cad_auto = 0; + if (prefs.cad_offset < -4 || prefs.cad_offset > 4) prefs.cad_offset = 0; + if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) prefs.cad_probe_interval = 10; /* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore * extension fields, and stored path_hash_mode/loop_detect as zero padding. @@ -333,6 +341,10 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) { fs_write(&file, &prefs.flood_max_advert, sizeof(prefs.flood_max_advert)); /* Mesh time sync on/off (offset 296) */ fs_write(&file, &prefs.meshtimesync, sizeof(prefs.meshtimesync)); + /* Adaptive CAD (offsets 297-299) */ + fs_write(&file, &prefs.cad_auto, sizeof(prefs.cad_auto)); + fs_write(&file, &prefs.cad_offset, sizeof(prefs.cad_offset)); + fs_write(&file, &prefs.cad_probe_interval, sizeof(prefs.cad_probe_interval)); ret = fs_sync(&file); fs_close(&file); diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index a9b6407..830b5a9 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -179,6 +179,24 @@ protected: return _prefs.multi_acks; } + /* Adaptive CAD */ + int formatCadStatus(char* buf, int cap) override { + return _radio->formatCadStatus(buf, cap); + } + void applyCadPrefs() override { + _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, + _prefs.cad_probe_interval); + } + void resetCadStats() override { + _radio->resetCadStats(); + } + void onCadOffsetChanged(int8_t offset) override { + /* Staircase steps are hours apart — persisting immediately is + * fine for flash wear and survives unexpected reboots. */ + _prefs.cad_offset = offset; + savePrefs(); + } + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; void onAnonDataRecv(mesh::Packet* packet, const uint8_t* secret, const mesh::Identity& sender, uint8_t* data, size_t len) override; diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index 4066785..2e43daf 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -148,6 +148,22 @@ protected: return _prefs.multi_acks; } + /* Adaptive CAD */ + int formatCadStatus(char* buf, int cap) override { + return _radio->formatCadStatus(buf, cap); + } + void applyCadPrefs() override { + _radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset, + _prefs.cad_probe_interval); + } + void resetCadStats() override { + _radio->resetCadStats(); + } + void onCadOffsetChanged(int8_t offset) override { + _prefs.cad_offset = offset; + savePrefs(); + } + mesh::DispatcherAction onRecvPacket(mesh::Packet* pkt) override; void onAdvertRecv(mesh::Packet* packet, const mesh::Identity& id, uint32_t timestamp, const uint8_t* app_data, size_t app_data_len) override; diff --git a/zephcore/boards/esp32/ttgo_lora32/board.conf b/zephcore/boards/esp32/ttgo_lora32/board.conf index 417b157..a3430c7 100644 --- a/zephcore/boards/esp32/ttgo_lora32/board.conf +++ b/zephcore/boards/esp32/ttgo_lora32/board.conf @@ -26,6 +26,15 @@ CONFIG_ZEPHCORE_DEFAULT_TX_POWER_DBM=17 # lora_recv_async, but disable it to avoid the spurious attempt) CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE=n +# Companion RAM budget — same constraint as the T-Beam: the classic ESP32's +# contiguous DRAM segment can't fit the default contact/queue arrays (350/256) +# alongside the BT controller heap. The T-Beam sizes (160/8/128) still +# overflow here by ~1.2 KB — the SX1276 loramac-node backend carries more +# static DRAM than the native SX126x driver — so contacts go to 150. +CONFIG_ZEPHCORE_MAX_CONTACTS=150 +CONFIG_ZEPHCORE_MAX_CHANNELS=8 +CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE=128 + # Flash mode: ESP32-PICO-D4 (rev 1.0) — use DIO, not QIO. # esp32_common.conf enables QIO for the ESP32-S3/C3/C6 boards in this repo. # Classic ESP32 PICO-D4 rev 1.0 has an issue where bootloader_enable_qio_mode() diff --git a/zephcore/boards/esp32/ttgo_lora32/board.overlay b/zephcore/boards/esp32/ttgo_lora32/board.overlay index df00eff..7ea4be3 100644 --- a/zephcore/boards/esp32/ttgo_lora32/board.overlay +++ b/zephcore/boards/esp32/ttgo_lora32/board.overlay @@ -62,6 +62,18 @@ status = "okay"; }; +/* + * BT HCI controller — the upstream ttgo_lora32 DTS leaves the node disabled + * (esp32_common.dtsi already sets chosen zephyr,bt-hci to it). Companion + * builds set CONFIG_BT=y, and the hal_espressif CMake compiles the ESP32 BT + * controller under CONFIG_BT alone; without this node BT_ESP32 stays off and + * its Kconfig symbols (CONFIG_ESP32_BT_CONTROLLER_TASK_PRIO etc.) are + * undefined, breaking the bt.c compile. Same fix as ttgo_tbeam. + */ +&esp32_bt_hci { + status = "okay"; +}; + /* I2C sensors — auto-detected at runtime */ &i2c0 { #include "../../common/sensors-i2c.dtsi" diff --git a/zephcore/boards/esp32/ttgo_tbeam/board.conf b/zephcore/boards/esp32/ttgo_tbeam/board.conf index 2f640e6..bd398e9 100644 --- a/zephcore/boards/esp32/ttgo_tbeam/board.conf +++ b/zephcore/boards/esp32/ttgo_tbeam/board.conf @@ -34,7 +34,10 @@ CONFIG_ESPTOOLPY_FLASHMODE_DIO=y # Companion RAM budget — the classic ESP32 has a much smaller contiguous DRAM # segment than the ESP32-S3 boards, and the default contact/queue arrays -# (350/256) overflow it. Use the Arduino MeshCore T-Beam companion sizes. -CONFIG_ZEPHCORE_MAX_CONTACTS=160 +# (350/256) overflow it. Started from the Arduino MeshCore T-Beam companion +# sizes (160 contacts); trimmed to 140 when CommonCLI (v-contact admin chat) +# became part of every companion build and its ~3 KB of static state pushed +# DRAM back over the edge. +CONFIG_ZEPHCORE_MAX_CONTACTS=140 CONFIG_ZEPHCORE_MAX_CHANNELS=8 CONFIG_ZEPHCORE_OFFLINE_QUEUE_SIZE=128 diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index f3eec87..f2b02fb 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -121,6 +121,9 @@ void CommonCLI::loadPrefs(const char* path) { ok = ok && prefs_read(&file, &_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); // 294 ok = ok && prefs_read(&file, &_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 295 ok = ok && prefs_read(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); // 296 + ok = ok && prefs_read(&file, &_prefs->cad_auto, sizeof(_prefs->cad_auto)); // 297 + ok = ok && prefs_read(&file, &_prefs->cad_offset, sizeof(_prefs->cad_offset)); // 298 + ok = ok && prefs_read(&file, &_prefs->cad_probe_interval, sizeof(_prefs->cad_probe_interval)); // 299 if (!ok) { LOG_WRN("Prefs file %s truncated, some fields use defaults", path); @@ -167,6 +170,11 @@ void CommonCLI::loadPrefs(const char* path) { _prefs->flood_max_unscoped = constrain(_prefs->flood_max_unscoped, (uint8_t)0, (uint8_t)64); _prefs->flood_max_advert = constrain(_prefs->flood_max_advert, (uint8_t)0, (uint8_t)64); _prefs->meshtimesync = constrain(_prefs->meshtimesync, (uint8_t)0, (uint8_t)1); + _prefs->cad_auto = constrain(_prefs->cad_auto, (uint8_t)0, (uint8_t)1); + _prefs->cad_offset = constrain(_prefs->cad_offset, (int8_t)-4, (int8_t)4); + if (_prefs->cad_probe_interval != 0 && _prefs->cad_probe_interval < 10) { + _prefs->cad_probe_interval = 10; + } LOG_INF("Loaded prefs from %s", path); } @@ -235,6 +243,9 @@ void CommonCLI::savePrefs(const char* path) { fs_write(&file, &_prefs->flood_max_unscoped, sizeof(_prefs->flood_max_unscoped)); fs_write(&file, &_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); fs_write(&file, &_prefs->meshtimesync, sizeof(_prefs->meshtimesync)); + fs_write(&file, &_prefs->cad_auto, sizeof(_prefs->cad_auto)); + fs_write(&file, &_prefs->cad_offset, sizeof(_prefs->cad_offset)); + fs_write(&file, &_prefs->cad_probe_interval, sizeof(_prefs->cad_probe_interval)); fs_close(&file); LOG_INF("Saved prefs to %s", path); @@ -560,6 +571,15 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch } else if (memcmp(config, "dc.restarts", 11) == 0) { snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_callbacks->getDutyCycleTimeoutRestarts()); + } else if (memcmp(config, "cad", 3) == 0) { + /* Runtime state + per-level probe stats live in the radio. + * Remote replies get the truncated buffer like meshtimesync. */ + size_t cap = (sender_timestamp == 0) ? CLI_REPLY_SIZE + : CLI_REMOTE_REPLY_SIZE; + int n = snprintf(reply, cap, "> "); + if (_callbacks->formatCadStatus(reply + n, (int)cap - n) == 0) { + strcpy(reply, "not available"); + } } else if (memcmp(config, "meshtimesync", 12) == 0) { MeshTimeSync* ts = _callbacks->getMeshTimeSync(); if (ts == nullptr) { @@ -606,6 +626,38 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch _prefs->agc_reset_interval = atoi(&config[19]) / 4; savePrefs(); snprintf(reply, CLI_REPLY_SIZE, "OK - interval rounded to %u", ((uint32_t)_prefs->agc_reset_interval) * 4); + } else if (memcmp(config, "cad.auto ", 9) == 0) { + if (memcmp(&config[9], "on", 2) == 0 || memcmp(&config[9], "off", 3) == 0) { + _prefs->cad_auto = (config[9] == 'o' && config[10] == 'n') ? 1 : 0; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } else { + strcpy(reply, "Error: must be on or off"); + } + } else if (memcmp(config, "cad.offset ", 11) == 0) { + int val = atoi(&config[11]); + if (val < -4 || val > 4) { + strcpy(reply, "Error: offset range is -4..4"); + } else { + _prefs->cad_offset = (int8_t)val; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "cad.probe.interval ", 19) == 0) { + int val = atoi(&config[19]); + if (val != 0 && (val < 10 || val > 255)) { + strcpy(reply, "Error: interval is 0 (off) or 10-255 seconds"); + } else { + _prefs->cad_probe_interval = (uint8_t)val; + _callbacks->applyCadPrefs(); + savePrefs(); + strcpy(reply, "OK"); + } + } else if (memcmp(config, "cad.reset", 9) == 0) { + _callbacks->resetCadStats(); + strcpy(reply, "OK - CAD probe stats cleared"); } else if (memcmp(config, "multi.acks ", 11) == 0) { int val = atoi(&config[11]); if (val == 0 || val == 1) { diff --git a/zephcore/helpers/CommonCLI.h b/zephcore/helpers/CommonCLI.h index 8667288..5ba9a00 100644 --- a/zephcore/helpers/CommonCLI.h +++ b/zephcore/helpers/CommonCLI.h @@ -82,6 +82,11 @@ public: virtual uint8_t getAPCTargetMargin() const { return 16; } virtual void setAPCTargetMargin(uint8_t margin_db) { (void)margin_db; } + // Adaptive CAD (LBT detPeak calibration) + virtual int formatCadStatus(char* buf, int cap) { (void)buf; (void)cap; return 0; } + virtual void applyCadPrefs() {} + virtual void resetCadStats() {} + // Mesh time sync (all roles wire one; nullptr = not compiled/available) virtual MeshTimeSync* getMeshTimeSync() { return nullptr; } diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index 8e9993a..1c459f9 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -65,6 +65,9 @@ struct NodePrefs { uint8_t apc_enabled; // 1 = APC on, 0 = fixed TX power uint8_t apc_margin; // APC target link margin dB (6-30) uint8_t meshtimesync; // 1 = mesh time-sync clock correction on (default off) + uint8_t cad_auto; // 1 = adaptive-CAD staircase acts on probe stats (default off = dry-run) + int8_t cad_offset; // operating detPeak offset from family base (-4..4) + uint8_t cad_probe_interval; // seconds between CAD probes (0 = probing off, default 60) /* ---- Companion-only fields ---- */ uint8_t manual_add_contacts; @@ -136,6 +139,9 @@ static inline void initNodePrefs(NodePrefs* prefs) { prefs->rx_duty_cycle = 0; // Default OFF — continuous RX for best reliability prefs->apc_enabled = 0; // Default OFF — fixed TX power prefs->apc_margin = 16; // Default 16 dB target link margin + prefs->cad_auto = 0; // Default OFF — dry-run: probes + counters only + prefs->cad_offset = 0; // Family base detPeak (SF+13 on SX126x) + prefs->cad_probe_interval = 60; // One CAD probe per minute prefs->wake_on_msg = 1; // Default ON — wake display when message arrives prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion) prefs->v_battery_alert_mv = 0xFFFF; // Sentinel: derive from board auto-shutdown threshold diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index 335057d..932dd5b 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -59,6 +59,8 @@ class Dispatcher { uint32_t radio_nonrx_start; uint32_t next_agc_reset_time; bool prev_isrecv_mode; + int8_t cad_offset_shadow; + bool cad_offset_shadow_valid; uint32_t n_sent_flood, n_sent_direct; uint32_t n_recv_flood, n_recv_direct; tx_queued_callback_t _tx_queued_cb; @@ -89,6 +91,9 @@ protected: virtual int getInterferenceThreshold() const { return 0; } virtual int getAGCResetInterval() const { return 0; } virtual uint32_t getDutyCycleWindowMs() const { return 3600000UL; } /* 1h default */ + /* Adaptive CAD: called when the auto staircase moved the operating + * detPeak offset — subclasses persist it to prefs. */ + virtual void onCadOffsetChanged(int8_t offset) { (void)offset; } public: void begin(); diff --git a/zephcore/include/mesh/Radio.h b/zephcore/include/mesh/Radio.h index 53cbac7..4af9647 100644 --- a/zephcore/include/mesh/Radio.h +++ b/zephcore/include/mesh/Radio.h @@ -41,6 +41,23 @@ public: virtual void setTxPowerReduction(int8_t reduction_db) { (void)reduction_db; } virtual int8_t getTxPowerReduction() const { return 0; } + /* Adaptive CAD (LBT detPeak calibration). Default no-ops for radios + * without hardware CAD (SX127x). */ + virtual void setCadParams(bool auto_enabled, int8_t offset, + uint16_t probe_interval_s) { + (void)auto_enabled; (void)offset; (void)probe_interval_s; + } + /* One housekeeping tick of the CAD calibrator: maybe run a probe, + * update stats, maybe step the staircase (auto mode). */ + virtual void cadMaintenance() {} + virtual int8_t getCadOffset() const { return 0; } + virtual void resetCadStats() {} + /* Writes a human-readable status block; returns chars written (0 = not + * supported by this radio). */ + virtual int formatCadStatus(char *buf, int cap) { + (void)buf; (void)cap; return 0; + } + /* Packet statistics */ virtual uint32_t getPacketsRecv() const { return 0; } virtual uint32_t getPacketsSent() const { return 0; } 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 e20ddba..8472d18 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.c @@ -95,6 +95,10 @@ struct lr11xx_data { struct k_sem cad_sem; int cad_result; bool cad_active; + /* Adaptive-CAD: signed offset applied to the per-SF base detPeak on + * every LBT CAD; cad_probe_peak overrides for one calibration probe. */ + int8_t cad_peak_offset; + uint8_t cad_probe_peak; /* Deferred hardware init — heavy SPI/radio work runs on first config() */ bool hw_initialized; @@ -710,6 +714,29 @@ static uint32_t lr11xx_lora_airtime(const struct device *dev, /* Forward declaration — needed by LBT in send_async */ static int lr11xx_lora_cad(const struct device *dev, k_timeout_t timeout); +/* Blocking-CAD wait budget scaled to the actual CAD duration: + * nSym * Tsym + startup radio-side, plus IRQ latency margin. A fixed + * 200 ms fits 2-symbol CAD everywhere but is exceeded by 4-symbol CAD + * on slow presets (SF12 @ 62.5 kHz = ~262 ms). */ +static uint32_t lr11xx_cad_timeout_ms(struct lr11xx_data *data) +{ + struct lora_modem_config *mc = &data->modem_cfg; + uint8_t sf = (uint8_t)mc->datarate; + uint8_t symb_nb = mc->cad.symbol_num ? + (uint8_t)mc->cad.symbol_num : 2; + uint32_t bw_hz = (uint32_t)(bw_enum_to_khz(mc->bandwidth) * 1000.0f); + + if (bw_hz == 0 || sf < 5 || sf > 12) { + return 200; + } + + uint32_t tsym_us = ((1UL << sf) * 1000000UL) / bw_hz; + /* +1 symbol covers radio startup + internal processing tail */ + uint32_t ms = ((symb_nb + 1U) * tsym_us) / 1000U + 100U; + + return MAX(ms, 200U); +} + /* ── Driver API: send_async ─────────────────────────────────────────── */ static int lr11xx_lora_send_async(const struct device *dev, @@ -732,7 +759,8 @@ static int lr11xx_lora_send_async(const struct device *dev, * to re-arm. */ if (data->modem_cfg.cad.mode == LORA_CAD_MODE_LBT) { bool was_in_rx = data->in_rx_mode; - int cad_ret = lr11xx_lora_cad(dev, K_MSEC(200)); + int cad_ret = lr11xx_lora_cad(dev, + K_MSEC(lr11xx_cad_timeout_ms(data))); if (cad_ret > 0) { if (was_in_rx && data->async_rx_cb != NULL) { k_mutex_lock(&data->spi_mutex, K_FOREVER); @@ -1136,6 +1164,21 @@ static int lr11xx_do_cad(struct lr11xx_data *data) } if (mc->cad.detection_peak != 0) { detect_peak = mc->cad.detection_peak; + } else if (data->cad_peak_offset != 0) { + /* Adaptive-CAD operating offset (base +/- learned delta). + * LR11xx detPeak scale is ~48-90 — never mix with SX126x. */ + int peak = (int)detect_peak + data->cad_peak_offset; + + if (peak < 48) { + peak = 48; + } else if (peak > 90) { + peak = 90; + } + detect_peak = (uint8_t)peak; + } + if (data->cad_probe_peak != 0) { + /* One-shot calibration probe: absolute peak wins over all. */ + detect_peak = data->cad_probe_peak; } lr11xx_radio_cad_params_t cad = { @@ -1228,6 +1271,44 @@ static int lr11xx_lora_cad_async(const struct device *dev, return ret; } +/* ── Extension API: adaptive CAD ────────────────────────────────────── */ + +void lr11xx_cad_set_peak_offset(const struct device *dev, int8_t offset) +{ + struct lr11xx_data *data = dev->data; + + data->cad_peak_offset = offset; +} + +uint8_t lr11xx_cad_base_peak(const struct device *dev) +{ + struct lr11xx_data *data = dev->data; + + return lr11xx_cad_detect_peak((uint8_t)data->modem_cfg.datarate); +} + +int lr11xx_cad_probe(const struct device *dev, int8_t peak_offset) +{ + struct lr11xx_data *data = dev->data; + int base = (int)lr11xx_cad_base_peak(dev); + int peak = base + peak_offset; + int ret; + + if (peak < 48) { + peak = 48; + } else if (peak > 90) { + peak = 90; + } + + /* One-shot absolute override consumed by lr11xx_do_cad(). Probes and + * LBT both run on the mesh loop thread, so no concurrent CAD exists. */ + data->cad_probe_peak = (uint8_t)peak; + ret = lr11xx_lora_cad(dev, K_MSEC(lr11xx_cad_timeout_ms(data))); + data->cad_probe_peak = 0; + + return ret; +} + /* ── Deferred hardware init ─────────────────────────────────────────── */ static int lr11xx_hw_init(struct lr11xx_data *data, diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h index e518701..ce50097 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr11xx/lr11xx_lora.h @@ -86,6 +86,39 @@ uint32_t lr11xx_get_random(const struct device *dev); */ void lr11xx_reset_agc(const struct device *dev); +/** + * @brief Set the adaptive-CAD operating detPeak offset + * + * Signed delta applied to the per-SF base cadDetPeak on every LBT CAD. + * Takes effect on the next CAD — no reconfigure needed. Clamped + * in-driver to the LR11xx scale (48-90). + * + * @param dev LoRa device + * @param offset Signed offset from the base table value + */ +void lr11xx_cad_set_peak_offset(const struct device *dev, int8_t offset); + +/** + * @brief Per-SF base cadDetPeak for the currently configured SF + * + * @param dev LoRa device + * @return Base detPeak (56-68 on this family) + */ +uint8_t lr11xx_cad_base_peak(const struct device *dev); + +/** + * @brief Run one blocking calibration CAD at base detPeak + peak_offset + * + * Uses the operating modem config (SF/BW/symbol count). Leaves the chip + * in STANDBY — the caller must restart RX afterwards. Mesh loop thread + * only. + * + * @param dev LoRa device + * @param peak_offset Signed offset from the base table value + * @return 1 = activity detected, 0 = channel free, <0 = error + */ +int lr11xx_cad_probe(const struct device *dev, int8_t peak_offset); + #ifdef __cplusplus } #endif diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c index 57acd7f..477589c 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.c @@ -99,6 +99,10 @@ struct lr20xx_data { struct k_sem cad_sem; int cad_result; /* 0=free, 1=busy, <0=error */ bool cad_active; + /* Adaptive-CAD: signed offset applied to the per-SF base detPeak on + * every LBT CAD; cad_probe_peak overrides for one calibration probe. */ + int8_t cad_peak_offset; + uint8_t cad_probe_peak; /* Deferred hardware init */ bool hw_initialized; @@ -838,6 +842,29 @@ static uint32_t lr20xx_lora_airtime(const struct device *dev, static int lr20xx_lora_cad(const struct device *dev, k_timeout_t timeout); +/* Blocking-CAD wait budget scaled to the actual CAD duration: + * nSym * Tsym + startup radio-side, plus IRQ latency margin. A fixed + * 200 ms fits 2-symbol CAD everywhere but is exceeded by 4-symbol CAD + * on slow presets (SF12 @ 62.5 kHz = ~262 ms). */ +static uint32_t lr20xx_cad_timeout_ms(struct lr20xx_data *data) +{ + struct lora_modem_config *mc = &data->modem_cfg; + uint8_t sf = (uint8_t)mc->datarate; + uint8_t symb_nb = mc->cad.symbol_num ? + (uint8_t)mc->cad.symbol_num : 2; + uint32_t bw_hz = (uint32_t)(bw_enum_to_khz(mc->bandwidth) * 1000.0f); + + if (bw_hz == 0 || sf < 5 || sf > 12) { + return 200; + } + + uint32_t tsym_us = ((1UL << sf) * 1000000UL) / bw_hz; + /* +1 symbol covers radio startup + internal processing tail */ + uint32_t ms = ((symb_nb + 1U) * tsym_us) / 1000U + 100U; + + return MAX(ms, 200U); +} + static int lr20xx_lora_send_async(const struct device *dev, uint8_t *buf, uint32_t data_len, struct k_poll_signal *async) @@ -858,7 +885,8 @@ static int lr20xx_lora_send_async(const struct device *dev, * to re-arm. */ if (data->modem_cfg.cad.mode == LORA_CAD_MODE_LBT) { bool was_in_rx = data->in_rx_mode; - int cad_ret = lr20xx_lora_cad(dev, K_MSEC(200)); + int cad_ret = lr20xx_lora_cad(dev, + K_MSEC(lr20xx_cad_timeout_ms(data))); if (cad_ret > 0) { LOG_DBG("LBT: channel busy"); if (was_in_rx && data->async_rx_cb != NULL) { @@ -1175,6 +1203,21 @@ static int lr20xx_do_cad(struct lr20xx_data *data) } if (mc->cad.detection_peak != 0) { cad.cad_detect_peak = mc->cad.detection_peak; + } else if (data->cad_peak_offset != 0) { + /* Adaptive-CAD operating offset (base +/- learned delta). + * LR20xx detPeak scale matches LR11xx (~48-90). */ + int peak = (int)cad.cad_detect_peak + data->cad_peak_offset; + + if (peak < 48) { + peak = 48; + } else if (peak > 90) { + peak = 90; + } + cad.cad_detect_peak = (uint8_t)peak; + } + if (data->cad_probe_peak != 0) { + /* One-shot calibration probe: absolute peak wins over all. */ + cad.cad_detect_peak = data->cad_probe_peak; } lr20xx_radio_lora_configure_cad_params(ctx, &cad); @@ -1264,6 +1307,44 @@ static int lr20xx_lora_cad_async(const struct device *dev, return ret; } +/* ── Extension API: adaptive CAD ────────────────────────────────────── */ + +void lr20xx_cad_set_peak_offset(const struct device *dev, int8_t offset) +{ + struct lr20xx_data *data = dev->data; + + data->cad_peak_offset = offset; +} + +uint8_t lr20xx_cad_base_peak(const struct device *dev) +{ + struct lr20xx_data *data = dev->data; + + return lr20xx_cad_detect_peak((uint8_t)data->modem_cfg.datarate); +} + +int lr20xx_cad_probe(const struct device *dev, int8_t peak_offset) +{ + struct lr20xx_data *data = dev->data; + int base = (int)lr20xx_cad_base_peak(dev); + int peak = base + peak_offset; + int ret; + + if (peak < 48) { + peak = 48; + } else if (peak > 90) { + peak = 90; + } + + /* One-shot absolute override consumed by lr20xx_do_cad(). Probes and + * LBT both run on the mesh loop thread, so no concurrent CAD exists. */ + data->cad_probe_peak = (uint8_t)peak; + ret = lr20xx_lora_cad(dev, K_MSEC(lr20xx_cad_timeout_ms(data))); + data->cad_probe_peak = 0; + + return ret; +} + /* ── Driver API: recv_duty_cycle ────────────────────────────────────── */ static int lr20xx_lora_recv_duty_cycle(const struct device *dev, diff --git a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h index 5b592ae..b3d18ba 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h +++ b/zephcore/patches/zephyr-new/drivers/lora/lr20xx/lr20xx_lora.h @@ -60,6 +60,39 @@ uint32_t lr20xx_get_random(const struct device *dev); */ void lr20xx_reset_agc(const struct device *dev); +/** + * @brief Set the adaptive-CAD operating detPeak offset + * + * Signed delta applied to the per-SF base cadDetPeak on every LBT CAD. + * Takes effect on the next CAD — no reconfigure needed. Clamped + * in-driver to the LR20xx scale (48-90). + * + * @param dev LoRa device + * @param offset Signed offset from the base table value + */ +void lr20xx_cad_set_peak_offset(const struct device *dev, int8_t offset); + +/** + * @brief Per-SF base cadDetPeak for the currently configured SF + * + * @param dev LoRa device + * @return Base detPeak (56-68 on this family) + */ +uint8_t lr20xx_cad_base_peak(const struct device *dev); + +/** + * @brief Run one blocking calibration CAD at base detPeak + peak_offset + * + * Uses the operating modem config (SF/BW/symbol count). Leaves the chip + * in STANDBY — the caller must restart RX afterwards. Mesh loop thread + * only. + * + * @param dev LoRa device + * @param peak_offset Signed offset from the base table value + * @return 1 = activity detected, 0 = channel free, <0 = error + */ +int lr20xx_cad_probe(const struct device *dev, int8_t peak_offset); + #ifdef __cplusplus } #endif diff --git a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h index 5ed3a7e..f14cf6a 100644 --- a/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h +++ b/zephcore/patches/zephyr-new/drivers/lora/native/sx126x/sx126x_ext.h @@ -123,6 +123,39 @@ uint32_t sx126x_get_dc_timeout_restarts(const struct device *dev); */ void sx126x_reset_dc_timeout_restarts(const struct device *dev); +/** + * @brief Set the adaptive-CAD operating detPeak offset + * + * Signed delta applied to the per-SF base cadDetPeak on every LBT CAD. + * Takes effect on the next CAD — no reconfigure needed. The absolute + * value is clamped in-driver to a sane window for the chip family. + * + * @param dev LoRa device + * @param offset Signed offset from the base table value + */ +void sx126x_cad_set_peak_offset(const struct device *dev, int8_t offset); + +/** + * @brief Per-SF base cadDetPeak for the currently configured SF + * + * @param dev LoRa device + * @return Base detPeak (SF + 13 on this family) + */ +uint8_t sx126x_cad_base_peak(const struct device *dev); + +/** + * @brief Run one blocking calibration CAD at base detPeak + peak_offset + * + * Uses the operating modem config (SF/BW/symbol count). Leaves the chip + * in STANDBY — the caller must restart RX afterwards. Must be called + * from the mesh loop thread only (same thread as the LBT CAD). + * + * @param dev LoRa device + * @param peak_offset Signed offset from the base table value + * @return 1 = activity detected, 0 = channel free, <0 = error + */ +int sx126x_cad_probe(const struct device *dev, int8_t peak_offset); + #ifdef __cplusplus } #endif diff --git a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch index 30f4fdc..6ea8b90 100644 --- a/zephcore/patches/zephyr/0003-lora-sx126x-native.patch +++ b/zephcore/patches/zephyr/0003-lora-sx126x-native.patch @@ -68,7 +68,7 @@ index 17689720dd2..09982dc2e70 100644 return -EINVAL; } diff --git a/drivers/lora/native/sx126x/sx126x.c b/drivers/lora/native/sx126x/sx126x.c -index 30243ba5dc7..ef06d9273ae 100644 +index 30243ba5dc7..5315e5d80ef 100644 --- a/drivers/lora/native/sx126x/sx126x.c +++ b/drivers/lora/native/sx126x/sx126x.c @@ -6,14 +6,21 @@ @@ -748,16 +748,17 @@ index 30243ba5dc7..ef06d9273ae 100644 /* Set sync word */ ret = sx126x_set_sync_word(dev, config->public_network); if (ret < 0) { -@@ -721,6 +1207,8 @@ out: +@@ -721,6 +1207,9 @@ out: return ret; } +static int sx126x_lora_cad(const struct device *dev, k_timeout_t timeout); ++static uint32_t sx126x_cad_timeout_ms(struct sx126x_data *data); + static int sx126x_lora_send_async(const struct device *dev, uint8_t *data_buf, uint32_t data_len, struct k_poll_signal *async) -@@ -738,11 +1226,27 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -738,11 +1227,27 @@ static int sx126x_lora_send_async(const struct device *dev, return -EINVAL; } @@ -787,7 +788,7 @@ index 30243ba5dc7..ef06d9273ae 100644 k_mutex_lock(&data->lock, K_FOREVER); ret = sx126x_ensure_ready(dev); -@@ -752,6 +1256,58 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -752,6 +1257,59 @@ static int sx126x_lora_send_async(const struct device *dev, return ret; } @@ -795,7 +796,8 @@ index 30243ba5dc7..ef06d9273ae 100644 + if (data->config.cad.mode == LORA_CAD_MODE_LBT) { + k_mutex_unlock(&data->lock); + atomic_set(&data->state, SX126X_REST_STATE); -+ int cad_ret = sx126x_lora_cad(dev, K_MSEC(200)); ++ int cad_ret = sx126x_lora_cad(dev, ++ K_MSEC(sx126x_cad_timeout_ms(data))); + if (cad_ret > 0) { + LOG_DBG("LBT: channel busy"); + k_mutex_lock(&data->lock, K_FOREVER); @@ -846,7 +848,7 @@ index 30243ba5dc7..ef06d9273ae 100644 data->tx_async_signal = async; k_msgq_purge(&data->tx_msgq); -@@ -777,6 +1333,31 @@ static int sx126x_lora_send_async(const struct device *dev, +@@ -777,6 +1335,31 @@ static int sx126x_lora_send_async(const struct device *dev, /* Enable antenna and set TX path */ sx126x_set_rf_path(dev, true, true); @@ -878,7 +880,7 @@ index 30243ba5dc7..ef06d9273ae 100644 /* Start transmission with 10 second timeout */ ret = sx126x_set_tx(dev, 10000); if (ret < 0) { -@@ -847,6 +1428,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, +@@ -847,6 +1430,8 @@ static int sx126x_lora_recv(const struct device *dev, uint8_t *data_buf, } data->rx_cb = NULL; @@ -887,7 +889,7 @@ index 30243ba5dc7..ef06d9273ae 100644 k_msgq_purge(&data->rx_msgq); /* Set packet parameters for variable length reception */ -@@ -918,6 +1501,8 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -918,6 +1503,8 @@ static int sx126x_lora_recv_async(const struct device *dev, /* Stop async reception */ data->rx_cb = NULL; data->rx_cb_user_data = NULL; @@ -896,7 +898,7 @@ index 30243ba5dc7..ef06d9273ae 100644 if (atomic_cas(&data->state, SX126X_STATE_RX, SX126X_STATE_IDLE)) { sx126x_set_standby(dev, SX126X_STANDBY_RC); sx126x_set_sleep(dev); -@@ -932,6 +1517,19 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -932,6 +1519,19 @@ static int sx126x_lora_recv_async(const struct device *dev, return -EINVAL; } @@ -916,7 +918,7 @@ index 30243ba5dc7..ef06d9273ae 100644 if (!atomic_cas(&data->state, SX126X_REST_STATE, SX126X_STATE_RX)) { LOG_ERR("Busy"); k_mutex_unlock(&data->lock); -@@ -945,8 +1543,18 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -945,8 +1545,18 @@ static int sx126x_lora_recv_async(const struct device *dev, return ret; } @@ -935,7 +937,7 @@ index 30243ba5dc7..ef06d9273ae 100644 /* Set packet parameters */ ret = sx126x_set_packet_params(dev, -@@ -959,6 +1567,7 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -959,6 +1569,7 @@ static int sx126x_lora_recv_async(const struct device *dev, SX126X_LORA_IQ_INVERTED : SX126X_LORA_IQ_STANDARD); if (ret < 0) { data->rx_cb = NULL; @@ -943,7 +945,7 @@ index 30243ba5dc7..ef06d9273ae 100644 sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); return ret; -@@ -971,11 +1580,21 @@ static int sx126x_lora_recv_async(const struct device *dev, +@@ -971,11 +1582,21 @@ static int sx126x_lora_recv_async(const struct device *dev, ret = sx126x_set_rx(dev, 0); if (ret < 0) { data->rx_cb = NULL; @@ -965,7 +967,7 @@ index 30243ba5dc7..ef06d9273ae 100644 k_mutex_unlock(&data->lock); return 0; } -@@ -1051,7 +1670,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1051,7 +1672,7 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, /* Set PA config and TX power */ ret = sx126x_hal_configure_tx_params(dev, tx_power, frequency, @@ -974,7 +976,7 @@ index 30243ba5dc7..ef06d9273ae 100644 if (ret < 0) { sx126x_set_sleep(dev); k_mutex_unlock(&data->lock); -@@ -1083,14 +1702,595 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, +@@ -1083,14 +1704,671 @@ static int sx126x_lora_test_cw(const struct device *dev, uint32_t frequency, return 0; } @@ -1224,6 +1226,29 @@ index 30243ba5dc7..ef06d9273ae 100644 + return sf + 13; +} + ++/* Blocking-CAD wait budget scaled to the actual CAD duration: ++ * nSym * Tsym + 32/BW radio-side, plus IRQ latency / work-queue margin. ++ * The old fixed 200 ms fit 2-symbol CAD everywhere but is exceeded by ++ * 4-symbol CAD on slow presets (SF12 @ 62.5 kHz = ~262 ms). */ ++static uint32_t sx126x_cad_timeout_ms(struct sx126x_data *data) ++{ ++ struct lora_modem_config *mc = &data->config; ++ uint8_t sf = (uint8_t)mc->datarate; ++ uint8_t symb_nb = mc->cad.symbol_num ? ++ (uint8_t)mc->cad.symbol_num : 2; ++ uint32_t bw_hz = bandwidth_to_hz(mc->bandwidth); ++ ++ if (bw_hz == 0 || sf < 5 || sf > 12) { ++ return 200; ++ } ++ ++ uint32_t tsym_us = ((1UL << sf) * 1000000UL) / bw_hz; ++ /* +1 symbol covers the 32/BW startup + internal processing tail */ ++ uint32_t ms = ((symb_nb + 1U) * tsym_us) / 1000U + 100U; ++ ++ return MAX(ms, 200U); ++} ++ +static int sx126x_do_cad(const struct device *dev, struct sx126x_data *data) +{ + struct lora_modem_config *mc = &data->config; @@ -1237,6 +1262,21 @@ index 30243ba5dc7..ef06d9273ae 100644 + } + if (mc->cad.detection_peak != 0) { + detect_peak = mc->cad.detection_peak; ++ } else if (data->cad_peak_offset != 0) { ++ /* Adaptive-CAD operating offset (base +/- learned delta). ++ * Clamped to a sane absolute window around the family scale. */ ++ int peak = (int)detect_peak + data->cad_peak_offset; ++ ++ if (peak < 15) { ++ peak = 15; ++ } else if (peak > 40) { ++ peak = 40; ++ } ++ detect_peak = (uint8_t)peak; ++ } ++ if (data->cad_probe_peak != 0) { ++ /* One-shot calibration probe: absolute peak wins over all. */ ++ detect_peak = data->cad_probe_peak; + } + + /* SET_CAD must run from STANDBY_RC (DS). LBT is entered from continuous RX @@ -1383,6 +1423,44 @@ index 30243ba5dc7..ef06d9273ae 100644 + return ret; +} + ++/* -- Extension API: adaptive CAD -- */ ++ ++void sx126x_cad_set_peak_offset(const struct device *dev, int8_t offset) ++{ ++ struct sx126x_data *data = dev->data; ++ ++ data->cad_peak_offset = offset; ++} ++ ++uint8_t sx126x_cad_base_peak(const struct device *dev) ++{ ++ struct sx126x_data *data = dev->data; ++ ++ return sx126x_cad_detect_peak((uint8_t)data->config.datarate); ++} ++ ++int sx126x_cad_probe(const struct device *dev, int8_t peak_offset) ++{ ++ struct sx126x_data *data = dev->data; ++ int base = (int)sx126x_cad_base_peak(dev); ++ int peak = base + peak_offset; ++ int ret; ++ ++ if (peak < 15) { ++ peak = 15; ++ } else if (peak > 40) { ++ peak = 40; ++ } ++ ++ /* One-shot absolute override consumed by sx126x_do_cad(). Probes and ++ * LBT both run on the mesh loop thread, so no concurrent CAD exists. */ ++ data->cad_probe_peak = (uint8_t)peak; ++ ret = sx126x_lora_cad(dev, K_MSEC(sx126x_cad_timeout_ms(data))); ++ data->cad_probe_peak = 0; ++ ++ return ret; ++} ++ +/* -- Driver API: recv_duty_cycle -- */ + +static int sx126x_lora_recv_duty_cycle(const struct device *dev, @@ -1577,7 +1655,7 @@ index 30243ba5dc7..ef06d9273ae 100644 }; #ifdef CONFIG_PM_DEVICE -@@ -1112,6 +2312,7 @@ static int sx126x_pm_action(const struct device *dev, +@@ -1112,6 +2390,7 @@ static int sx126x_pm_action(const struct device *dev, static int sx126x_init(const struct device *dev) { struct sx126x_data *data = dev->data; @@ -1585,7 +1663,7 @@ index 30243ba5dc7..ef06d9273ae 100644 int ret; /* Initialize data structures */ -@@ -1121,9 +2322,29 @@ static int sx126x_init(const struct device *dev) +@@ -1121,9 +2400,29 @@ static int sx126x_init(const struct device *dev) k_msgq_init(&data->rx_msgq, (char *)&data->rx_result, sizeof(struct sx126x_rx_result), 1); k_work_init(&data->irq_work, sx126x_irq_work_handler); @@ -1616,10 +1694,10 @@ index 30243ba5dc7..ef06d9273ae 100644 /* Initialize HAL */ ret = sx126x_hal_init(dev); diff --git a/drivers/lora/native/sx126x/sx126x.h b/drivers/lora/native/sx126x/sx126x.h -index 9dbf3f26586..2598b56d048 100644 +index 9dbf3f26586..b18bebceec7 100644 --- a/drivers/lora/native/sx126x/sx126x.h +++ b/drivers/lora/native/sx126x/sx126x.h -@@ -56,13 +56,69 @@ struct sx126x_data { +@@ -56,13 +56,74 @@ struct sx126x_data { /* Async RX callback */ lora_recv_cb rx_cb; void *rx_cb_user_data; @@ -1686,6 +1764,11 @@ index 9dbf3f26586..2598b56d048 100644 + struct k_sem cad_sem; + int cad_result; + bool cad_active; ++ /* Adaptive-CAD: signed offset applied to the per-SF base detPeak on ++ * every LBT CAD (set via sx126x_cad_set_peak_offset). cad_probe_peak, ++ * when non-zero, overrides everything for one calibration probe. */ ++ int8_t cad_peak_offset; ++ uint8_t cad_probe_peak; }; #endif /* ZEPHYR_DRIVERS_LORA_SX126X_SX126X_INTERNAL_H_ */ diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index 1720338..64ef6ab 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -38,6 +38,8 @@ Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr) _err_flags = 0; radio_nonrx_start = 0; prev_isrecv_mode = true; + cad_offset_shadow = 0; + cad_offset_shadow_valid = false; n_sent_flood = n_sent_direct = 0; n_recv_flood = n_recv_direct = 0; _tx_queued_cb = nullptr; @@ -181,6 +183,20 @@ void Dispatcher::maintenanceLoop() _radio->resetAGC(); next_agc_reset_time = futureMillis(getAGCResetInterval()); } + + /* Adaptive CAD: probe scheduling + staircase live in the radio; + * we only surface offset changes so the app layer can persist them. */ + _radio->cadMaintenance(); + + int8_t cad_off = _radio->getCadOffset(); + + if (!cad_offset_shadow_valid) { + cad_offset_shadow = cad_off; + cad_offset_shadow_valid = true; + } else if (cad_off != cad_offset_shadow) { + cad_offset_shadow = cad_off; + onCadOffsetChanged(cad_off); + } } bool Dispatcher::tryParsePacket(Packet *pkt, const uint8_t *raw, int len) diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 8a13509..d917e0e 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -708,6 +708,19 @@ public: return lora_radio.setRxBoost(enable); } + /* Adaptive CAD */ + int formatCadStatus(char* buf, int cap) override { + return lora_radio.formatCadStatus(buf, cap); + } + void applyCadPrefs() override { + lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, + companion_mesh.prefs.cad_offset, + companion_mesh.prefs.cad_probe_interval); + } + void resetCadStats() override { + lora_radio.resetCadStats(); + } + #ifdef CONFIG_ZEPHCORE_APC int8_t getAPCReduction() const override { return companion_mesh.getAPCReduction(); @@ -1507,6 +1520,9 @@ int main(void) /* Apply RX boost and duty cycle from prefs */ lora_radio.setRxBoost(companion_mesh.prefs.rx_boost != 0); lora_radio.enableRxDutyCycle(companion_mesh.prefs.rx_duty_cycle != 0); + lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0, + companion_mesh.prefs.cad_offset, + companion_mesh.prefs.cad_probe_interval); #ifdef CONFIG_ZEPHCORE_APC ui_set_radio_runtime( lora_radio.getEffectiveTxPower(), diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 2cce46f..45bb87d 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -630,6 +630,8 @@ int main(void) /* Apply RX boost and duty cycle from prefs */ lora_radio.setRxBoost(prefs->rx_boost != 0); lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); + lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, + prefs->cad_probe_interval); /* Feed initial UI state from loaded prefs */ ui_set_node_name(prefs->node_name); diff --git a/zephcore/src/main_room_server.cpp b/zephcore/src/main_room_server.cpp index dbbb12f..40307e3 100644 --- a/zephcore/src/main_room_server.cpp +++ b/zephcore/src/main_room_server.cpp @@ -609,6 +609,8 @@ int main(void) /* Apply RX boost and duty cycle from prefs */ lora_radio.setRxBoost(prefs->rx_boost != 0); lora_radio.enableRxDutyCycle(prefs->rx_duty_cycle != 0); + lora_radio.setCadParams(prefs->cad_auto != 0, prefs->cad_offset, + prefs->cad_probe_interval); /* Feed initial UI state from loaded prefs */ ui_set_node_name(prefs->node_name);