From a4e96fa6e60a4d558452a18fbd61baa9752d464f Mon Sep 17 00:00:00 2001 From: liquidraver <504870+liquidraver@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:08:21 +0100 Subject: [PATCH] separate main events from housekeeping ones --- zephcore/adapters/radio/LoRaRadioBase.cpp | 82 ++++++++++++++++------- zephcore/adapters/radio/radio_common.h | 7 +- zephcore/include/mesh/Dispatcher.h | 3 +- zephcore/src/Dispatcher.cpp | 48 ++++++------- zephcore/src/main_companion.cpp | 20 +++--- zephcore/src/main_repeater.cpp | 16 ++++- 6 files changed, 116 insertions(+), 60 deletions(-) diff --git a/zephcore/adapters/radio/LoRaRadioBase.cpp b/zephcore/adapters/radio/LoRaRadioBase.cpp index 611fdb7..57c9a1f 100644 --- a/zephcore/adapters/radio/LoRaRadioBase.cpp +++ b/zephcore/adapters/radio/LoRaRadioBase.cpp @@ -7,6 +7,7 @@ #include "radio_common.h" #include #include +#include #include #include @@ -515,60 +516,93 @@ void LoRaRadioBase::triggerNoiseFloorCalibrate(int threshold) return; } - int16_t rssi = hwGetCurrentRSSI(); + /* Random delay 0-500 ms before sampling. Breaks phase-lock with + * periodic interference that might be synchronized with our fixed + * 5-second housekeeping cadence. */ + uint32_t jitter; + sys_rand_get(&jitter, sizeof(jitter)); + k_sleep(K_MSEC(jitter % 500)); - /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly - * and start warmup window where all samples are accepted. */ + /* Re-check after the delay — a packet may have arrived. */ + if (isReceiving()) { + return; + } + + /* Take multiple RSSI reads and use the minimum. The noise floor is + * the lowest ambient energy — any higher sample contains signal or + * interference. Min of N reads (~200 us) naturally rejects + * interference-contaminated samples. */ + int16_t rssi = hwGetCurrentRSSI(); + for (int i = 1; i < NOISE_FLOOR_SAMPLES_PER_TICK; i++) { + int16_t s = hwGetCurrentRSSI(); + if (s < rssi) { + rssi = s; + } + } + + /* First sample after reset (DEFAULT_NOISE_FLOOR == 0): seed directly. */ if (_noise_floor == DEFAULT_NOISE_FLOOR) { _noise_floor = rssi; if (_noise_floor < -120) _noise_floor = -120; if (_noise_floor > -50) _noise_floor = -50; - _ema_unguarded = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 ticks */ + _ema_unguarded = 0; LOG_DBG("noise_floor_cal: seed=%d", _noise_floor); return; } - /* Threshold filter with periodic unguarded samples. - * During warmup (after seed/reset), all samples are accepted so the - * EMA converges quickly. After warmup, every Nth tick (N = EMA window) - * one sample bypasses the filter so the floor can track sustained - * upward shifts (new interference source, antenna change, etc.). - * The EMA's 1/8 weight naturally dampens isolated spikes. */ - if (_ema_unguarded > 0) { - _ema_unguarded--; - } else if (rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { + /* Threshold filter with warmup and periodic bypass. + * + * _ema_unguarded counts up from 0 on every tick. + * Ticks 0..N-1 (warmup): all samples accepted for fast convergence + * after seed/reset — prevents a bad seed from locking out the + * real noise floor via a too-tight threshold. + * Ticks N+: threshold filter active. Every Nth tick (when the low + * bits are zero) one sample bypasses the filter so the floor can + * track sustained upward shifts (new interference, antenna change). + * The EMA's 1/8 weight naturally dampens isolated spikes. */ + const int N = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 */ + bool warmup = (_ema_unguarded < N); + bool periodic = (!warmup && (_ema_unguarded & (N - 1)) == 0); + _ema_unguarded++; /* wraps at 255 — harmless */ + + if (!warmup && !periodic && + rssi >= _noise_floor + NOISE_FLOOR_SAMPLING_THRESHOLD) { return; } - /* Reload: next unguarded sample in N ticks. */ - if (_ema_unguarded == 0) { - _ema_unguarded = (1 << NOISE_FLOOR_EMA_SHIFT); /* 8 */ - } - /* EMA: floor += round_nearest((sample - floor) / 8). + /* EMA: floor += round_nearest((sample - floor) / N). * Plain >> has downward bias (-1>>3 == -1 but +1>>3 == 0). * Plain / has a ±7 dead zone (small drifts ignored). * Round-to-nearest: add half the divisor before dividing, * with sign-aware bias so both directions are symmetric. */ int diff = rssi - _noise_floor; - int half = (1 << NOISE_FLOOR_EMA_SHIFT) / 2; /* 4 */ - int step = (diff + (diff > 0 ? half : -half)) / (1 << NOISE_FLOOR_EMA_SHIFT); + int half = N / 2; /* 4 */ + int step = (diff + (diff > 0 ? half : -half)) / N; _noise_floor += step; if (_noise_floor < -120) _noise_floor = -120; if (_noise_floor > -50) _noise_floor = -50; - LOG_DBG("noise_floor_cal: rssi=%d, floor=%d", rssi, _noise_floor); + LOG_DBG("noise_floor_cal: rssi=%d, floor=%d, tick=%u", + rssi, _noise_floor, _ema_unguarded - 1); } void LoRaRadioBase::resetAGC() { - /* Don't reset AGC while actively receiving a packet — warm sleep would - * corrupt it. The Dispatcher will retry next interval. */ - if (isReceiving()) { + /* 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. */ + if (_tx_active || isReceiving()) { return; } hwResetAGC(); + /* Warm sleep + calibrate leaves the radio in STANDBY. + * Restart receive if we were in RX mode. */ + if (_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. */ diff --git a/zephcore/adapters/radio/radio_common.h b/zephcore/adapters/radio/radio_common.h index f7c6e94..3089876 100644 --- a/zephcore/adapters/radio/radio_common.h +++ b/zephcore/adapters/radio/radio_common.h @@ -12,11 +12,14 @@ #include /* --- Noise floor calibration (EMA) --- - * Single RSSI sample per tick, smoothed with exponential moving average. - * alpha = 1/8 (bit-shiftable): new_floor = floor + (sample - floor) / 8 + * Takes SAMPLES_PER_TICK RSSI reads (~100 us), feeds the minimum into an + * exponential moving average. Using min naturally rejects interference + * spikes — the noise floor is the lowest ambient energy in the band. + * alpha = 1/8: new_floor = floor + round((sample - floor) / 8) * Convergence: ~8 ticks (~40s at 5s housekeeping) to track a step change. * Samples above floor + SAMPLING_THRESHOLD are rejected (interference). */ #define NOISE_FLOOR_EMA_SHIFT 3 /* alpha = 1 / (1 << 3) = 1/8 */ +#define NOISE_FLOOR_SAMPLES_PER_TICK 4 /* min of 4 RSSI reads per tick */ #define NOISE_FLOOR_SAMPLING_THRESHOLD 14 /* only sample if rssi < floor + threshold */ #define DEFAULT_NOISE_FLOOR 0 /* accept all samples until first update */ diff --git a/zephcore/include/mesh/Dispatcher.h b/zephcore/include/mesh/Dispatcher.h index 8c88c18..7b8d4f4 100644 --- a/zephcore/include/mesh/Dispatcher.h +++ b/zephcore/include/mesh/Dispatcher.h @@ -87,7 +87,7 @@ class Dispatcher { uint32_t cad_busy_start; DutyCycleTracker _duty_cycle; uint32_t radio_nonrx_start; - uint32_t next_floor_calib_time, next_agc_reset_time; + uint32_t next_agc_reset_time; bool prev_isrecv_mode; uint32_t n_sent_flood, n_sent_direct; uint32_t n_recv_flood, n_recv_direct; @@ -120,6 +120,7 @@ protected: public: void begin(); void loop(); + void maintenanceLoop(); Packet *obtainNewPacket(); void releasePacket(Packet *packet); void sendPacket(Packet *packet, uint8_t priority, uint32_t delay_millis = 0); diff --git a/zephcore/src/Dispatcher.cpp b/zephcore/src/Dispatcher.cpp index 4f63ec5..95dab5a 100644 --- a/zephcore/src/Dispatcher.cpp +++ b/zephcore/src/Dispatcher.cpp @@ -22,7 +22,6 @@ LOG_MODULE_REGISTER(zephcore_dispatcher, CONFIG_ZEPHCORE_LORA_LOG_LEVEL); namespace mesh { #define MAX_RX_DELAY_MILLIS 32000 -#define NOISE_FLOOR_CALIB_INTERVAL 2000 Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr) : _radio(&radio), _ms(&ms), _mgr(&mgr) @@ -31,7 +30,7 @@ Dispatcher::Dispatcher(Radio &radio, MillisecondClock &ms, PacketManager &mgr) total_air_time = rx_air_time = 0; next_tx_time = 0; cad_busy_start = 0; - next_floor_calib_time = next_agc_reset_time = 0; + next_agc_reset_time = 0; _err_flags = 0; _duty_cycle.init(0); radio_nonrx_start = 0; @@ -94,22 +93,6 @@ uint32_t Dispatcher::getCADFailMaxDuration() const void Dispatcher::loop() { - if (millisHasNowPassed(next_floor_calib_time)) { - _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); - next_floor_calib_time = futureMillis(NOISE_FLOOR_CALIB_INTERVAL); - } - - bool is_recv = _radio->isInRecvMode(); - if (is_recv != prev_isrecv_mode) { - prev_isrecv_mode = is_recv; - if (!is_recv) { - radio_nonrx_start = (uint32_t)_ms->getMillis(); - } - } - if (!is_recv && (uint32_t)_ms->getMillis() - radio_nonrx_start > 8000) { - _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; - } - if (outbound) { if (_radio->isSendComplete()) { uint32_t t = (uint32_t)_ms->getMillis() - outbound_start; @@ -135,11 +118,6 @@ void Dispatcher::loop() next_agc_reset_time = futureMillis(getAGCResetInterval()); } - if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) { - _radio->resetAGC(); - next_agc_reset_time = futureMillis(getAGCResetInterval()); - } - { Packet *pkt = _mgr->getNextInbound((uint32_t)_ms->getMillis()); if (pkt) { @@ -150,6 +128,30 @@ void Dispatcher::loop() checkSend(); } +void Dispatcher::maintenanceLoop() +{ + /* Noise floor calibration — one EMA tick per housekeeping cycle */ + _radio->triggerNoiseFloorCalibrate(getInterferenceThreshold()); + + /* RX mode watchdog — detect if radio is stuck outside RX */ + bool is_recv = _radio->isInRecvMode(); + if (is_recv != prev_isrecv_mode) { + prev_isrecv_mode = is_recv; + if (!is_recv) { + radio_nonrx_start = (uint32_t)_ms->getMillis(); + } + } + if (!is_recv && (uint32_t)_ms->getMillis() - radio_nonrx_start > 8000) { + _err_flags |= ERR_EVENT_STARTRX_TIMEOUT; + } + + /* AGC reset — periodic warm sleep + recalibration */ + if (getAGCResetInterval() > 0 && millisHasNowPassed(next_agc_reset_time)) { + _radio->resetAGC(); + next_agc_reset_time = futureMillis(getAGCResetInterval()); + } +} + bool Dispatcher::tryParsePacket(Packet *pkt, const uint8_t *raw, int len) { int i = 0; diff --git a/zephcore/src/main_companion.cpp b/zephcore/src/main_companion.cpp index 963cd11..31eea92 100644 --- a/zephcore/src/main_companion.cpp +++ b/zephcore/src/main_companion.cpp @@ -288,18 +288,22 @@ static void mesh_event_loop(void) gps_process_event(); } - /* Run mesh loop - handles all pending work: - * - Process received LoRa packets - * - Check TX completion - * - Timeout handling - * - Noise floor calibration (runs when loop is called) - */ - if (companion_mesh_ptr) { + /* Packet processing — only on radio/BLE/TX events */ + if (companion_mesh_ptr && + (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | + MESH_EVENT_BLE_RX | MESH_EVENT_TX_DRAIN))) { companion_mesh_ptr->loop(); } - /* Periodic UI refresh (every housekeeping cycle = 5s) */ + /* Periodic housekeeping — maintenance + UI refresh */ if (events & MESH_EVENT_HOUSEKEEPING) { + /* Radio maintenance: noise floor calibration, AGC reset, + * RX watchdog. Separated from loop() so these never run + * on packet-driven events. */ + if (companion_mesh_ptr) { + companion_mesh_ptr->maintenanceLoop(); + } + mesh_housekeeping_ui_refresh(); } #endif diff --git a/zephcore/src/main_repeater.cpp b/zephcore/src/main_repeater.cpp index 22809e6..16a3ef6 100644 --- a/zephcore/src/main_repeater.cpp +++ b/zephcore/src/main_repeater.cpp @@ -328,13 +328,25 @@ static void repeater_event_loop(void) } #ifdef ZEPHCORE_LORA - if (repeater_mesh_ptr) { + /* Packet processing — only on radio/CLI/TX events */ + if (repeater_mesh_ptr && + (events & (MESH_EVENT_LORA_RX | MESH_EVENT_LORA_TX_DONE | + MESH_EVENT_CLI_RX | MESH_EVENT_TX_DRAIN))) { repeater_mesh_ptr->loop(); } #endif - /* Periodic housekeeping — update display with live data */ + /* Periodic housekeeping — maintenance + display refresh */ if (events & MESH_EVENT_HOUSEKEEPING) { +#ifdef ZEPHCORE_LORA + /* Radio maintenance: noise floor calibration, AGC reset, + * RX watchdog. Separated from loop() so these never run + * on packet-driven events. */ + if (repeater_mesh_ptr) { + repeater_mesh_ptr->maintenanceLoop(); + } +#endif + ui_set_clock(rtc_clock.getCurrentTime()); #ifdef ZEPHCORE_LORA