mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-08-29 14:28:18 +00:00
probing improvements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<mean>/<%>.
|
||||
* 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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user