diff --git a/zephcore/Kconfig b/zephcore/Kconfig index 590a930..ed39b0e 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -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 diff --git a/zephcore/Repeater_CLI_commands.md b/zephcore/Repeater_CLI_commands.md index 0257d16..8825705 100644 --- a/zephcore/Repeater_CLI_commands.md +++ b/zephcore/Repeater_CLI_commands.md @@ -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 ` | | Set guest access password | | `set owner.info ` | Use `\|` for newlines | Owner/contact information | | `set int.thresh ` | | Interference detection threshold | -| `set agc.reset.interval ` | Rounded to 4 s | AGC reset interval | +| `set agc.reset.interval ` | Accepted, ignored | Removed - replies `use rxduty instead` | | `set multi.acks <0\|1>` | | Enable extra ACK transmits | | `set path.hash.mode ` | 0, 1, or 2 | Path hashing algorithm | | `set loop.detect ` | `off`, `minimal`, `moderate`, `strict` | Loop detection sensitivity | diff --git a/zephcore/adapters/radio/LR1110Radio.cpp b/zephcore/adapters/radio/LR1110Radio.cpp index 2a4f8a0..5761fcf 100644 --- a/zephcore/adapters/radio/LR1110Radio.cpp +++ b/zephcore/adapters/radio/LR1110Radio.cpp @@ -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); diff --git a/zephcore/adapters/radio/LR1110Radio.h b/zephcore/adapters/radio/LR1110Radio.h index e5eeba6..e31ff99 100644 --- a/zephcore/adapters/radio/LR1110Radio.h +++ b/zephcore/adapters/radio/LR1110Radio.h @@ -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; diff --git a/zephcore/adapters/radio/LR2021Radio.cpp b/zephcore/adapters/radio/LR2021Radio.cpp index 014d1b6..1dfef66 100644 --- a/zephcore/adapters/radio/LR2021Radio.cpp +++ b/zephcore/adapters/radio/LR2021Radio.cpp @@ -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); diff --git a/zephcore/adapters/radio/LR2021Radio.h b/zephcore/adapters/radio/LR2021Radio.h index 916728e..2c4002f 100644 --- a/zephcore/adapters/radio/LR2021Radio.h +++ b/zephcore/adapters/radio/LR2021Radio.h @@ -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; diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 6b82107..eb75364 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -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(); diff --git a/zephcore/adapters/radio/LoRaRadioBase.h b/zephcore/adapters/radio/LoRaRadioBase.h index e21a166..cab3766 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.h +++ b/zephcore/adapters/radio/LoRaRadioBase.h @@ -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(); diff --git a/zephcore/adapters/radio/SX126xRadio.cpp b/zephcore/adapters/radio/SX126xRadio.cpp index 7501600..8931679 100644 --- a/zephcore/adapters/radio/SX126xRadio.cpp +++ b/zephcore/adapters/radio/SX126xRadio.cpp @@ -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); diff --git a/zephcore/adapters/radio/SX126xRadio.h b/zephcore/adapters/radio/SX126xRadio.h index 645e26e..31754f2 100644 --- a/zephcore/adapters/radio/SX126xRadio.h +++ b/zephcore/adapters/radio/SX126xRadio.h @@ -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; diff --git a/zephcore/adapters/radio/SX127xRadio.cpp b/zephcore/adapters/radio/SX127xRadio.cpp index 95ed98d..a3b15fd 100644 --- a/zephcore/adapters/radio/SX127xRadio.cpp +++ b/zephcore/adapters/radio/SX127xRadio.cpp @@ -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 */ diff --git a/zephcore/adapters/radio/SX127xRadio.h b/zephcore/adapters/radio/SX127xRadio.h index a8779bb..1787f49 100644 --- a/zephcore/adapters/radio/SX127xRadio.h +++ b/zephcore/adapters/radio/SX127xRadio.h @@ -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 */ }; diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index e968406..ce2b85b 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -12,7 +12,7 @@ #include /* --- 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 */ diff --git a/zephcore/app/RepeaterMesh.cpp b/zephcore/app/RepeaterMesh.cpp index 0e7ab7d..cf1e171 100644 --- a/zephcore/app/RepeaterMesh.cpp +++ b/zephcore/app/RepeaterMesh.cpp @@ -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; diff --git a/zephcore/app/RepeaterMesh.h b/zephcore/app/RepeaterMesh.h index aa76001..ca6fb29 100644 --- a/zephcore/app/RepeaterMesh.h +++ b/zephcore/app/RepeaterMesh.h @@ -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; }; diff --git a/zephcore/app/RoomServerMesh.h b/zephcore/app/RoomServerMesh.h index faef6c3..5bdee41 100644 --- a/zephcore/app/RoomServerMesh.h +++ b/zephcore/app/RoomServerMesh.h @@ -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; } diff --git a/zephcore/helpers/CommonCLI.cpp b/zephcore/helpers/CommonCLI.cpp index b613bdb..ee36a4f 100644 --- a/zephcore/helpers/CommonCLI.cpp +++ b/zephcore/helpers/CommonCLI.cpp @@ -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; diff --git a/zephcore/helpers/MeshTimeSync.h b/zephcore/helpers/MeshTimeSync.h index 29df9a7..fce9385 100644 --- a/zephcore/helpers/MeshTimeSync.h +++ b/zephcore/helpers/MeshTimeSync.h @@ -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. */ diff --git a/zephcore/helpers/NodePrefs.h b/zephcore/helpers/NodePrefs.h index c5e4142..cb3144b 100644 --- a/zephcore/helpers/NodePrefs.h +++ b/zephcore/helpers/NodePrefs.h @@ -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 diff --git a/zephcore/include/mesh/ContentionTracker.h b/zephcore/include/mesh/ContentionTracker.h index 007e1e8..5d42fd3 100644 --- a/zephcore/include/mesh/ContentionTracker.h +++ b/zephcore/include/mesh/ContentionTracker.h @@ -9,6 +9,7 @@ #pragma once #include +#include 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); diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index 932dd5b..b6d1781 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -11,6 +11,7 @@ #include #include #include +#include #include 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); diff --git a/zephcore/include/mesh/Maintenance.h b/zephcore/include/mesh/Maintenance.h new file mode 100644 index 0000000..2252e6d --- /dev/null +++ b/zephcore/include/mesh/Maintenance.h @@ -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 + +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 */ diff --git a/zephcore/include/mesh/Mesh.h b/zephcore/include/mesh/Mesh.h index 6f99d74..f116550 100644 --- a/zephcore/include/mesh/Mesh.h +++ b/zephcore/include/mesh/Mesh.h @@ -80,6 +80,7 @@ public: void begin(); void loop(); void maintenanceLoop(); + uint32_t msUntilNextMaintenance() override; LocalIdentity self_id; diff --git a/zephcore/include/mesh/Radio.h b/zephcore/include/mesh/Radio.h index d4f8bf3..6ac8692 100644 --- a/zephcore/include/mesh/Radio.h +++ b/zephcore/include/mesh/Radio.h @@ -6,6 +6,7 @@ #pragma once #include +#include 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 diff --git a/zephcore/include/mesh/StaticPoolPacketManager.h b/zephcore/include/mesh/StaticPoolPacketManager.h index a330316..895e263 100644 --- a/zephcore/include/mesh/StaticPoolPacketManager.h +++ b/zephcore/include/mesh/StaticPoolPacketManager.h @@ -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 */ diff --git a/zephcore/src/ContentionTracker.cpp b/zephcore/src/ContentionTracker.cpp index b819630..a3aaeeb 100644 --- a/zephcore/src/ContentionTracker.cpp +++ b/zephcore/src/ContentionTracker.cpp @@ -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 diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index 64ef6ab..1ef9817 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -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; diff --git a/zephcore/src/Mesh.cpp b/zephcore/src/Mesh.cpp index a14acfb..4144b58 100644 --- a/zephcore/src/Mesh.cpp +++ b/zephcore/src/Mesh.cpp @@ -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(); diff --git a/zephcore/src/StaticPoolPacketManager.cpp b/zephcore/src/StaticPoolPacketManager.cpp index 4bb8cf0..db5290d 100644 --- a/zephcore/src/StaticPoolPacketManager.cpp +++ b/zephcore/src/StaticPoolPacketManager.cpp @@ -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 */ diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 201f667..52ffbfd 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -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 @@ -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(); } }