diff --git a/zephcore/ADAPTIVE_CAD.md b/zephcore/ADAPTIVE_CAD.md index 127c44d..6802ac4 100644 --- a/zephcore/ADAPTIVE_CAD.md +++ b/zephcore/ADAPTIVE_CAD.md @@ -204,14 +204,54 @@ get cad Compact output (kept short so it survives a truncated LoRa reply): ``` -> a:on o:-1 pk:20(b21/4s) iv:15s bc:25% +> a:on o:-1 pk:20(b21/4s) sp:0.9/84%(312) bc:25% -2(19) 241p 9b 7f 2t 3% *-1(20) 900p 5b 3f 2t 0% +0(21) 300p 2b 0f 2t 0% ``` Header: `a` auto on/off · `o` operating offset · `pk` operating detPeak · -`b` family base · `4s` 4 symbols · `iv` probe interval · `bc` busy cap. +`b` family base · `4s` 4 symbols · `sp` RSSI burst quality · `bc` busy cap. + +**`sp` — are the noise-floor sampler's reads independent?** The floor +sampler takes a median of 8 RSSI reads. The chip refreshes RSSI only once +per averaging window (~16 µs at BW 62.5, ~134 µs at BW 7.8); reads issued +faster than that return the same underlying value repeatedly and the median +collapses to a single read. `sp` reports mean spread (max−min) across a +burst, the share of bursts whose spread was 0, and the burst count. + +Read it this way: + +- **A non-zero mean proves the reads are independent**, however high the + zero-spread share climbs. A quiet or steadily-occupied channel genuinely + reads the same value 8 times at integer-dB resolution — that is correct, + not broken. +- **Mean 0.0 with a high share is the fault signature**: no burst ever + spans anything, i.e. 8 copies of one sample. +- The count matters. 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, so a share without its denominator is unreadable. + It appears on the local USB console only — a remote reply is capped at + 161 B and the three level rows have first claim on it. All three + counters halve together at 8192 bursts, so the count stays four digits + and the figures describe a recent window rather than everything since + boot. + +Measured on-air at BW 62.5: `0.6/90%` on a quiet channel, `0.9/84%` with +the floor at −103 dBm. The share falls and the spread rises as ambient +comes up — independent reads, responding the right way. + +Note what this median does and does not do. The burst spans ~300 µs 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. The median rejects sub-300 µs glitches and bad SPI reads. +Interference is handled by the mid-receive guard and the +14 dB threshold +filter, not here. + +The probe interval used to sit in this slot; it moved out because it is a +pref you set, readable with `get probe.interval`, whereas burst spread is +only observable from inside the sampler. Then a **three-rung window** centred on the operating offset (`*` marks it): the frontier (one step more sensitive), the operating level, and one step less sensitive — exactly the three rungs the staircase reads to judge diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index cb96be5..75f2977 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -208,7 +208,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot. | `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 probe.interval` | Seconds between periodic radio measurements (noise-floor sample + CAD probe). 0 = CAD probing off | | `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: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `iv` probe interval, `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. 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 cad` | Adaptive-CAD status: header (`a` auto on/off, `o` operating detPeak offset, `pk` absolute peak with family base, `sp` noise-floor RSSI burst quality as `mean-spread-dB/zero-spread-%` (plus `(burst-count)` on the local USB console, omitted over the air to protect the 161 B reply budget) — a non-zero mean proves the 8 reads are independent however high the share climbs; only mean `0.0` with a high share indicts the sampler. See `ADAPTIVE_CAD.md`. `bc` busy cap), then a 3-rung window around the operating offset (`*` marks it) with probe/busy/fp/tp counts and false-positive rate — the three levels the knee controller reads. 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 | diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index b4f1564..27b8b0f 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -50,6 +50,8 @@ LoRaRadioBase::LoRaRadioBase(const struct device *lora_dev, MainBoard &board, _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), _probe_interval_s(0), _cad_busycap_pct(0), _cad_last_probe_ms(0), _cad_last_decay_ms(0), @@ -583,6 +585,7 @@ void LoRaRadioBase::startReceive() K_USEC(sleep_us), rxCallbackStatic, this); if (ret == 0) { + _rx_entry_cyc = k_cycle_get_32(); atomic_set(&_in_recv_mode, 1); return; } @@ -608,6 +611,7 @@ void LoRaRadioBase::startReceive() atomic_set(&_in_recv_mode, 0); return; } + _rx_entry_cyc = k_cycle_get_32(); atomic_set(&_in_recv_mode, 1); } @@ -833,12 +837,56 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) return; } - /* Median of multiple RSSI reads (~200 us). Rejects up to N/2-1 - * outliers in either direction without the downward bias of min - * or the spike sensitivity of average. Insertion sort is fine - * for N=8 (28 comparisons worst case, all in registers). */ + /* 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. */ + 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); + + if (since_rx_us < rssi_settle_delay_us(bw_khz)) { + 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. */ + uint32_t window_us = rssi_avg_window_us(bw_khz); int16_t samples[NOISE_FLOOR_SAMPLES_PER_TICK]; for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { + if (i) { + k_busy_wait(window_us); + } samples[i] = hwGetCurrentRSSI(); if (samples[i] == -128) { /* Chip busy or RSSI read contended — keep the short @@ -864,6 +912,31 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) int16_t rssi = (samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2 - 1] + samples[NOISE_FLOOR_SAMPLES_PER_TICK / 2]) / 2; + /* 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. */ + _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. */ + if (_rssi_bursts >= RSSI_BURST_STATS_CAP) { + _rssi_bursts >>= 1; + _rssi_spread_sum >>= 1; + _rssi_degenerate >>= 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 @@ -1323,16 +1396,59 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap) } /* Terse on purpose — remote replies are capped at ~160 B over LoRa. - * Header: a:on o:1 pk:22(b21/4s) iv:15s bc:25% + * 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 iv probe interval bc busy cap + * b family base 4s symbols bc busy cap + * 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%%. */ + * '*' = 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. */ + unsigned spread_mean10 = _rssi_bursts + ? (unsigned)((_rssi_spread_sum * 10U + _rssi_bursts / 2U) / + _rssi_bursts) + : 0; + unsigned degen_pct = _rssi_bursts + ? (unsigned)((_rssi_degenerate * 100U + _rssi_bursts / 2U) / + _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. */ + bool room_for_count = (cap >= 200); + n += snprintf(buf + n, cap > n ? cap - n : 0, - "a:%s o:%d pk:%d(b%u/4s) iv:%us bc:%u%%", + "a:%s o:%d pk:%d(b%u/4s) sp:%u.%u/%u%%", _cad_auto ? "on" : "off", (int)_cad_offset, (int)base + _cad_offset, base, - (unsigned)_probe_interval_s, + spread_mean10 / 10U, spread_mean10 % 10U, degen_pct); + if (room_for_count) { + n += snprintf(buf + n, cap > n ? cap - n : 0, "(%u)", + (unsigned)_rssi_bursts); + } + n += snprintf(buf + n, cap > n ? cap - n : 0, " bc:%u%%", (unsigned)_cad_busycap_pct); /* Only the 3 rungs around the operating offset — the far rungs are mildly diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index 99041fe..89385a1 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -210,6 +210,18 @@ protected: int16_t _sample_rssi; bool _sample_channel_quiet; bool _sample_fresh; + /* 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. */ + uint32_t _rssi_bursts; + uint32_t _rssi_spread_sum; + uint32_t _rssi_degenerate; /* Adaptive CAD state */ struct CadLevelStats { diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index a735c12..7a2661d 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -20,6 +20,58 @@ #define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */ #define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */ +/* --- RSSI read timing (SX1261/2 DS rev 2.2 Table 13-82) --- + * + * The median-of-N above only rejects outliers if the N reads are actually + * independent. The chip updates RSSI once per "averaging window"; reads + * issued faster than that return the same underlying sample repeatedly, and + * the median degenerates into one read with extra SPI traffic. + * + * The published table is indexed by GFSK channel-filter bandwidth. Across + * all 19 rows the product window_us * BW_kHz lands in 921..938, so + * 936 / BW_kHz reproduces every published value to within a microsecond. + * The separate "RSSI delay" column (BUSY falling edge -> first valid sample) + * is 12x to 15x the window across the same rows. + * + * CAVEAT: Semtech documents this for GFSK only and publishes no LoRa + * equivalent. The RSSI path is the same analog/AGC chain, so these are used + * as the best available proxy — not as specified LoRa figures. `get cad` + * reports the measured burst spread so the assumption stays falsifiable on + * real hardware. + */ +#define RSSI_WINDOW_BW_PRODUCT 936U /* window_us * BW_kHz, DS Table 13-82 */ +#define RSSI_SETTLE_WINDOWS 16U /* delay/window is 12..15; round up */ + +/* Burst-stat rescale point. The counters behind `get cad`'s sp field are + * halved (all three together, so the mean and the zero-spread share are + * preserved exactly) once the burst count reaches this. + * + * Two reasons, and the display one is the hard constraint: the remote CLI + * reply is capped at CLI_REMOTE_REPLY_SIZE (161 B) and has to fit the header + * plus three level lines, so no field may grow without bound. At one burst + * per 15 s a free-running counter passes 500 000 in three months and would + * eat the level rows. 8192 keeps it to four digits forever. + * + * The second reason is that halving turns the totals into an exponential + * forgetting window, so the numbers describe recent conditions instead of + * being anchored to whatever the channel was doing at boot. Same idea as + * CAD_STATS_DECAY_MS below, which halves the CAD counters on a timer. */ +#define RSSI_BURST_STATS_CAP 8192U + +static inline uint32_t rssi_avg_window_us(uint16_t bw_khz) +{ + uint32_t bw = bw_khz ? bw_khz : 1U; + + /* ceil. Non-zero for every LoRa bandwidth (BW 500 -> 2 us), so no + * zero-spacing guard is needed. */ + return (RSSI_WINDOW_BW_PRODUCT + bw - 1U) / bw; +} + +static inline uint32_t rssi_settle_delay_us(uint16_t bw_khz) +{ + return rssi_avg_window_us(bw_khz) * RSSI_SETTLE_WINDOWS; +} + /* Sampling cadence. The sampler used to run once per housekeeping tick and so * inherited that 5 s period; owning an explicit interval is what let the * periodic tick go away (see mesh/Maintenance.h). Both the 8-sample warmup and