refactor / unify probes

This commit is contained in:
liquidraver
2026-07-29 12:30:47 +02:00
parent fe6e585eb6
commit a705d5b5ce
19 changed files with 1331 additions and 1165 deletions
+3 -3
View File
@@ -90,7 +90,7 @@ caveat noted at the end, viewed through the strong/faint lens.)
## How it works
Every `cad.probe.interval` seconds (default 15), when the radio is idle
Every `probe.interval` seconds (default 15), 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
@@ -248,7 +248,7 @@ any time with `set cad.auto on`. Offset changes (manual or automatic)
appear in the log as `cad: step down/up -> offset N`.
To collect dry-run data faster, drop the interval (probing is temporary
then): `set cad.probe.interval 10`. At the default 15 s, allow ~a day for
then): `set probe.interval 10`. At the default 15 s, allow ~a day for
a knee to resolve clearly, longer to capture day/night variation.
## Command reference
@@ -258,7 +258,7 @@ a knee to resolve clearly, longer to capture day/night variation.
| `get cad` | | Status + per-level statistics (see above). |
| `set cad.auto <on\|off>` | **on** | Staircase controller acts on the stats. Off = observe/hand-tune. |
| `set cad.offset <n>` | 0 | Operating offset, 8…12. Negative = more sensitive. Applied live. |
| `set cad.probe.interval <sec>` | 15 | Probe cadence; 0 disables probing (and freezes auto), 10255 otherwise. |
| `set probe.interval <sec>` | 15 | Shared cadence for the noise-floor sample and the CAD probe that consumes it; 0 disables probing (and freezes auto), 10255 otherwise. |
| `set cad.busycap <pct>` | 25 | Faint-tolerance / airtime cap: raise detPeak once more than this % of (quiet-moment) probes trip on faint signals. Lower = reject faint/echo harder (busy backbones); 0 = off. 1090 otherwise. |
| `set cad.reset` | | Clear accumulated statistics (RAM only). |
+1055 -1055
View File
File diff suppressed because it is too large Load Diff
+13 -9
View File
@@ -396,24 +396,28 @@ config ZEPHCORE_MAINTENANCE_BACKSTOP_MS
UNCONDITIONAL, so this is a safety net against a deadline that is missed
or mis-reported — not a value that should ever bind in normal operation.
MUST stay above ZEPHCORE_NOISE_FLOOR_INTERVAL_MS and the CAD probe
interval (prefs cad_probe_interval, default 15 s), which are the shortest
recurring deadlines on an idle repeater. Set at or below them and this
silently becomes the wake period, reproducing the old fixed 5 s tick and
making the deadline scheduling a no-op. That is exactly the bug the
original 5000 default shipped with.
MUST stay above the radio measurement interval ("set probe.interval",
default 15 s; ZEPHCORE_NOISE_FLOOR_INTERVAL_MS when probing is off) —
the shortest recurring deadline on an idle repeater. Set at or below it
and this silently becomes the wake period, reproducing the old fixed 5 s
tick and making the deadline scheduling a no-op. That is exactly the bug
the original 5000 default shipped with.
Lowering it costs wakes without improving responsiveness — real work is
already scheduled at its own deadline. Raising it widens the window in
which a scheduling bug goes unnoticed.
config ZEPHCORE_NOISE_FLOOR_INTERVAL_MS
int "Noise floor sampling interval (ms)"
default 5000 if ZEPHCORE_ROLE_COMPANION || ZEPHCORE_ROLE_ROOM_SERVER
int "Default radio measurement interval (ms)"
default 15000
range 1000 120000
help
How often the radio samples ambient RSSI to update the noise floor EMA.
Fallback cadence for periodic radio measurements — the noise-floor RSSI
sample and the CAD probe that consumes it.
This is only the DEFAULT. At runtime the "probe.interval" pref governs
both (see set probe.interval); this value applies when that pref is 0,
i.e. CAD probing is switched off but the floor sampler still has to run.
This used to be implicit: the sampler ran once per housekeeping tick, so
it inherited that 5 s period. The sampler lives in shared radio code, so
+2 -1
View File
@@ -206,6 +206,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot.
| `get rxduty` | RX duty cycle mode: `0` or `1` |
| `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 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 adc.multiplier` | Battery voltage ADC calibration multiplier |
@@ -253,7 +254,7 @@ Changes are persisted immediately unless noted. Some require a reboot.
| `set meshtimesync <on\|off>` | 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 <on\|off>` | default **on** | Adaptive CAD: let the staircase controller move the operating detPeak offset based on probe statistics. On by default (repeaters and companions); at the default 15 s probe interval it responds to environment change in ~12 h. Turn off to observe/hand-tune via `get cad` + `set cad.offset`. See `ADAPTIVE_CAD.md`. |
| `set cad.offset <n>` | 8 to 12, default 0 | Operating detPeak offset from the chip family's per-SF base (SX126x: SF+13; LR11xx/LR20xx: 5668 table). Negative = more sensitive LBT (catches weaker signals, risks false busy), positive = less sensitive. Wide range so dense hilltops / quiet valleys can settle far from base. The per-family absolute clamp in the driver (SX126x 1540, LR 4890) is a firmware guardrail against a CAD that never/always fires, not a chip limit (`cadDetPeak` is a full `uint8_t`). Applied live; the auto staircase may move it later if `cad.auto` is on. |
| `set cad.probe.interval <sec>` | 0 (off) or 10255, default **15** | Seconds between calibration CAD probes. Default 15 s → ~12 h staircase response. 0 disables probing entirely (also freezes auto adaptation). |
| `set probe.interval <sec>` | 0 (off) or 10255, default **15** | Seconds between periodic radio measurements. ONE reading serves both: the noise-floor RSSI sample (median of 8) and the CAD calibration probe, which consumes that same reading rather than measuring separately — so this is also the noise-floor sampling rate, and it sets how often an idle repeater wakes. Default 15 s → ~12 h CAD staircase response; the floor EMA warms up over 8 samples (~2 min) and its unguarded bypass runs every 16th (~4 min). Longer = fewer wakes, slower to track a changing RF environment. 0 disables CAD probing entirely (also freezes auto adaptation); the floor sampler then falls back to its build-time default. |
| `set cad.busycap <pct>` | 0 (off) or 1090, default **25** | Airtime-protection cap: the max percentage of TX attempts the node will let CAD defer before the staircase backs off to a less sensitive detPeak — counting **real** traffic, not just false positives. On a congested hilltop most busy verdicts are distant traffic won on capture anyway, so deferring for all of it starves the node's own airtime. Self-targeting: a quiet node's busy rate never reaches the cap. Shown as `bc:` in `get cad`. 0 disables the cap (pure knee-seeking). |
| `set cad.reset` | | Clear the accumulated per-level CAD probe statistics (RAM only; also cleared automatically on any radio parameter change). |
| `set prv.key <hex>` | 64-char hex (32-byte key) | Replace private key; derive new identity *(reboot to apply)* |
@@ -777,12 +777,12 @@ void ZephyrDataStore::loadPrefs(NodePrefs &prefs)
}
}
/* Offset 157: cad_probe_interval (ZephCore extension, seconds; 0 = off).
/* Offset 157: 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;
prefs.probe_interval = buf[off++];
if (prefs.probe_interval != 0 && prefs.probe_interval < 10) {
prefs.probe_interval = 10;
}
}
@@ -892,8 +892,8 @@ void ZephyrDataStore::savePrefs(const NodePrefs &prefs)
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;
/* Offset 157: probe_interval (ZephCore extension, seconds) */
buf[off++] = prefs.probe_interval;
/* Offset 158: cad_busycap (ZephCore extension, percent) */
buf[off++] = prefs.cad_busycap;
/* Offset 159: adc_multiplier (ZephCore extension, float LE, 0 = board
+92 -52
View File
@@ -47,10 +47,12 @@ 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),
_noise_floor_next_ms(0),
_cad_auto(false), _cad_offset(0), _cad_probe_interval_s(0),
_noise_floor_next_ms(0), _noise_floor_retries(0),
_measure_interval_ms(CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS),
_sample_rssi(0), _sample_channel_quiet(false), _sample_fresh(false),
_cad_auto(false), _cad_offset(0), _probe_interval_s(0),
_cad_busycap_pct(0),
_cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_retry_ms(0),
_cad_last_probe_ms(0), _cad_last_decay_ms(0),
_cad_probe_rr(0),
_rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)),
_rx_boost_enabled(true),
@@ -792,13 +794,28 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
* calls are a no-op, so this is safe to invoke from any wake. */
int64_t now = k_uptime_get();
/* Invalidate first: "fresh" must mean a sample landed in THIS pass, not
* merely at some point in the past. cadMaintenance() runs immediately
* after us and treats the verdict as current-channel ground truth, so a
* carried-over sample would let it probe on a reading taken a full
* interval ago — on a different channel state entirely. */
_sample_fresh = false;
if (_noise_floor_next_ms != 0 && now < _noise_floor_next_ms) {
return;
}
/* Due. Any bail-out below is a blocked attempt, not a completed one —
* push the deadline out by the short retry so msUntilNextMaintenance()
* cannot report "due now" on a loop. */
* push the deadline out by the retry so msUntilNextMaintenance() cannot
* report "due now" on a loop. Bounded for the same reason as the CAD
* probe: an unbounded retry grid makes the retry period the de-facto
* wake period whenever the radio is persistently busy. */
if (_noise_floor_retries >= NOISE_FLOOR_MAX_RETRIES) {
_noise_floor_retries = 0;
_noise_floor_next_ms = now + _measure_interval_ms;
return;
}
_noise_floor_retries++;
_noise_floor_next_ms = now + NOISE_FLOOR_RETRY_MS;
if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) {
@@ -831,7 +848,8 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
}
/* A full sample landed: next one is a full interval away. */
_noise_floor_next_ms = now + NOISE_FLOOR_INTERVAL_MS;
_noise_floor_next_ms = now + _measure_interval_ms;
_noise_floor_retries = 0;
/* Insertion sort — tiny array, branch-friendly on Cortex-M */
for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) {
@@ -846,10 +864,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;
/* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */
/* Publish this sample for cadMaintenance(). The CAD probe needs exactly
* the same fact we just established — "is the channel at its floor right
* now?" — and used to answer it with its own single hwGetCurrentRSSI() on
* its own deadline. That cost a second wake per interval (measured: two
* 15 s grids ~3 s apart) and made the worse decision, since one raw read
* is precisely what the median-of-8 exists to defend against.
*
* The verdict is taken against the floor BEFORE this sample is folded in,
* so it compares a new observation to the established floor rather than
* to one already dragged toward it. */
_sample_rssi = rssi;
_sample_channel_quiet = (_noise_floor == DEFAULT_NOISE_FLOOR) ||
(rssi <= _noise_floor + CAD_PROBE_RSSI_GUARD);
_sample_fresh = true;
/* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly.
* The lower clamp tracks the active bandwidth — thermal noise is
* 10*log10(BW) so a fixed rail pins narrow-BW presets several dB high
* (BW 31.25 kHz sits ~3 dB below BW 62.5) and never engages at all on
* wide ones. */
int16_t floor_min = noise_floor_min_dbm(getActiveBandwidthKHzX10() / 10);
if (_noise_floor == DEFAULT_NOISE_FLOOR) {
_noise_floor = rssi;
if (_noise_floor < -120) _noise_floor = -120;
if (_noise_floor < floor_min) _noise_floor = floor_min;
if (_noise_floor > -50) _noise_floor = -50;
_ema_unguarded = 0;
LOG_DBG("noise_floor_cal: seed=%d", _noise_floor);
@@ -886,7 +925,7 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
int half = W / 2; /* 4 */
int step = (diff + (diff > 0 ? half : -half)) / W;
_noise_floor += step;
if (_noise_floor < -120) _noise_floor = -120;
if (_noise_floor < floor_min) _noise_floor = floor_min;
if (_noise_floor > -50) _noise_floor = -50;
LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u",
@@ -949,12 +988,25 @@ void LoRaRadioBase::setCadParams(bool auto_enabled, int8_t offset,
_cad_auto = auto_enabled;
_cad_offset = offset;
_cad_probe_interval_s = probe_interval_s;
_probe_interval_s = probe_interval_s;
_cad_busycap_pct = busycap_pct;
/* One interval governs every periodic radio measurement, because there
* is only one measurement: the noise-floor sampler takes a median-of-8
* and the CAD probe consumes that same reading (see cadMaintenance).
* Splitting them into two knobs could only ever express a rate the
* hardware does not actually run at.
*
* 0 means "CAD probing off" — the floor sampler still has to run, so it
* falls back to the build-time default. */
_measure_interval_ms = probe_interval_s
? (uint32_t)probe_interval_s * 1000U
: (uint32_t)CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS;
hwCadSetPeakOffset(_cad_offset);
LOG_INF("cad: auto=%d offset=%d probe_interval=%us busycap=%u%%",
(int)auto_enabled, (int)offset, (unsigned)probe_interval_s,
LOG_INF("cad: auto=%d offset=%d measure_interval=%ums busycap=%u%%",
(int)auto_enabled, (int)offset, (unsigned)_measure_interval_ms,
(unsigned)busycap_pct);
}
@@ -1090,7 +1142,7 @@ void LoRaRadioBase::cadStaircaseStep()
void LoRaRadioBase::cadMaintenance()
{
if (_cad_probe_interval_s == 0) {
if (_probe_interval_s == 0) {
return;
}
@@ -1104,39 +1156,30 @@ void LoRaRadioBase::cadMaintenance()
_cad_last_decay_ms = now;
}
if (now - _cad_last_probe_ms < (int64_t)_cad_probe_interval_s * 1000) {
return;
}
if (now < _cad_retry_ms) {
return;
}
/* No separate probe-interval check: the probe interval IS the measurement
* interval (setCadParams derives _measure_interval_ms from it), so a
* fresh sample means a probe is due by construction. */
/* Due. _cad_last_probe_ms only advances on a probe that actually runs,
* so hold a short retry deadline across the guards below — otherwise a
* blocked probe reports "due now" to msUntilNextMaintenance() forever. */
_cad_retry_ms = now + CAD_PROBE_RETRY_MS;
/* 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()) {
/* Ride on the noise-floor sampler rather than measuring independently.
*
* A fresh sample means the sampler ran THIS pass, which already proves
* everything the probe needs: the radio was idle in RX, not transmitting,
* not mid-packet, and out of its duty-cycle sleep window — the sampler
* applies exactly those guards before it reads. So there is nothing left
* to re-check, no separate deadline, and no retry budget: if no sample
* landed this pass, the probe simply waits for the next one.
*
* This is what makes the wake cost one per interval instead of two. It
* also upgrades the ground-truth prefilter from a single raw RSSI read to
* the sampler's median-of-8 — the probe is trying to establish that the
* channel is quiet, and a busy verdict taken over real traffic teaches
* nothing about false positives, so the outlier rejection matters here. */
if (!_sample_fresh) {
return;
}
_sample_fresh = false;
/* 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) {
if (!_sample_channel_quiet) {
return;
}
@@ -1251,18 +1294,15 @@ uint32_t LoRaRadioBase::msUntilNextMaintenance()
}
next = clampDeadline(_noise_floor_next_ms - now);
if (_cad_probe_interval_s == 0) {
if (_probe_interval_s == 0) {
return next;
}
/* CAD probe: whichever is later of the interval since the last probe
* that actually ran and any retry deadline left by a blocked one. */
int64_t probe_at = _cad_last_probe_ms + (int64_t)_cad_probe_interval_s * 1000;
if (probe_at < _cad_retry_ms) {
probe_at = _cad_retry_ms;
}
next = mesh::maintenanceSooner(next, clampDeadline(probe_at - now));
/* The CAD probe deliberately contributes NO deadline of its own. It runs
* off the noise-floor sampler's measurement (see cadMaintenance), so its
* wake is already accounted for above. Giving it a second deadline is
* what produced two independent 15 s grids ~3 s apart — one extra wake
* per interval, forever, on every repeater. */
/* Stats decay. _cad_last_decay_ms == 0 means the first call latches it
* rather than decaying, so treat that as due now. */
@@ -1292,7 +1332,7 @@ int LoRaRadioBase::formatCadStatus(char *buf, int cap)
"a:%s o:%d pk:%d(b%u/4s) iv:%us bc:%u%%",
_cad_auto ? "on" : "off", (int)_cad_offset,
(int)base + _cad_offset, base,
(unsigned)_cad_probe_interval_s,
(unsigned)_probe_interval_s,
(unsigned)_cad_busycap_pct);
/* Only the 3 rungs around the operating offset — the far rungs are mildly
+11 -2
View File
@@ -200,6 +200,16 @@ protected:
* shorter retry when an attempt is turned away because the radio was
* mid-packet / transmitting / in its duty-cycle sleep window. */
int64_t _noise_floor_next_ms;
uint8_t _noise_floor_retries; /* consecutive blocked attempts, capped */
/* Shared cadence for every periodic radio measurement (floor sample +
* CAD probe). Runtime, from the probe.interval pref. */
uint32_t _measure_interval_ms;
/* Latest floor sample, published for cadMaintenance() so the CAD probe
* shares this measurement instead of taking its own single RSSI read.
* _sample_fresh is true only within the pass that produced it. */
int16_t _sample_rssi;
bool _sample_channel_quiet;
bool _sample_fresh;
/* Adaptive CAD state */
struct CadLevelStats {
@@ -211,7 +221,7 @@ protected:
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 */
uint16_t _probe_interval_s; /* 0 = CAD probing disabled; drives _measure_interval_ms */
uint8_t _cad_busycap_pct; /* airtime cap: max % TX deferred (0 = off) */
int64_t _cad_last_probe_ms;
int64_t _cad_last_decay_ms;
@@ -219,7 +229,6 @@ protected:
* interval check in cadMaintenance() is against _cad_last_probe_ms,
* which only advances on a probe that actually ran without this a
* blocked probe would report "due now" forever and spin the wake. */
int64_t _cad_retry_ms;
uint8_t _cad_probe_rr; /* round-robin index (sweep) / frontier mix counter */
int8_t pickCadProbeLevel();
+40 -5
View File
@@ -37,6 +37,8 @@
* rather than the exception a shorter retry there can push the wake rate
* ABOVE the fixed tick this conversion replaced, inverting the whole point. */
#define NOISE_FLOOR_RETRY_MS 5000
/* Blocked attempts allowed before standing down to the next full interval. */
#define NOISE_FLOOR_MAX_RETRIES 2
/* --- Adaptive CAD (LBT detPeak calibration) ---
* Housekeeping-tick CAD probes accumulate per-level busy/free statistics;
@@ -85,11 +87,11 @@
#define CAD_BUSY_DEFER_HYST_PERMILLE 100 /* descend only if frontier busy <= cap-10% */
#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 */
/* Retry deadline for a due probe turned away by the idle-RX guards or the
* RSSI prefilter. Those paths leave _cad_last_probe_ms untouched (by design
* a skipped probe is not a probe), so the deadline query needs its own marker
* to avoid reporting "due now" on every wake. */
#define CAD_PROBE_RETRY_MS 2000
/* NOTE: the probe has no retry deadline and no wake of its own. It runs off
* the noise-floor sampler's measurement (LoRaRadioBase::cadMaintenance), which
* already applies the idle-RX guards and yields a median-of-8. Consequently
* the effective probe rate is quantised to NOISE_FLOOR_INTERVAL_MS: setting
* probe_interval below that just gets one probe per floor sample. */
/* --- RX ring buffer --- */
#define RX_RING_SIZE 8 /* ~2 KB; buffers burst arrivals at SF7/BW500 */
@@ -146,6 +148,39 @@ static inline enum lora_signal_bandwidth bw_khz_to_enum(uint16_t bw_khz)
}
}
/* Lowest physically-possible noise floor for a given bandwidth, in dBm.
*
* This is raw thermal noise kTB at 290 K, with NO noise-figure term:
* -174 dBm/Hz + 10*log10(BW_Hz)
* A passive receiver cannot read below it, so it is the one place a sanity
* clamp belongs: anything under this line is a bad RSSI read, not a quiet
* site. Adding a receiver noise figure here would clamp ABOVE what the
* hardware can legitimately report and manufacture a floor which is exactly
* what the old fixed -120 rail did to BW 62.5 kHz, pinning it on every EMA
* update because -120 happens to be that preset's kTB+NF.
*
* Bandwidth is the only term that moves. SF changes the SNR the demodulator
* can decode at, not the noise power in the channel, so it must NOT appear.
*
* For reference, a typical SX126x (NF ~6 dB) reads about 6 dB above these:
* BW 62.5 kHz measures ~-120 dBm on a quiet site against a -126 kTB limit. */
static inline int16_t noise_floor_min_dbm(uint16_t bw_khz)
{
switch (bw_khz) {
case 7: return -135; /* 10*log10(7800) = 38.9 */
case 10: return -134; /* 10*log10(10400) = 40.2 */
case 15: return -132; /* 10*log10(15600) = 41.9 */
case 20: return -131; /* 10*log10(20800) = 43.2 */
case 31: return -129; /* 10*log10(31250) = 45.0 */
case 41: return -128; /* 10*log10(41700) = 46.2 */
case 62: return -126; /* 10*log10(62500) = 48.0 */
case 125: return -123; /* 10*log10(125000) = 51.0 */
case 250: return -120; /* 10*log10(250000) = 54.0 */
case 500: return -117; /* 10*log10(500000) = 57.0 */
default: return -123; /* matches bw_khz_to_enum's 125 kHz fallback */
}
}
/* CR 5-8 → Zephyr coding_rate enum */
static inline enum lora_coding_rate cr_to_enum(uint8_t cr)
{
+3 -3
View File
@@ -209,7 +209,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) {
* 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_read(&file, &prefs.probe_interval, sizeof(prefs.probe_interval));
/* cad_busycap absent in <301-byte files; EOF read keeps default 25 */
fs_read(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap));
@@ -243,7 +243,7 @@ bool RepeaterDataStore::loadPrefs(NodePrefs& prefs) {
if (prefs.meshtimesync > 1) prefs.meshtimesync = 0;
if (prefs.cad_auto > 1) prefs.cad_auto = 0;
if (prefs.cad_offset < CAD_OFFSET_MIN || prefs.cad_offset > CAD_OFFSET_MAX) prefs.cad_offset = 0;
if (prefs.cad_probe_interval != 0 && prefs.cad_probe_interval < 10) prefs.cad_probe_interval = 10;
if (prefs.probe_interval != 0 && prefs.probe_interval < 10) prefs.probe_interval = 10;
if (prefs.cad_busycap > 90) prefs.cad_busycap = 90;
/* One-time format upgrade: old files (< 294 bytes) never saved the ZephCore
@@ -349,7 +349,7 @@ bool RepeaterDataStore::savePrefs(const NodePrefs& prefs) {
/* Adaptive CAD (offsets 297-300) */
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_write(&file, &prefs.probe_interval, sizeof(prefs.probe_interval));
fs_write(&file, &prefs.cad_busycap, sizeof(prefs.cad_busycap));
ret = fs_sync(&file);
+7
View File
@@ -59,6 +59,8 @@ static void uplink_time_sync_cb(uint32_t unix_ts)
if (s_uplink_mesh) {
s_uplink_mesh->getRTCClock()->setCurrentTime(unix_ts);
atomic_set(&s_uplink_sntp_pending, 1);
/* Wake the main loop rather than waiting for its next deadline. */
s_uplink_mesh->notifyWake();
}
}
#endif
@@ -1018,6 +1020,11 @@ void RepeaterMesh::begin(RepeaterDataStore* store) {
* loop via an atomic flag drained in maintenanceLoop(). */
if (s_uplink_mesh) {
atomic_set(&s_uplink_mesh->_uplink_connect_pending, 1);
/* Wake the main loop so "online" publishes immediately.
* Without this the flag waits for whatever deadline fires
* next up to UPLINK_STATUS_INTERVAL_MS (5 min) on an idle
* repeater, since maintenance no longer ticks every 5 s. */
s_uplink_mesh->notifyWake();
}
});
_uplink_next_status_at = futureMillis(UPLINK_STATUS_INTERVAL_MS);
+1 -1
View File
@@ -179,7 +179,7 @@ protected:
}
void applyCadPrefs() override {
_radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset,
_prefs.cad_probe_interval, _prefs.cad_busycap);
_prefs.probe_interval, _prefs.cad_busycap);
}
void resetCadStats() override {
_radio->resetCadStats();
+1 -1
View File
@@ -148,7 +148,7 @@ protected:
}
void applyCadPrefs() override {
_radio->setCadParams(_prefs.cad_auto != 0, _prefs.cad_offset,
_prefs.cad_probe_interval, _prefs.cad_busycap);
_prefs.probe_interval, _prefs.cad_busycap);
}
void resetCadStats() override {
_radio->resetCadStats();
+15 -8
View File
@@ -126,7 +126,7 @@ void CommonCLI::loadPrefs(const char* path) {
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
ok = ok && prefs_read(&file, &_prefs->probe_interval, sizeof(_prefs->probe_interval)); // 299
ok = ok && prefs_read(&file, &_prefs->cad_busycap, sizeof(_prefs->cad_busycap)); // 300
if (!ok) {
@@ -174,8 +174,8 @@ void CommonCLI::loadPrefs(const char* path) {
_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)CAD_OFFSET_MIN, (int8_t)CAD_OFFSET_MAX);
if (_prefs->cad_probe_interval != 0 && _prefs->cad_probe_interval < 10) {
_prefs->cad_probe_interval = 10;
if (_prefs->probe_interval != 0 && _prefs->probe_interval < 10) {
_prefs->probe_interval = 10;
}
_prefs->cad_busycap = constrain(_prefs->cad_busycap, (uint8_t)0, (uint8_t)90);
@@ -249,7 +249,7 @@ void CommonCLI::savePrefs(const char* path) {
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_write(&file, &_prefs->probe_interval, sizeof(_prefs->probe_interval));
fs_write(&file, &_prefs->cad_busycap, sizeof(_prefs->cad_busycap));
fs_close(&file);
@@ -566,6 +566,11 @@ 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, "probe.interval", 14) == 0) {
/* Seconds between periodic radio measurements — the noise-floor
* sample and the CAD probe that consumes it. 0 = probing off. */
snprintf(reply, CLI_REPLY_SIZE, "> %u",
(uint32_t)_prefs->probe_interval);
} 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. */
@@ -653,12 +658,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
savePrefs();
strcpy(reply, "OK");
}
} else if (memcmp(config, "cad.probe.interval ", 19) == 0) {
int val = atoi(&config[19]);
/* Governs every periodic radio measurement, not just CAD — the
* noise-floor sampler and the CAD probe share one reading. */
} else if (memcmp(config, "probe.interval ", 15) == 0) {
int val = atoi(&config[15]);
if (val != 0 && (val < 10 || val > 255)) {
strcpy(reply, "Error: interval is 0 (off) or 10-255 seconds");
strcpy(reply, "Error: interval is 0 (probing off) or 10-255 seconds");
} else {
_prefs->cad_probe_interval = (uint8_t)val;
_prefs->probe_interval = (uint8_t)val;
_callbacks->applyCadPrefs();
savePrefs();
strcpy(reply, "OK");
+4 -2
View File
@@ -90,7 +90,9 @@ struct NodePrefs {
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)
uint8_t probe_interval; // seconds between periodic radio measurements:
// one noise-floor sample, and the CAD probe that
// consumes it (0 = CAD probing off, default 15)
uint8_t cad_busycap; // airtime-protection: max % of TX attempts deferred before backing off detPeak (0 = off, default 25)
/* ---- Companion-only fields ---- */
@@ -165,7 +167,7 @@ static inline void initNodePrefs(NodePrefs* prefs) {
prefs->_reserved_apc_margin = 0; // reserved (was APC), see NodePrefs
prefs->cad_auto = 1; // Default ON — adaptive staircase acts on probe stats
prefs->cad_offset = 0; // Start at family base detPeak (SF+13 on SX126x)
prefs->cad_probe_interval = 15; // 15 s → staircase responds to change in ~1-2 h
prefs->probe_interval = 15; // floor sample + CAD probe; staircase responds in ~1-2 h
prefs->cad_busycap = 25; // back off detPeak once >25% of TX attempts are deferred
prefs->wake_on_msg = 1; // Default ON — wake display when message arrives
prefs->v_contact_enabled = 1; // Default ON — v-contact loopback admin chat (companion)
+17
View File
@@ -35,6 +35,12 @@ public:
/* Notifies event loop of pending TX so it can schedule a wake. */
typedef void (*tx_queued_callback_t)(uint32_t delay_ms, void *user_data);
/* Wakes the event loop so loop() runs at the next opportunity. For off-main
* code that sets state loop() must drain (MQTT CONNACK, SNTP): without it that
* state waits for whatever deadline happens to fire next, which since the move
* to deadline-driven maintenance can be minutes rather than the old 5 s tick. */
typedef void (*wake_callback_t)(void *user_data);
typedef uint32_t DispatcherAction;
#define ACTION_RELEASE (0)
@@ -66,6 +72,8 @@ class Dispatcher {
uint32_t n_recv_flood, n_recv_direct;
tx_queued_callback_t _tx_queued_cb;
void *_tx_queued_user_data;
wake_callback_t _wake_cb;
void *_wake_user_data;
void processRecvPacket(Packet *pkt);
@@ -126,6 +134,15 @@ public:
_tx_queued_cb = cb;
_tx_queued_user_data = user_data;
}
void setWakeCallback(wake_callback_t cb, void *user_data) {
_wake_cb = cb;
_wake_user_data = user_data;
}
/* Safe from any thread: the callback only posts an event. Public because
* off-main C callbacks (e.g. the SNTP hook) are not class members. */
void notifyWake() {
if (_wake_cb) _wake_cb(_wake_user_data);
}
bool millisHasNowPassed(uint32_t timestamp) const;
uint32_t futureMillis(int millis_from_now) const;
+2
View File
@@ -43,6 +43,8 @@ Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr)
n_recv_flood = n_recv_direct = 0;
_tx_queued_cb = nullptr;
_tx_queued_user_data = nullptr;
_wake_cb = nullptr;
_wake_user_data = nullptr;
}
void Dispatcher::begin()
+3 -3
View File
@@ -737,7 +737,7 @@ public:
void applyCadPrefs() override {
lora_radio.setCadParams(companion_mesh.prefs.cad_auto != 0,
companion_mesh.prefs.cad_offset,
companion_mesh.prefs.cad_probe_interval,
companion_mesh.prefs.probe_interval,
companion_mesh.prefs.cad_busycap);
}
void resetCadStats() override {
@@ -1385,7 +1385,7 @@ int main(void)
* its proper default. A hand-maintained subset here silently drifts: any
* field not listed defaults to 0, and on upgrade the past-EOF read in
* loadPrefs then keeps that 0 instead of the real default (this is what
* zeroed cad_probe_interval / cad_auto and, earlier, the GPS settings). */
* zeroed probe_interval / cad_auto and, earlier, the GPS settings). */
initNodePrefs(&companion_mesh.prefs);
/* Companion-specific overrides vs. initNodePrefs defaults: */
companion_mesh.prefs.auto_shutdown_mv = CONFIG_ZEPHCORE_AUTO_SHUTDOWN_MILLIVOLTS; /* low-batt cutoff (0=off) */
@@ -1547,7 +1547,7 @@ int main(void)
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,
companion_mesh.prefs.probe_interval,
companion_mesh.prefs.cad_busycap);
ui_set_radio_runtime(
lora_radio.getActiveSyncWord(),
+55 -13
View File
@@ -103,7 +103,8 @@ static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios);
#define MESH_EVENT_TX_DRAIN BIT(5) /* Outbound packet delay expired, run checkSend */
#define MESH_EVENT_RTC_SAVE BIT(6) /* Hardware-RTC write requested off-main */
#define MESH_EVENT_INIT_ADVERT BIT(7) /* Deferred boot advert — send on main thread */
#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT)
#define MESH_EVENT_WAKE BIT(8) /* Off-main state set; run loop() promptly */
#define MESH_EVENT_ALL (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | MESH_EVENT_CLI_RX | MESH_EVENT_MAINTENANCE | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT | MESH_EVENT_WAKE)
/* Maintenance is deadline-driven, not periodic: after every pass the loop asks
* the mesh when its soonest pending deadline is (msUntilNextMaintenance) and
@@ -290,10 +291,11 @@ static void maintenance_timer_fn(struct k_timer *timer)
static void arm_maintenance_wake(void)
{
uint32_t delay = MAINTENANCE_BACKSTOP_MS;
uint32_t next = MAINTENANCE_BACKSTOP_MS;
#ifdef ZEPHCORE_LORA
if (repeater_mesh_ptr) {
uint32_t next = repeater_mesh_ptr->msUntilNextMaintenance();
next = repeater_mesh_ptr->msUntilNextMaintenance();
if (next < delay) {
delay = next;
@@ -305,6 +307,15 @@ static void arm_maintenance_wake(void)
delay = MAINTENANCE_MIN_MS;
}
/* Both figures, because which one is binding is the whole diagnosis:
* next=15000 armed=15000 a real deadline won; working as intended
* next=15000 armed=5000 the backstop is clamping (it must stay
* above the noise-floor/CAD intervals)
* next=0 armed=50 a deadline reports due but its state is
* not advancing; repeated = spin
* mesh::MAINTENANCE_IDLE (0x7FFFFFFF) as `next` means nothing pending. */
LOG_DBG("maint: next=%u armed=%u", (unsigned)next, (unsigned)delay);
k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT);
}
@@ -346,6 +357,15 @@ static void tx_queued_callback(uint32_t delay_ms, void *user_data)
ARG_UNUSED(user_data);
k_work_reschedule(&tx_drain_work, K_MSEC(delay_ms));
}
/* Off-main code (MQTT CONNACK, SNTP) set state that loop() must drain. Post
* only this runs on the MQTT publisher / WiFi thread, and k_event_post is
* the sole thing safe to do from there. */
static void wake_callback(void *user_data)
{
ARG_UNUSED(user_data);
k_event_post(&mesh_events, MESH_EVENT_WAKE);
}
#endif
/* Global instances */
@@ -487,23 +507,44 @@ static void repeater_event_loop(void)
/* Packet processing — only on radio/CLI/TX events */
if (repeater_mesh_ptr &&
(events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE |
MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN))) {
MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN |
MESH_EVENT_WAKE))) {
repeater_mesh_ptr->loop();
}
#endif
/* A maintenance deadline came due — run the pass. */
#ifdef ZEPHCORE_LORA
/* Radio maintenance runs OPPORTUNISTICALLY, on every pass, whatever
* woke us not only when its own timer fired.
*
* Every item inside is deadline-gated internally, so this is nearly
* free when nothing is due. The point is what it does to a busy
* node: a hilltop repeater is already waking constantly for packets,
* so maintenance rides along on those wakes, its deadlines advance,
* and arm_maintenance_wake() below keeps pushing the timer out the
* maintenance timer then almost never fires and costs no wakes at
* all. Running it only on its own event (as this did originally)
* inverted that: the busiest nodes, which can least afford it, paid
* the full timer cadence on top of their packet wakes, and every
* blocked sample burned a retry against a channel that is busy for
* sustained reasons (real traffic in isReceiving(), the RSSI
* prefilter) rather than the transient duty-cycle sleep window the
* retries were sized for.
*
* Ordering matters: this sits AFTER packet processing so an inbound
* frame is handled before we spend SPI time on an RSSI sweep. */
if (repeater_mesh_ptr) {
repeater_mesh_ptr->maintenanceLoop();
}
#endif
/* A maintenance deadline came due — run the time-based work too. */
if (events & MESH_EVENT_MAINTENANCE) {
#ifdef ZEPHCORE_LORA
/* Radio maintenance: noise floor calibration, AGC reset,
* RX watchdog. Separated from loop() so these never run
* on packet-driven events. */
/* Drive loop() so time-based actions (advert timers,
* tempradio set/revert, contacts flush, uplink status)
* still fire when no LoRa/CLI traffic wakes the loop. */
if (repeater_mesh_ptr) {
repeater_mesh_ptr->maintenanceLoop();
/* Also drive loop() so time-based actions (advert
* timers, tempradio set/revert, contacts flush,
* uplink status) still fire when no LoRa/CLI
* traffic wakes the event loop. */
repeater_mesh_ptr->loop();
}
#endif
@@ -633,6 +674,7 @@ int main(void)
lora_radio.setRxCallback(lora_rx_callback, nullptr);
lora_radio.setTxDoneCallback(lora_tx_done_callback, nullptr);
repeater_mesh.setTxQueuedCallback(tx_queued_callback, nullptr);
repeater_mesh.setWakeCallback(wake_callback, nullptr);
/* Load or generate identity BEFORE begin(). First-boot keygen runs
* the layered entropy mixer + Ed25519 derive + reserved-prefix
@@ -680,7 +722,7 @@ int main(void)
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, prefs->cad_busycap);
prefs->probe_interval, prefs->cad_busycap);
/* Feed initial UI state from loaded prefs */
ui_set_node_name(prefs->node_name);
+1 -1
View File
@@ -610,7 +610,7 @@ int main(void)
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, prefs->cad_busycap);
prefs->probe_interval, prefs->cad_busycap);
/* Feed initial UI state from loaded prefs */
ui_set_node_name(prefs->node_name);