get rid of housekeeping tick and AGC reset

This commit is contained in:
liquidraver
2026-07-28 14:12:40 +02:00
parent 394eefec51
commit fe6e585eb6
30 changed files with 524 additions and 166 deletions
+56
View File
@@ -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
+2 -2
View File
@@ -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 |
-6
View File
@@ -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);
-1
View File
@@ -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;
-5
View File
@@ -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);
-1
View File
@@ -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;
+81 -31
View File
@@ -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();
+13 -2
View File
@@ -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();
-6
View File
@@ -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);
-1
View File
@@ -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;
-19
View File
@@ -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 */
-9
View File
@@ -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 */
};
+24 -1
View File
@@ -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 */
+42
View File
@@ -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;
+7 -6
View File
@@ -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;
};
-6
View File
@@ -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;
}
+9 -10
View File
@@ -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;
+12
View File
@@ -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. */
+4 -1
View File
@@ -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
+18
View File
@@ -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 -4
View File
@@ -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);
+50
View File
@@ -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 */
+1
View File
@@ -80,6 +80,7 @@ public:
void begin();
void loop();
void maintenanceLoop();
uint32_t msUntilNextMaintenance() override;
LocalIdentity self_id;
+6 -1
View File
@@ -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 */
+65 -4
View File
@@ -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
View File
@@ -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;
+8
View File
@@ -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();
-15
View File
@@ -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 */
+82 -16
View File
@@ -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();
}
}