mirror of
https://github.com/liquidraver/ZephCore.git
synced 2026-09-02 01:38:19 +00:00
get rid of housekeeping tick and AGC reset
This commit is contained in:
@@ -370,12 +370,68 @@ endif # ZEPHCORE_ROLE_COMPANION
|
||||
|
||||
config ZEPHCORE_HOUSEKEEPING_INTERVAL_MS
|
||||
int "Periodic housekeeping interval (ms)"
|
||||
depends on ZEPHCORE_ROLE_COMPANION || ZEPHCORE_ROLE_ROOM_SERVER
|
||||
default 5000
|
||||
range 1000 60000
|
||||
help
|
||||
Wake interval for noise floor calibration, battery reads, and UI refresh.
|
||||
Longer = lower power, less responsive monitoring.
|
||||
|
||||
Companion and room server only. Both still run a fixed periodic tick:
|
||||
the companion's block contains genuine pollers (contact-dump progress,
|
||||
BLE advertising watchdog) that have no deadline to arm, and the room
|
||||
server is paced by its own 500 ms push timer regardless. Repeaters are
|
||||
deadline-driven instead — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS.
|
||||
|
||||
config ZEPHCORE_MAINTENANCE_BACKSTOP_MS
|
||||
int "Repeater maintenance backstop (ms)"
|
||||
depends on ZEPHCORE_ROLE_REPEATER
|
||||
default 60000
|
||||
range 15000 600000
|
||||
help
|
||||
Hard ceiling on how long a repeater will go without a maintenance pass.
|
||||
|
||||
main_repeater.cpp arms a one-shot wake at the soonest deadline reported
|
||||
by msUntilNextMaintenance(), then clamps it to this value. The clamp is
|
||||
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.
|
||||
|
||||
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
|
||||
default 15000
|
||||
range 1000 120000
|
||||
help
|
||||
How often the radio samples ambient RSSI to update the noise floor EMA.
|
||||
|
||||
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
|
||||
roles that still tick periodically (companion, room server) keep 5000 —
|
||||
raising it there would change their RF tracking for no power gain, since
|
||||
their housekeeping timer wakes them at 5 s regardless.
|
||||
|
||||
Repeaters use 15 s, matching ZEPHCORE_CAD_PROBE_INTERVAL — the only other
|
||||
recurring radio deadline — so the two do not interleave into separate
|
||||
wakes any more often than they have to.
|
||||
|
||||
On a repeater this is one of the two shortest recurring deadlines, so it
|
||||
bounds how long the SoC can sleep between wakes. Raising it stretches
|
||||
EMA warmup (8 samples) and the every-16th-sample unguarded bypass by the
|
||||
same factor: at 15000 warmup is ~2 min and the bypass ~4 min apart; at
|
||||
30000, ~4 min and ~8 min. Past 15000 the CAD probe becomes the binding
|
||||
deadline instead, so raise both or neither.
|
||||
|
||||
menu "Radio Configuration"
|
||||
|
||||
choice ZEPHCORE_RADIO_TYPE
|
||||
|
||||
@@ -198,7 +198,7 @@ All `set uplink.*` changes are saved immediately and only applied after reboot.
|
||||
| `get guest.password` | Guest access password |
|
||||
| `get owner.info` | Owner/contact info (pipes `\|` display as newlines) |
|
||||
| `get int.thresh` | Interference threshold |
|
||||
| `get agc.reset.interval` | AGC reset interval in ms (stored in 4 s steps) |
|
||||
| `get agc.reset.interval` | Removed - replies `use rxduty instead` |
|
||||
| `get multi.acks` | Extra ACK transmit count (`0` or `1`) |
|
||||
| `get path.hash.mode` | Path hashing algorithm: `0`, `1`, or `2` |
|
||||
| `get loop.detect` | Loop detection level: `off`, `minimal`, `moderate`, or `strict` |
|
||||
@@ -243,7 +243,7 @@ Changes are persisted immediately unless noted. Some require a reboot.
|
||||
| `set guest.password <pwd>` | | Set guest access password |
|
||||
| `set owner.info <text>` | Use `\|` for newlines | Owner/contact information |
|
||||
| `set int.thresh <value>` | | Interference detection threshold |
|
||||
| `set agc.reset.interval <ms>` | Rounded to 4 s | AGC reset interval |
|
||||
| `set agc.reset.interval <ms>` | Accepted, ignored | Removed - replies `use rxduty instead` |
|
||||
| `set multi.acks <0\|1>` | | Enable extra ACK transmits |
|
||||
| `set path.hash.mode <mode>` | 0, 1, or 2 | Path hashing algorithm |
|
||||
| `set loop.detect <mode>` | `off`, `minimal`, `moderate`, `strict` | Loop detection sensitivity |
|
||||
|
||||
@@ -73,12 +73,6 @@ void LR1110Radio::hwSetRxBoost(bool enable)
|
||||
lr11xx_set_rx_boost(_dev, enable);
|
||||
}
|
||||
|
||||
void LR1110Radio::hwResetAGC()
|
||||
{
|
||||
/* Warm sleep → Calibrate(ALL) → re-calibrate image → re-apply RX boost */
|
||||
lr11xx_reset_agc(_dev);
|
||||
}
|
||||
|
||||
uint32_t LR1110Radio::hwWakeupTimeUs()
|
||||
{
|
||||
return lr11xx_get_wakeup_time_us(_dev);
|
||||
|
||||
@@ -27,7 +27,6 @@ protected:
|
||||
int16_t hwGetCurrentRSSI() override;
|
||||
bool hwIsReceiving() override;
|
||||
void hwSetRxBoost(bool enable) override;
|
||||
void hwResetAGC() override;
|
||||
uint32_t hwWakeupTimeUs() override;
|
||||
int hwCadProbe(int8_t level) override;
|
||||
void hwCadSetPeakOffset(int8_t offset) override;
|
||||
|
||||
@@ -73,11 +73,6 @@ void LR2021Radio::hwSetRxBoost(bool enable)
|
||||
lr20xx_set_rx_boost(_dev, enable);
|
||||
}
|
||||
|
||||
void LR2021Radio::hwResetAGC()
|
||||
{
|
||||
lr20xx_reset_agc(_dev);
|
||||
}
|
||||
|
||||
int LR2021Radio::hwCadProbe(int8_t level)
|
||||
{
|
||||
return lr20xx_cad_probe(_dev, level);
|
||||
|
||||
@@ -27,7 +27,6 @@ protected:
|
||||
int16_t hwGetCurrentRSSI() override;
|
||||
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;
|
||||
|
||||
@@ -47,9 +47,11 @@ 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),
|
||||
_cad_busycap_pct(0),
|
||||
_cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_probe_rr(0),
|
||||
_cad_last_probe_ms(0), _cad_last_decay_ms(0), _cad_retry_ms(0),
|
||||
_cad_probe_rr(0),
|
||||
_rx_duty_cycle_enabled(IS_ENABLED(CONFIG_ZEPHCORE_LORA_RX_DUTY_CYCLE)),
|
||||
_rx_boost_enabled(true),
|
||||
_dc_last_rx_us(0), _dc_last_sleep_us(0),
|
||||
@@ -786,6 +788,19 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
|
||||
{
|
||||
_calibration_threshold = threshold;
|
||||
|
||||
/* Own the sampling cadence rather than inheriting the caller's. Early
|
||||
* calls are a no-op, so this is safe to invoke from any wake. */
|
||||
int64_t now = k_uptime_get();
|
||||
|
||||
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. */
|
||||
_noise_floor_next_ms = now + NOISE_FLOOR_RETRY_MS;
|
||||
|
||||
if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) {
|
||||
return;
|
||||
}
|
||||
@@ -809,10 +824,15 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
|
||||
for (int i = 0; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) {
|
||||
samples[i] = hwGetCurrentRSSI();
|
||||
if (samples[i] == -128) {
|
||||
/* Chip busy or RSSI read contended — retry next tick. */
|
||||
/* Chip busy or RSSI read contended — keep the short
|
||||
* retry deadline set above and try again shortly. */
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* A full sample landed: next one is a full interval away. */
|
||||
_noise_floor_next_ms = now + NOISE_FLOOR_INTERVAL_MS;
|
||||
|
||||
/* Insertion sort — tiny array, branch-friendly on Cortex-M */
|
||||
for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) {
|
||||
int16_t key = samples[i];
|
||||
@@ -873,35 +893,6 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold)
|
||||
rssi, _noise_floor, _ema_unguarded - 1);
|
||||
}
|
||||
|
||||
void LoRaRadioBase::resetAGC()
|
||||
{
|
||||
/* Don't reset AGC while transmitting or receiving — warm sleep would
|
||||
* abort the TX or corrupt the incoming packet. maintenanceLoop()
|
||||
* will retry next housekeeping cycle.
|
||||
* Also skip if the chip is in its duty-cycle sleep phase: hwResetAGC()
|
||||
* holds the SPI mutex with K_FOREVER and would hang for 3 s. */
|
||||
if (atomic_get(&_tx_active) || isReceiving()) {
|
||||
return;
|
||||
}
|
||||
if (_rx_duty_cycle_enabled && hwIsChipBusy()) {
|
||||
return;
|
||||
}
|
||||
|
||||
hwResetAGC();
|
||||
|
||||
/* Warm sleep + calibrate leaves the radio in STANDBY.
|
||||
* Restart receive if we were in RX mode. */
|
||||
if (atomic_get(&_in_recv_mode)) {
|
||||
startReceive();
|
||||
}
|
||||
|
||||
/* Reset noise floor so it reconverges from scratch (seed + warmup).
|
||||
* Without this, a stuck _noise_floor of -120 makes the sampling threshold
|
||||
* too low to accept normal samples, self-reinforcing the stuck value. */
|
||||
_noise_floor = DEFAULT_NOISE_FLOOR;
|
||||
_ema_unguarded = 0;
|
||||
}
|
||||
|
||||
bool LoRaRadioBase::isReceiving()
|
||||
{
|
||||
if (!atomic_get(&_in_recv_mode) || atomic_get(&_tx_active)) {
|
||||
@@ -1116,6 +1107,14 @@ void LoRaRadioBase::cadMaintenance()
|
||||
if (now - _cad_last_probe_ms < (int64_t)_cad_probe_interval_s * 1000) {
|
||||
return;
|
||||
}
|
||||
if (now < _cad_retry_ms) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* 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,
|
||||
@@ -1223,6 +1222,57 @@ void LoRaRadioBase::cadMaintenance()
|
||||
}
|
||||
}
|
||||
|
||||
/* int64 uptime delta → the uint32 "ms from now" the maintenance contract wants.
|
||||
* Already-passed deadlines saturate at 0 (due now), far-future ones at IDLE. */
|
||||
static uint32_t clampDeadline(int64_t remaining_ms)
|
||||
{
|
||||
if (remaining_ms <= 0) {
|
||||
return 0;
|
||||
}
|
||||
if (remaining_ms >= (int64_t)mesh::MAINTENANCE_IDLE) {
|
||||
return mesh::MAINTENANCE_IDLE;
|
||||
}
|
||||
return (uint32_t)remaining_ms;
|
||||
}
|
||||
|
||||
/* When does this radio next need a maintenance call? Two independent items:
|
||||
* the noise floor sampler (always running) and the CAD calibrator (only when
|
||||
* probing is enabled). Both hold absolute uptime deadlines, so this is a pure
|
||||
* read — it must not touch the chip, since the event loop calls it on every
|
||||
* wake to decide how long it may sleep. */
|
||||
uint32_t LoRaRadioBase::msUntilNextMaintenance()
|
||||
{
|
||||
int64_t now = k_uptime_get();
|
||||
uint32_t next = mesh::MAINTENANCE_IDLE;
|
||||
|
||||
/* Noise floor. A zero deadline means "never sampled yet" — due now. */
|
||||
if (_noise_floor_next_ms == 0) {
|
||||
return 0;
|
||||
}
|
||||
next = clampDeadline(_noise_floor_next_ms - now);
|
||||
|
||||
if (_cad_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));
|
||||
|
||||
/* Stats decay. _cad_last_decay_ms == 0 means the first call latches it
|
||||
* rather than decaying, so treat that as due now. */
|
||||
if (_cad_last_decay_ms == 0) {
|
||||
return 0;
|
||||
}
|
||||
return mesh::maintenanceSooner(
|
||||
next, clampDeadline(_cad_last_decay_ms + (int64_t)CAD_STATS_DECAY_MS - now));
|
||||
}
|
||||
|
||||
int LoRaRadioBase::formatCadStatus(char *buf, int cap)
|
||||
{
|
||||
uint8_t base = hwCadBasePeak();
|
||||
|
||||
@@ -68,7 +68,6 @@ public:
|
||||
/* Advanced radio features */
|
||||
int getNoiseFloor() const override;
|
||||
void triggerNoiseFloorCalibrate(int threshold) override;
|
||||
void resetAGC() override;
|
||||
bool isReceiving() override;
|
||||
void recoverRxState() override;
|
||||
|
||||
@@ -108,6 +107,7 @@ public:
|
||||
void setCadParams(bool auto_enabled, int8_t offset,
|
||||
uint16_t probe_interval_s, uint8_t busycap_pct) override;
|
||||
void cadMaintenance() override;
|
||||
uint32_t msUntilNextMaintenance() override;
|
||||
int8_t getCadOffset() const override { return _cad_offset; }
|
||||
void resetCadStats() override;
|
||||
int formatCadStatus(char *buf, int cap) override;
|
||||
@@ -124,7 +124,6 @@ protected:
|
||||
* latch + raw IRQ bits, never clears. Backs LoRaRadioBase::isReceiving(). */
|
||||
virtual bool hwIsReceiving() = 0;
|
||||
virtual void hwSetRxBoost(bool enable) = 0;
|
||||
virtual void hwResetAGC() = 0;
|
||||
|
||||
/** GPIO-only BUSY check (no SPI). Default false for chips without duty-cycle sleep. */
|
||||
virtual bool hwIsChipBusy() { return false; }
|
||||
@@ -194,6 +193,13 @@ protected:
|
||||
int _noise_floor;
|
||||
int _calibration_threshold;
|
||||
uint8_t _ema_unguarded; /* tick counter for warmup + periodic bypass */
|
||||
/* Absolute uptime deadline of the next floor sample. The sampler used
|
||||
* to run on every housekeeping tick, which pinned its cadence to the
|
||||
* 5 s timer; owning its own deadline is what lets that timer go away.
|
||||
* Advanced by NOISE_FLOOR_INTERVAL_MS after a sample lands, and by the
|
||||
* shorter retry when an attempt is turned away because the radio was
|
||||
* mid-packet / transmitting / in its duty-cycle sleep window. */
|
||||
int64_t _noise_floor_next_ms;
|
||||
|
||||
/* Adaptive CAD state */
|
||||
struct CadLevelStats {
|
||||
@@ -209,6 +215,11 @@ protected:
|
||||
uint8_t _cad_busycap_pct; /* airtime cap: max % TX deferred (0 = off) */
|
||||
int64_t _cad_last_probe_ms;
|
||||
int64_t _cad_last_decay_ms;
|
||||
/* Earliest uptime at which a due-but-blocked probe may be retried. The
|
||||
* interval check in cadMaintenance() is against _cad_last_probe_ms,
|
||||
* which only advances on a probe that actually ran — without this a
|
||||
* blocked probe would report "due now" forever and spin the wake. */
|
||||
int64_t _cad_retry_ms;
|
||||
uint8_t _cad_probe_rr; /* round-robin index (sweep) / frontier mix counter */
|
||||
|
||||
int8_t pickCadProbeLevel();
|
||||
|
||||
@@ -80,12 +80,6 @@ void SX126xRadio::hwSetRxBoost(bool enable)
|
||||
sx126x_set_rx_boost(_dev, enable);
|
||||
}
|
||||
|
||||
void SX126xRadio::hwResetAGC()
|
||||
{
|
||||
/* Warm sleep → Calibrate(ALL) → re-calibrate image → re-apply RX settings */
|
||||
sx126x_reset_agc(_dev);
|
||||
}
|
||||
|
||||
bool SX126xRadio::hwIsChipBusy()
|
||||
{
|
||||
return sx126x_is_chip_busy(_dev);
|
||||
|
||||
@@ -29,7 +29,6 @@ protected:
|
||||
int16_t hwGetCurrentRSSI() override;
|
||||
bool hwIsReceiving() override;
|
||||
void hwSetRxBoost(bool enable) override;
|
||||
void hwResetAGC() override;
|
||||
bool hwIsChipBusy() override;
|
||||
uint32_t hwWakeupTimeUs() override;
|
||||
int hwCadProbe(int8_t level) override;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* hwGetCurrentRSSI() — returns -80 dBm sentinel (no hardware path)
|
||||
* hwIsReceiving() — always false (no preamble/header IRQ exposed)
|
||||
* hwSetRxBoost() — no-op (SX127x has no RX boost register)
|
||||
* hwResetAGC() — no-op (loramac-node manages AGC internally)
|
||||
* hwIsChipBusy() — inherited false (no BUSY pin on SX127x)
|
||||
*
|
||||
* Everything else (configure, send, receive) uses the standard API.
|
||||
@@ -96,22 +95,4 @@ void SX127xRadio::hwSetRxBoost(bool enable)
|
||||
ARG_UNUSED(enable);
|
||||
}
|
||||
|
||||
void SX127xRadio::hwResetAGC()
|
||||
{
|
||||
/* The loramac-node SX127x driver manages AGC recalibration internally
|
||||
* (RadioSetRxConfig re-programs all gain registers on every RX config
|
||||
* call). No explicit AGC reset is needed or possible via the standard
|
||||
* API. */
|
||||
}
|
||||
|
||||
void SX127xRadio::resetAGC()
|
||||
{
|
||||
/* hwResetAGC() is a no-op, so skip the base-class resetAGC() entirely.
|
||||
* The base class calls startReceive() after hwResetAGC(), but the
|
||||
* loramac-node modem mutex (STATE_BUSY during async RX) causes
|
||||
* lora_recv_async() to return -EBUSY, setting _in_recv_mode = 0 and
|
||||
* corrupting the state machine. The loramac-node driver self-manages
|
||||
* AGC, so nothing needs to happen here. */
|
||||
}
|
||||
|
||||
} /* namespace mesh */
|
||||
|
||||
@@ -49,15 +49,6 @@ protected:
|
||||
/* SX127x has no RX boost / LNA gain switch via standard API. No-op. */
|
||||
void hwSetRxBoost(bool enable) override;
|
||||
|
||||
/* SX127x loramac-node driver manages AGC automatically. No-op. */
|
||||
void hwResetAGC() override;
|
||||
|
||||
/* Override public resetAGC(): hwResetAGC() is a no-op, and the
|
||||
* loramac-node modem mutex makes the base-class startReceive() call
|
||||
* fail with -EBUSY (modem STATE_BUSY during async RX), which would
|
||||
* set _in_recv_mode = 0 and corrupt the state machine. */
|
||||
void resetAGC() override;
|
||||
|
||||
/* SX127x has no BUSY pin. Default (false) from base is correct. */
|
||||
/* bool hwIsChipBusy() — inherited, returns false */
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#include <zephyr/drivers/lora.h>
|
||||
|
||||
/* --- Noise floor calibration (EMA) ---
|
||||
* Median-of-N RSSI reads → EMA. alpha = 1/8, converges in ~8 ticks (~40s).
|
||||
* Median-of-N RSSI reads → EMA. alpha = 1/8, converges in ~8 ticks.
|
||||
* Samples above floor + SAMPLING_THRESHOLD rejected as interference. */
|
||||
#define NOISE_FLOOR_EMA_SHIFT 3 /* alpha = 1/(1<<3) = 1/8 */
|
||||
#define NOISE_FLOOR_SAMPLES_PER_TICK 8 /* median of 8 reads per tick */
|
||||
@@ -20,6 +20,24 @@
|
||||
#define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* dB above floor to reject as interference */
|
||||
#define DEFAULT_NOISE_FLOOR 0 /* sentinel: seed from first sample */
|
||||
|
||||
/* 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
|
||||
* the every-16th-sample unguarded bypass are counted in samples, so they scale
|
||||
* with this value — see the Kconfig help before changing it. */
|
||||
#define NOISE_FLOOR_INTERVAL_MS CONFIG_ZEPHCORE_NOISE_FLOOR_INTERVAL_MS
|
||||
/* A due sample that lands while the radio is mid-packet, transmitting, or in
|
||||
* its duty-cycle sleep window is retried on this deadline rather than waiting a
|
||||
* full interval.
|
||||
*
|
||||
* 5000 is not a tuned guess: before the deadline conversion a blocked sample
|
||||
* simply waited for the next 5 s housekeeping tick, so this reproduces the old
|
||||
* retry grid exactly. It matters under RX duty cycle, where the chip is in its
|
||||
* sleep window a large fraction of the time and blocked attempts are the norm
|
||||
* 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
|
||||
|
||||
/* --- 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
|
||||
@@ -67,6 +85,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
|
||||
|
||||
/* --- RX ring buffer --- */
|
||||
#define RX_RING_SIZE 8 /* ~2 KB; buffers burst arrivals at SF7/BW500 */
|
||||
|
||||
@@ -1396,6 +1396,48 @@ void RepeaterMesh::loop() {
|
||||
last_millis = now;
|
||||
}
|
||||
|
||||
uint32_t RepeaterMesh::msUntilNextMaintenance() {
|
||||
uint32_t now = (uint32_t)_ms->getMillis();
|
||||
uint32_t next = mesh::Mesh::msUntilNextMaintenance();
|
||||
|
||||
/* Advert timers. 0 means "disabled" for both — flood defaults to 47 h,
|
||||
* periodic local advert is off unless configured. */
|
||||
if (next_flood_advert) {
|
||||
next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_flood_advert));
|
||||
}
|
||||
if (next_local_advert) {
|
||||
next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, next_local_advert));
|
||||
}
|
||||
|
||||
/* Temporary radio params: apply, then revert. Both CLI-armed, both 0 when idle. */
|
||||
if (set_radio_at) {
|
||||
next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, set_radio_at));
|
||||
}
|
||||
if (revert_radio_at) {
|
||||
next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, revert_radio_at));
|
||||
}
|
||||
|
||||
/* Deferred ACL write-back. */
|
||||
if (dirty_contacts_expiry) {
|
||||
next = mesh::maintenanceSooner(next, mesh::maintenanceUntil(now, dirty_contacts_expiry));
|
||||
}
|
||||
|
||||
#if IS_ENABLED(CONFIG_ZEPHCORE_REPEATER_UPLINK) && IS_ENABLED(CONFIG_MQTT_LIB)
|
||||
if (_uplink_next_status_at) {
|
||||
next = mesh::maintenanceSooner(next,
|
||||
mesh::maintenanceUntil(now, _uplink_next_status_at));
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Mesh time sync evaluates at most every 15 min, and only when enabled. */
|
||||
if (_prefs.meshtimesync) {
|
||||
next = mesh::maintenanceSooner(
|
||||
next, _timesync.msUntilNextEval((uint32_t)(k_uptime_get() / 1000)));
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
void RepeaterMesh::timeSyncTick() {
|
||||
if (!_prefs.meshtimesync) return;
|
||||
if (!_timesync.runTick(*getRTCClock())) return;
|
||||
|
||||
@@ -169,12 +169,6 @@ protected:
|
||||
int getInterferenceThreshold() const override {
|
||||
return _prefs.interference_threshold;
|
||||
}
|
||||
int getAGCResetInterval() const override {
|
||||
if (_prefs.rx_duty_cycle) {
|
||||
return 0;
|
||||
}
|
||||
return ((int)_prefs.agc_reset_interval) * 4000;
|
||||
}
|
||||
uint8_t getExtraAckTransmitCount() const override {
|
||||
return _prefs.multi_acks;
|
||||
}
|
||||
@@ -288,5 +282,12 @@ public:
|
||||
void handleCommand(uint32_t sender_timestamp, char* command, char* reply);
|
||||
void loop();
|
||||
|
||||
/* Folds this role's own time-based work in loop() — advert timers,
|
||||
* tempradio apply/revert, ACL flush, uplink status, mesh time sync — into
|
||||
* the base maintenance deadline, so the event loop can arm one wake that
|
||||
* covers both loop() and maintenanceLoop(). Every item here is an
|
||||
* absolute deadline already; none of them was ever a poll. */
|
||||
uint32_t msUntilNextMaintenance() override;
|
||||
|
||||
bool hasPendingWork() const;
|
||||
};
|
||||
|
||||
@@ -138,12 +138,6 @@ protected:
|
||||
int getInterferenceThreshold() const override {
|
||||
return _prefs.interference_threshold;
|
||||
}
|
||||
int getAGCResetInterval() const override {
|
||||
if (_prefs.rx_duty_cycle) {
|
||||
return 0;
|
||||
}
|
||||
return ((int)_prefs.agc_reset_interval) * 4000;
|
||||
}
|
||||
uint8_t getExtraAckTransmitCount() const override {
|
||||
return _prefs.multi_acks;
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
||||
} else if (memcmp(config, "int.thresh", 10) == 0) {
|
||||
snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->interference_threshold);
|
||||
} else if (memcmp(config, "agc.reset.interval", 18) == 0) {
|
||||
snprintf(reply, CLI_REPLY_SIZE, "> %u", ((uint32_t)_prefs->agc_reset_interval) * 4);
|
||||
strcpy(reply, "Removed - use rxduty instead");
|
||||
} else if (memcmp(config, "multi.acks", 10) == 0) {
|
||||
snprintf(reply, CLI_REPLY_SIZE, "> %u", (uint32_t)_prefs->multi_acks);
|
||||
} else if (memcmp(config, "allow.read.only", 15) == 0) {
|
||||
@@ -625,15 +625,14 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, const char* command, ch
|
||||
strcpy(reply, "OK");
|
||||
}
|
||||
} else if (memcmp(config, "agc.reset.interval ", 19) == 0) {
|
||||
/* Companion runtime never reads this (getAGCResetInterval is only
|
||||
* overridden in Repeater/RoomServer). */
|
||||
if (strcmp(_callbacks->getRole(), "companion") == 0) {
|
||||
strcpy(reply, "Error: not supported on companion");
|
||||
} else {
|
||||
_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);
|
||||
}
|
||||
/* Periodic AGC recalibration was removed: it reset the noise floor
|
||||
* to its unseeded sentinel on every fire, forcing a fresh seed and
|
||||
* a full EMA warmup, and it was already forced off under RX duty
|
||||
* cycle. RX duty cycle is the supported way to cut RX current.
|
||||
* The prefs BYTE is retained (read/written, never acted on) — the
|
||||
* on-disk layout is byte-exact and shifting it would corrupt every
|
||||
* existing node's prefs. */
|
||||
strcpy(reply, "Removed - use rxduty instead");
|
||||
} 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;
|
||||
|
||||
@@ -135,6 +135,18 @@ public:
|
||||
* gates on its own enable pref. */
|
||||
bool runTick(mesh::RTCClock &rtc);
|
||||
|
||||
/* Milliseconds until runTick() would next actually evaluate (it no-ops
|
||||
* in between). Lets a deadline-driven event loop size its sleep instead
|
||||
* of calling this on a fixed cadence. Callers still gate on their own
|
||||
* enable pref — a disabled time sync has no deadline at all. */
|
||||
uint32_t msUntilNextEval(uint32_t uptime_secs) const
|
||||
{
|
||||
if (_next_eval_uptime == 0 || uptime_secs >= _next_eval_uptime) {
|
||||
return 0;
|
||||
}
|
||||
return (_next_eval_uptime - uptime_secs) * 1000U;
|
||||
}
|
||||
|
||||
/* Manual clock set (CLI time/clock sync, app time set, SNTP): arms the
|
||||
* 7-day suppression window AND drift-envelope pedigree. Suppression
|
||||
* gates bootstrap too. */
|
||||
|
||||
@@ -62,7 +62,10 @@ struct NodePrefs {
|
||||
uint8_t flood_max_unscoped; // hop limit for un-scoped (ROUTE_TYPE_FLOOD) floods
|
||||
uint8_t flood_max_advert; // hop limit for ADVERT floods (curbs advert churn)
|
||||
uint8_t interference_threshold;
|
||||
uint8_t agc_reset_interval; // stored as secs / 4
|
||||
uint8_t agc_reset_interval; // RETIRED: read/written for on-disk layout
|
||||
// compatibility only, never acted on.
|
||||
// "set agc.reset.interval" replies
|
||||
// "use rxduty instead".
|
||||
// Power saving
|
||||
uint8_t powersaving_enabled;
|
||||
// GPS settings
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <mesh/Maintenance.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
@@ -43,6 +44,13 @@ public:
|
||||
/* Finalize expired entries into EMA. */
|
||||
void tick(uint32_t now_ms);
|
||||
|
||||
/* Milliseconds until tick() next has work to do, or MAINTENANCE_IDLE
|
||||
* when nothing is pending (no tracked entries, EMA already at rest).
|
||||
* Lets the event loop schedule a wake at the deadline instead of
|
||||
* polling: an idle repeater has no tracked retransmits and a decayed
|
||||
* EMA, so this returns MAINTENANCE_IDLE and costs no wakes at all. */
|
||||
uint32_t msUntilNextTick(uint32_t now_ms) const;
|
||||
|
||||
float getContentionEstimate() const;
|
||||
|
||||
/* Saturating curve: MIN + (MAX-MIN) * est/(est+HALFPOINT).
|
||||
@@ -65,6 +73,15 @@ private:
|
||||
static constexpr float DEFAULT_BACKOFF_MULT = 0.2f; /* airtime*0.2 per dupe heard */
|
||||
static constexpr uint32_t REACTIVE_HARD_CAP_MS = 2000; /* max cumulative reactive extension */
|
||||
static constexpr uint32_t STALE_MS = 300000; /* 5 min: reset EMA if no traffic */
|
||||
/* Wall-clock period of one 1/8 stale-decay step. The decay used to be
|
||||
* "one step per tick()", which silently coupled it to the 5 s
|
||||
* housekeeping cadence; pinning it to 5000 ms keeps exactly that rate
|
||||
* while making it independent of how often tick() is actually called. */
|
||||
static constexpr uint32_t DECAY_PERIOD_MS = 5000;
|
||||
/* Bound the catch-up loop when tick() is called much later than one
|
||||
* period. 8 steps takes the EMA down by ~66%; anything beyond that is
|
||||
* indistinguishable from rest given the WARMUP/read paths. */
|
||||
static constexpr int MAX_DECAY_CATCHUP = 8;
|
||||
|
||||
struct Entry {
|
||||
uint32_t hash32;
|
||||
@@ -79,6 +96,7 @@ private:
|
||||
uint32_t _ema_x256;
|
||||
int _finalized_count;
|
||||
uint32_t _last_retransmit_ms;
|
||||
uint32_t _last_decay_ms;
|
||||
float _backoff_multiplier;
|
||||
|
||||
void finalizeEntry(int idx);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <mesh/Utils.h>
|
||||
#include <mesh/Radio.h>
|
||||
#include <mesh/Clock.h>
|
||||
#include <mesh/Maintenance.h>
|
||||
#include <string.h>
|
||||
|
||||
namespace mesh {
|
||||
@@ -29,8 +30,6 @@ public:
|
||||
virtual uint32_t getOutboundSchedule(int i) const = 0;
|
||||
virtual bool rescheduleOutbound(int i, uint32_t new_scheduled_for) = 0;
|
||||
virtual uint8_t peekNextOutboundPriority(uint32_t now) const = 0;
|
||||
virtual void queueInbound(Packet *packet, uint32_t scheduled_for) = 0;
|
||||
virtual Packet *getNextInbound(uint32_t now) = 0;
|
||||
};
|
||||
|
||||
/* Notifies event loop of pending TX so it can schedule a wake. */
|
||||
@@ -43,6 +42,9 @@ typedef uint32_t DispatcherAction;
|
||||
#define ACTION_RETRANSMIT(pri) (((uint32_t)1 + (pri))<<24)
|
||||
#define ACTION_RETRANSMIT_DELAYED(pri, _delay) ((((uint32_t)1 + (pri))<<24) | (_delay))
|
||||
|
||||
/* Radio considered stalled after this long neither receiving nor sending. */
|
||||
#define RADIO_STALL_THRESHOLD_MS 8000
|
||||
|
||||
#define ERR_EVENT_FULL (1 << 0)
|
||||
#define ERR_EVENT_CAD_TIMEOUT (1 << 1)
|
||||
#define ERR_EVENT_STARTRX_TIMEOUT (1 << 2)
|
||||
@@ -57,7 +59,6 @@ class Dispatcher {
|
||||
uint32_t last_budget_update;
|
||||
uint32_t duty_cycle_window_ms;
|
||||
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;
|
||||
@@ -89,7 +90,6 @@ protected:
|
||||
virtual uint32_t getCADFailRetryDelay() const;
|
||||
virtual uint32_t getCADFailMaxDuration() const;
|
||||
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. */
|
||||
@@ -99,6 +99,13 @@ public:
|
||||
void begin();
|
||||
void loop();
|
||||
void maintenanceLoop();
|
||||
|
||||
/* Milliseconds until maintenanceLoop() next has work, or
|
||||
* MAINTENANCE_IDLE if nothing is pending. Cheap and side-effect free:
|
||||
* the event loop calls it after every pass to size its next sleep.
|
||||
* Subclasses that add their own time-based work in loop() override this
|
||||
* and fold their deadlines in — see RepeaterMesh. */
|
||||
virtual uint32_t msUntilNextMaintenance();
|
||||
Packet *obtainNewPacket();
|
||||
void releasePacket(Packet *packet);
|
||||
void sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis = 0);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: MIT
|
||||
* Deadline-driven maintenance scheduling.
|
||||
*
|
||||
* maintenanceLoop() used to be driven by a fixed periodic tick (the 5 s
|
||||
* "housekeeping" timer). Every item inside it is really either a deadline
|
||||
* (AGC reset, CAD probe, advert timers, tempradio revert) or a sampler with
|
||||
* its own interval (noise floor) — nothing needs a poll. The msUntilNext*()
|
||||
* queries below let the event loop ask "when does maintenance next have work?"
|
||||
* and arm a single one-shot wake for exactly that moment, so an otherwise idle
|
||||
* node sleeps until its soonest real deadline instead of waking on a fixed
|
||||
* cadence.
|
||||
*
|
||||
* Contract: every msUntilNext*() returns milliseconds from now, 0 when work is
|
||||
* due right now, and MAINTENANCE_IDLE when the item has nothing pending at all.
|
||||
* Callers combine them with maintenanceSooner().
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
/* "No deadline pending." Deliberately not UINT32_MAX so that callers can add
|
||||
* a modest slack to a returned deadline without wrapping. */
|
||||
static const uint32_t MAINTENANCE_IDLE = 0x7FFFFFFFu;
|
||||
|
||||
/* Fold one deadline into a running minimum. */
|
||||
static inline uint32_t maintenanceSooner(uint32_t a, uint32_t b)
|
||||
{
|
||||
return (a < b) ? a : b;
|
||||
}
|
||||
|
||||
/* Milliseconds from now until `deadline` (a millisecond-clock timestamp),
|
||||
* saturating at 0 when it has already passed. Wrap-safe: the subtraction is
|
||||
* done in unsigned arithmetic and the sign is taken from the wrapped result,
|
||||
* matching Dispatcher::millisHasNowPassed(). */
|
||||
static inline uint32_t maintenanceUntil(uint32_t now_ms, uint32_t deadline_ms)
|
||||
{
|
||||
int32_t remaining = (int32_t)(deadline_ms - now_ms);
|
||||
|
||||
if (remaining <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return ((uint32_t)remaining < MAINTENANCE_IDLE) ? (uint32_t)remaining
|
||||
: MAINTENANCE_IDLE;
|
||||
}
|
||||
|
||||
} /* namespace mesh */
|
||||
@@ -80,6 +80,7 @@ public:
|
||||
void begin();
|
||||
void loop();
|
||||
void maintenanceLoop();
|
||||
uint32_t msUntilNextMaintenance() override;
|
||||
|
||||
LocalIdentity self_id;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
#include <mesh/Maintenance.h>
|
||||
|
||||
namespace mesh {
|
||||
|
||||
@@ -22,7 +23,6 @@ public:
|
||||
|
||||
virtual int getNoiseFloor() const { return 0; }
|
||||
virtual void triggerNoiseFloorCalibrate(int threshold) { (void)threshold; }
|
||||
virtual void resetAGC() {}
|
||||
|
||||
virtual bool isInRecvMode() const = 0;
|
||||
virtual bool isReceiving() { return false; }
|
||||
@@ -49,6 +49,11 @@ public:
|
||||
/* One housekeeping tick of the CAD calibrator: maybe run a probe,
|
||||
* update stats, maybe step the staircase (auto mode). */
|
||||
virtual void cadMaintenance() {}
|
||||
|
||||
/* Milliseconds until this radio's periodic work (noise floor sampling,
|
||||
* CAD probing/decay) next needs a call, or MAINTENANCE_IDLE when it has
|
||||
* nothing pending. Radios with no periodic work keep the default. */
|
||||
virtual uint32_t msUntilNextMaintenance() { return MAINTENANCE_IDLE; }
|
||||
virtual int8_t getCadOffset() const { return 0; }
|
||||
virtual void resetCadStats() {}
|
||||
/* Writes a human-readable status block; returns chars written (0 = not
|
||||
|
||||
@@ -23,8 +23,6 @@ public:
|
||||
uint32_t getOutboundSchedule(int i) const override;
|
||||
bool rescheduleOutbound(int i, uint32_t new_scheduled_for) override;
|
||||
uint8_t peekNextOutboundPriority(uint32_t now) const override;
|
||||
void queueInbound(Packet *packet, uint32_t scheduled_for) override;
|
||||
Packet *getNextInbound(uint32_t now) override;
|
||||
};
|
||||
|
||||
} /* namespace mesh */
|
||||
|
||||
@@ -11,7 +11,8 @@ namespace mesh {
|
||||
|
||||
ContentionTracker::ContentionTracker()
|
||||
: _next_idx(0), _ema_x256(0), _finalized_count(0),
|
||||
_last_retransmit_ms(0), _backoff_multiplier(DEFAULT_BACKOFF_MULT)
|
||||
_last_retransmit_ms(0), _last_decay_ms(0),
|
||||
_backoff_multiplier(DEFAULT_BACKOFF_MULT)
|
||||
{
|
||||
memset(_ring, 0, sizeof(_ring));
|
||||
}
|
||||
@@ -144,12 +145,72 @@ void ContentionTracker::tick(uint32_t now_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/* Decay EMA toward 0 if no retransmit in STALE_MS */
|
||||
if (_last_retransmit_ms != 0 && now_ms - _last_retransmit_ms > STALE_MS) {
|
||||
if (_ema_x256 > 0) {
|
||||
/* Decay EMA toward 0 if no retransmit in STALE_MS. Paced by wall clock
|
||||
* (one 1/8 step per DECAY_PERIOD_MS) rather than one step per call, so
|
||||
* the decay rate no longer depends on how often the event loop happens
|
||||
* to call us — see DECAY_PERIOD_MS. */
|
||||
if (_last_retransmit_ms != 0 && now_ms - _last_retransmit_ms > STALE_MS &&
|
||||
_ema_x256 > 0) {
|
||||
if (_last_decay_ms == 0) {
|
||||
/* Arm one full period in the PAST, so the loop below
|
||||
* applies a step on this very call. The old code decayed
|
||||
* immediately on the first tick that observed staleness;
|
||||
* arming at now_ms instead would make the first check
|
||||
* (0 < DECAY_PERIOD_MS) break without decaying, deferring
|
||||
* onset by a whole period on every stale transition — and
|
||||
* would let the reset branch below starve decay entirely
|
||||
* for traffic that goes stale and un-stale repeatedly.
|
||||
* Unsigned wraparound makes this exact even near zero. */
|
||||
_last_decay_ms = now_ms - DECAY_PERIOD_MS;
|
||||
}
|
||||
for (int n = 0; n < MAX_DECAY_CATCHUP; n++) {
|
||||
if (now_ms - _last_decay_ms < DECAY_PERIOD_MS ||
|
||||
_ema_x256 == 0) {
|
||||
break;
|
||||
}
|
||||
_ema_x256 -= _ema_x256 >> EMA_SHIFT;
|
||||
_last_decay_ms += DECAY_PERIOD_MS;
|
||||
}
|
||||
} else {
|
||||
/* Not decaying — restart the phase next time we are. */
|
||||
_last_decay_ms = 0;
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t ContentionTracker::msUntilNextTick(uint32_t now_ms) const
|
||||
{
|
||||
uint32_t next = MAINTENANCE_IDLE;
|
||||
|
||||
/* Soonest ring entry to fall out of the observation window. */
|
||||
for (int i = 0; i < RING_SIZE; i++) {
|
||||
if (!_ring[i].active) {
|
||||
continue;
|
||||
}
|
||||
next = maintenanceSooner(
|
||||
next, maintenanceUntil(now_ms,
|
||||
_ring[i].first_seen_ms + WINDOW_MS + 1));
|
||||
}
|
||||
|
||||
/* Stale-decay step, only while there is EMA left to decay. */
|
||||
if (_last_retransmit_ms != 0 && _ema_x256 > 0) {
|
||||
uint32_t stale_at = _last_retransmit_ms + STALE_MS + 1;
|
||||
|
||||
if (now_ms - _last_retransmit_ms > STALE_MS) {
|
||||
/* Already stale: next step is one period after the last
|
||||
* one (or immediately, if we have not started yet). */
|
||||
next = maintenanceSooner(
|
||||
next, _last_decay_ms == 0
|
||||
? 0
|
||||
: maintenanceUntil(now_ms,
|
||||
_last_decay_ms +
|
||||
DECAY_PERIOD_MS));
|
||||
} else {
|
||||
next = maintenanceSooner(next,
|
||||
maintenanceUntil(now_ms, stale_at));
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
float ContentionTracker::getContentionEstimate() const
|
||||
|
||||
+33
-17
@@ -34,7 +34,6 @@ Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr)
|
||||
tx_budget_ms = 0;
|
||||
last_budget_update = 0;
|
||||
duty_cycle_window_ms = 0;
|
||||
next_agc_reset_time = 0;
|
||||
_err_flags = 0;
|
||||
radio_nonrx_start = 0;
|
||||
prev_isrecv_mode = true;
|
||||
@@ -148,15 +147,8 @@ void Dispatcher::loop()
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
next_agc_reset_time = futureMillis(getAGCResetInterval());
|
||||
}
|
||||
|
||||
{
|
||||
Packet *pkt = _mgr->getNextInbound((uint32_t)_ms->getMillis());
|
||||
if (pkt) {
|
||||
processRecvPacket(pkt);
|
||||
}
|
||||
}
|
||||
checkRecv();
|
||||
checkSend();
|
||||
}
|
||||
@@ -165,8 +157,12 @@ void Dispatcher::maintenanceLoop()
|
||||
{
|
||||
_radio->triggerNoiseFloorCalibrate(getInterferenceThreshold());
|
||||
|
||||
/* RX mode watchdog: TX counts as "active" to avoid false triggers
|
||||
* when the 5s housekeeping timer misses brief RX windows. */
|
||||
/* RX mode watchdog: TX counts as "active" to avoid false triggers when
|
||||
* a maintenance pass lands between brief RX windows. Diagnostic only —
|
||||
* it raises a status bit and recovers nothing — but that bit is surfaced
|
||||
* on every role: the repeater/room-server "stats" CLI reply and binary
|
||||
* telemetry read _err_flags directly, the MQTT uplink publishes it, and
|
||||
* the companion returns it in its BLE device-status response. */
|
||||
bool is_active = _radio->isInRecvMode() || !_radio->isSendComplete();
|
||||
if (is_active != prev_isrecv_mode) {
|
||||
prev_isrecv_mode = is_active;
|
||||
@@ -174,16 +170,11 @@ void Dispatcher::maintenanceLoop()
|
||||
radio_nonrx_start = (uint32_t)_ms->getMillis();
|
||||
}
|
||||
}
|
||||
if (!is_active && (uint32_t)_ms->getMillis() - radio_nonrx_start > 8000) { /* 8s stall threshold */
|
||||
if (!is_active &&
|
||||
(uint32_t)_ms->getMillis() - radio_nonrx_start > RADIO_STALL_THRESHOLD_MS) {
|
||||
_err_flags |= ERR_EVENT_STARTRX_TIMEOUT;
|
||||
}
|
||||
|
||||
/* Periodic AGC recalibration */
|
||||
if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) {
|
||||
_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();
|
||||
@@ -199,6 +190,31 @@ void Dispatcher::maintenanceLoop()
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t Dispatcher::msUntilNextMaintenance()
|
||||
{
|
||||
uint32_t now = (uint32_t)_ms->getMillis();
|
||||
/* Noise floor sampling + CAD probing/decay both live in the radio and
|
||||
* carry their own deadlines. */
|
||||
uint32_t next = _radio->msUntilNextMaintenance();
|
||||
|
||||
/* Radio stall watchdog. Only pending while the radio is known to be
|
||||
* neither receiving nor transmitting as of the last pass — the
|
||||
* transition into that state is itself event-driven (TX start, RX done,
|
||||
* CAD), so there is nothing to poll for while the radio is active.
|
||||
*
|
||||
* The already-flagged check is load-bearing: the verdict is a latched
|
||||
* status bit, so once raised its deadline sits permanently in the past.
|
||||
* Without this the query would return 0 on every call and the event loop
|
||||
* would re-arm at its minimum interval forever. */
|
||||
if (!prev_isrecv_mode && !(_err_flags & ERR_EVENT_STARTRX_TIMEOUT)) {
|
||||
next = maintenanceSooner(
|
||||
next, maintenanceUntil(now, radio_nonrx_start +
|
||||
RADIO_STALL_THRESHOLD_MS));
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
bool Dispatcher::tryParsePacket(Packet *pkt, const uint8_t *raw, int len)
|
||||
{
|
||||
int i = 0;
|
||||
|
||||
@@ -34,6 +34,14 @@ void Mesh::maintenanceLoop()
|
||||
_contention.tick(now);
|
||||
}
|
||||
|
||||
uint32_t Mesh::msUntilNextMaintenance()
|
||||
{
|
||||
uint32_t now = (uint32_t)_ms->getMillis();
|
||||
|
||||
return maintenanceSooner(Dispatcher::msUntilNextMaintenance(),
|
||||
_contention.msUntilNextTick(now));
|
||||
}
|
||||
|
||||
void Mesh::extendPendingRetransmit(uint32_t hash32)
|
||||
{
|
||||
uint32_t now = (uint32_t)_ms->getMillis();
|
||||
|
||||
@@ -113,7 +113,6 @@ struct PacketQueue {
|
||||
static Packet _packet_pool[POOL_SIZE];
|
||||
static PacketQueue _unused;
|
||||
static PacketQueue _send_queue;
|
||||
static PacketQueue _rx_queue;
|
||||
static bool _initialized = false;
|
||||
|
||||
static void init_pool() {
|
||||
@@ -202,18 +201,4 @@ uint8_t StaticPoolPacketManager::peekNextOutboundPriority(uint32_t now) const
|
||||
return _send_queue.peekPriority(now);
|
||||
}
|
||||
|
||||
void StaticPoolPacketManager::queueInbound(Packet *packet, uint32_t scheduled_for)
|
||||
{
|
||||
if (!_rx_queue.add(packet, 0, scheduled_for)) {
|
||||
LOG_WRN("queueInbound: FULL (%d entries) — dropping type=%d",
|
||||
_rx_queue.count(), packet->getPayloadType());
|
||||
free(packet);
|
||||
}
|
||||
}
|
||||
|
||||
Packet *StaticPoolPacketManager::getNextInbound(uint32_t now)
|
||||
{
|
||||
return _rx_queue.get(now);
|
||||
}
|
||||
|
||||
} /* namespace mesh */
|
||||
|
||||
@@ -57,6 +57,19 @@ extern "C" void bt_ctlr_assert_handle(char *file, uint32_t line)
|
||||
/* UI subsystem (display, buttons, buzzer) */
|
||||
#include "ui_task.h"
|
||||
|
||||
/* Headless repeaters link the weak no-op ui_* stubs (ui_headless_stubs.c), so
|
||||
* the periodic UI refresh in the maintenance pass is pure work for nothing on
|
||||
* them. Guard the hot path on a real ui_* implementation being linked; the
|
||||
* one-shot calls at init and in the CLI reply path stay unguarded, matching
|
||||
* the rest of the file.
|
||||
*
|
||||
* This MUST mirror the CMake condition that selects the stubs, not the display
|
||||
* devicetree node: ZEPHCORE_UI_DESIGN_BUTTON is enabled by BUTTONS *or*
|
||||
* DISPLAY *or* BUZZER, so a board with buttons/buzzer and no panel still links
|
||||
* the real UI and still needs these updates. */
|
||||
#define ZEPHCORE_HAS_UI (IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_BUTTON) || \
|
||||
IS_ENABLED(CONFIG_ZEPHCORE_UI_DESIGN_JOYSTICK))
|
||||
|
||||
/* Radio + mesh includes (shared header selects LR1110 or SX126x) */
|
||||
#include <mesh/RadioIncludes.h>
|
||||
|
||||
@@ -85,15 +98,26 @@ static const struct gpio_dt_spec led1 = GPIO_DT_SPEC_GET(LED1_NODE, gpios);
|
||||
#define MESH_EVENT_LORA_RX BIT(0) /* LoRa packet received */
|
||||
#define MESH_EVENT_LORA_TX_DONE BIT(1) /* LoRa TX complete */
|
||||
#define MESH_EVENT_CLI_RX BIT(2) /* CLI command received */
|
||||
#define MESH_EVENT_HOUSEKEEPING BIT(3) /* Periodic housekeeping (noise floor, etc.) */
|
||||
#define MESH_EVENT_MAINTENANCE BIT(3) /* A maintenance deadline came due */
|
||||
#define MESH_EVENT_GPS_ACTION BIT(4) /* GPS state change (must run on main thread!) */
|
||||
#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_HOUSEKEEPING | MESH_EVENT_GPS_ACTION | MESH_EVENT_TX_DRAIN | MESH_EVENT_RTC_SAVE | MESH_EVENT_INIT_ADVERT)
|
||||
#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)
|
||||
|
||||
/* Housekeeping interval - infrequent to preserve power savings */
|
||||
#define HOUSEKEEPING_INTERVAL_MS CONFIG_ZEPHCORE_HOUSEKEEPING_INTERVAL_MS
|
||||
/* Maintenance is deadline-driven, not periodic: after every pass the loop asks
|
||||
* the mesh when its soonest pending deadline is (msUntilNextMaintenance) and
|
||||
* arms a single one-shot wake for exactly that moment. An idle repeater with
|
||||
* nothing scheduled therefore sleeps until its next real deadline instead of
|
||||
* waking on a fixed cadence.
|
||||
*
|
||||
* MAINTENANCE_BACKSTOP_MS bounds that: it caps how long we will go without a
|
||||
* pass even when everything reports idle, so a deadline that is missed or
|
||||
* mis-reported degrades to the old behaviour instead of wedging.
|
||||
* MAINTENANCE_MIN_MS floors it, so an item that is due-but-blocked (radio
|
||||
* mid-packet, duty-cycle sleep window) re-arms shortly rather than spinning. */
|
||||
#define MAINTENANCE_BACKSTOP_MS CONFIG_ZEPHCORE_MAINTENANCE_BACKSTOP_MS
|
||||
#define MAINTENANCE_MIN_MS 50
|
||||
|
||||
/* Event object for mesh loop */
|
||||
static struct k_event mesh_events;
|
||||
@@ -128,15 +152,16 @@ K_MSGQ_DEFINE(cli_cmd_queue, sizeof(struct cli_cmd_line), 4, 4);
|
||||
|
||||
/* Work items for event-driven processing */
|
||||
static void cli_rx_work_fn(struct k_work *work);
|
||||
static void housekeeping_timer_fn(struct k_timer *timer);
|
||||
static void maintenance_timer_fn(struct k_timer *timer);
|
||||
static void tx_drain_work_fn(struct k_work *work);
|
||||
static void initial_advert_work_fn(struct k_work *work);
|
||||
K_WORK_DEFINE(cli_rx_work, cli_rx_work_fn);
|
||||
K_WORK_DELAYABLE_DEFINE(tx_drain_work, tx_drain_work_fn);
|
||||
K_WORK_DELAYABLE_DEFINE(initial_advert_work, initial_advert_work_fn);
|
||||
|
||||
/* Housekeeping timer for periodic tasks (noise floor calibration, etc.) */
|
||||
K_TIMER_DEFINE(housekeeping_timer, housekeeping_timer_fn, NULL);
|
||||
/* One-shot maintenance wake, re-armed after every loop pass (see
|
||||
* arm_maintenance_wake). Not periodic — the period is the deadline. */
|
||||
K_TIMER_DEFINE(maintenance_timer, maintenance_timer_fn, NULL);
|
||||
|
||||
/* Forward declarations */
|
||||
#ifdef ZEPHCORE_LORA
|
||||
@@ -244,11 +269,43 @@ static void process_cli_commands(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Housekeeping timer callback - signals event to wake mesh loop periodically */
|
||||
static void housekeeping_timer_fn(struct k_timer *timer)
|
||||
/* Maintenance deadline expired — wake the mesh loop to run the pass. */
|
||||
static void maintenance_timer_fn(struct k_timer *timer)
|
||||
{
|
||||
ARG_UNUSED(timer);
|
||||
k_event_post(&mesh_events, MESH_EVENT_HOUSEKEEPING);
|
||||
k_event_post(&mesh_events, MESH_EVENT_MAINTENANCE);
|
||||
}
|
||||
|
||||
/* Ask the mesh for its soonest pending deadline and arm the one-shot for it.
|
||||
* Called after every loop pass, whatever woke us: any event may have created
|
||||
* or cleared a deadline (a queued advert, a CLI tempradio command, a CAD probe
|
||||
* that just ran), so the schedule is recomputed from scratch each time rather
|
||||
* than tracked incrementally.
|
||||
*
|
||||
* The backstop below is an unconditional CEILING on the wait, not a fallback
|
||||
* used only when everything reports idle. That means it must stay well above
|
||||
* the shortest legitimate recurring deadline (the noise-floor sampler, and the
|
||||
* CAD probe) or it becomes the effective period and the deadline scheduling
|
||||
* buys nothing — see ZEPHCORE_MAINTENANCE_BACKSTOP_MS. */
|
||||
static void arm_maintenance_wake(void)
|
||||
{
|
||||
uint32_t delay = MAINTENANCE_BACKSTOP_MS;
|
||||
|
||||
#ifdef ZEPHCORE_LORA
|
||||
if (repeater_mesh_ptr) {
|
||||
uint32_t next = repeater_mesh_ptr->msUntilNextMaintenance();
|
||||
|
||||
if (next < delay) {
|
||||
delay = next;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
if (delay < MAINTENANCE_MIN_MS) {
|
||||
delay = MAINTENANCE_MIN_MS;
|
||||
}
|
||||
|
||||
k_timer_start(&maintenance_timer, K_MSEC(delay), K_NO_WAIT);
|
||||
}
|
||||
|
||||
#ifdef ZEPHCORE_LORA
|
||||
@@ -398,9 +455,8 @@ static void repeater_event_loop(void)
|
||||
/* Print startup banner (no prompt - Arduino style) */
|
||||
cli_print("\r\n=== ZephCore Repeater ===\r\n");
|
||||
|
||||
/* Start housekeeping timer for periodic maintenance tasks */
|
||||
k_timer_start(&housekeeping_timer, K_MSEC(HOUSEKEEPING_INTERVAL_MS),
|
||||
K_MSEC(HOUSEKEEPING_INTERVAL_MS));
|
||||
/* Arm the first maintenance wake; every pass below re-arms it. */
|
||||
arm_maintenance_wake();
|
||||
|
||||
for (;;) {
|
||||
/* Wait for any mesh event - blocks until signaled */
|
||||
@@ -436,8 +492,8 @@ static void repeater_event_loop(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Periodic housekeeping — maintenance + display refresh */
|
||||
if (events & MESH_EVENT_HOUSEKEEPING) {
|
||||
/* A maintenance deadline came due — run the pass. */
|
||||
if (events & MESH_EVENT_MAINTENANCE) {
|
||||
#ifdef ZEPHCORE_LORA
|
||||
/* Radio maintenance: noise floor calibration, AGC reset,
|
||||
* RX watchdog. Separated from loop() so these never run
|
||||
@@ -452,16 +508,21 @@ static void repeater_event_loop(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if ZEPHCORE_HAS_UI
|
||||
ui_set_clock(rtc_clock.getCurrentTime());
|
||||
|
||||
#ifdef ZEPHCORE_LORA
|
||||
/* Refresh live radio state (noise floor, TX power
|
||||
* reduction, RX/TX mode, packet counters). */
|
||||
* reduction, RX/TX mode, packet counters). Headless
|
||||
* repeaters compile this out entirely — every ui_set_*
|
||||
* below it is a weak no-op there (ui_headless_stubs.c),
|
||||
* so the whole block was pure work for nothing. */
|
||||
refresh_repeater_ui_radio_state();
|
||||
|
||||
/* Battery is now refreshed lazily from ui_pages_render() with
|
||||
* a 30 s freshness guard — no periodic ADC fire here. */
|
||||
#endif
|
||||
#endif /* ZEPHCORE_HAS_UI */
|
||||
}
|
||||
|
||||
/* Off-main RTC write request (gps_fix_callback runs in modem_chat
|
||||
@@ -476,6 +537,11 @@ static void repeater_event_loop(void)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/* Re-arm for the soonest deadline this pass left behind. Done for
|
||||
* every wake, not just maintenance ones: a CLI command, an inbound
|
||||
* packet or a GPS fix can all create or clear a deadline. */
|
||||
arm_maintenance_wake();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user