diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index 1ba4f9ced..b37f4c753 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -73,8 +73,11 @@ an attempt on a busy channel. After a successful baseline, ordinary single-profile refresh waits five minutes. A rejected rise or failed attempt retains the last usable floor and -retries after one minute. Gain, tuning and AGC changes still request immediate -recalibration, so a configured short AGC interval can refresh more frequently. +retries after one minute. Gain/tuning changes and radio recovery still request +immediate recalibration. Adaptive AGC maintenance with an unchanged RF path +retains a valid baseline and its existing refresh deadline; it does not cancel +a pending collection or postpone a failed-sample retry. Legacy fixed-cadence +AGC resets still request recalibration. Dual-profile scanning keeps its existing timing and calibration policy. ESP32 timed sleep uses a millisecond timer capped at the next sample deadline. diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 90c8c04d4..a32bcf9a8 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -1040,10 +1040,10 @@ bool RadioLibWrapper::tryResetAGC() { // BUSY is expected during RXPS sleep. Reuse the legacy explicit wake/reset // sequence there; polling only every 50 ms can miss every short RX window. // Outside known duty cycling, a busy SPI command still defers admission. - return (_rx_ps_armed || !isChipBusy()) && resetAGCIfIdle(); + return (_rx_ps_armed || !isChipBusy()) && resetAGCIfIdle(false); } -bool RadioLibWrapper::resetAGCIfIdle() { +bool RadioLibWrapper::resetAGCIfIdle(bool refresh_floor) { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return false; #endif @@ -1053,11 +1053,20 @@ bool RadioLibWrapper::resetAGCIfIdle() { doResetAGC(); state = STATE_IDLE; // trigger a startReceive() - if (_rx_boosted_gain_valid) applyRxBoostedGainMode(_cur_rx_boosted_gain); + const bool gain_restored = !_rx_boosted_gain_valid + || applyRxBoostedGainMode(_cur_rx_boosted_gain); - // AGC reset discards stale samples, but the established floor remains usable - // for LBT while the next spaced block is collected. Gain/tuning changes use - // recalibrateNoiseFloor() directly to seed a fresh baseline. + // Adaptive maintenance restores the same RF path. A valid, idle baseline + // keeps its existing refresh deadline instead of spending another complete + // continuous-RX window on every reset. Never cancel a requested collection, + // postpone failed-sample retries, or change dual-profile scanning policy. + if (!refresh_floor && gain_restored && _noise_floor_valid + && !_profiles.enabled() && !_nf_refresh_requested && !_nf_calib_active) { + return true; + } + + // Recovery, legacy resets, changed/unknown paths and pending collections + // retain prompt resampling. Do not mix samples across the analog reset. const bool previous_valid = _noise_floor_valid; const bool previous_secondary_valid = _noise_floor_secondary_valid; recalibrateNoiseFloor(); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index e90e393b3..e8a82599d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -242,7 +242,7 @@ protected: void rxPsWatchdogCheck(); void requestPeriodicNoiseFloorRefresh(); void requestNoiseFloorRefresh(); - bool resetAGCIfIdle(); + bool resetAGCIfIdle(bool refresh_floor = true); void requestRestartRecv(); bool isPacketPendingOrReceiving(); void noiseFloorCalibCheck(unsigned long now); diff --git a/test/fixtures/noise_floor_sleep.cpp b/test/fixtures/noise_floor_sleep.cpp index 64b43c6ac..e8e6d3daa 100644 --- a/test/fixtures/noise_floor_sleep.cpp +++ b/test/fixtures/noise_floor_sleep.cpp @@ -157,6 +157,49 @@ int main(int argc, char** argv) { CHECK(w._nf_refresh_requested && !w._nf_lazy_refresh && !w._noise_floor_valid, "forced tuning/gain baseline must bypass lazy routine cadence"); } + } else if (!std::strcmp(scenario, "agc-idle")) { + for (unsigned mode : {0U, 1U, 2U}) for (bool lazy : {false, true}) + for (uint32_t last : {0U, 100U, UINT32_MAX - 2000U}) { + RadioLibWrapper w; + w._rx_ps_enabled = mode != 0; + w._rx_ps_continuous_fallback = mode == 2; + w._rx_ps_armed = mode == 1; + w._noise_floor_valid = true; + w._nf_refresh_requested = w._nf_calib_active = false; + w._nf_lazy_refresh = lazy; + w._nf_refresh_attempted = true; + w._nf_last_calib = last; + const uint32_t interval = lazy ? NF_LAZY_REFRESH_INTERVAL_MS : NF_CALIB_INTERVAL_MS; + for (uint32_t age : {1000U, 2000U, interval - 1}) { + now_ms = last + age; + w.requestPeriodicNoiseFloorRefresh(); + CHECK(w.tryResetAGC(), "idle maintenance must run the reset sequence"); + CHECK(!w._nf_refresh_requested && w._noise_floor_valid + && w._nf_lazy_refresh == lazy && w._nf_last_calib == last, + "maintenance must preserve the floor and original refresh deadline"); + w.startRecv(); + CHECK(w._rx_ps_armed == (mode == 1) && !w._nf_calib_active, + "maintenance must return immediately to the selected RX policy"); + } + now_ms = last + interval; + w.requestPeriodicNoiseFloorRefresh(); + CHECK(w._nf_refresh_requested, "maintenance postponed a routine refresh or failed retry"); + } + for (unsigned reason : {0U, 1U, 2U, 3U, 4U, 5U}) { + RadioLibWrapper w; + w._nf_refresh_requested = w._nf_calib_active = false; + w._noise_floor_valid = true; + w._nf_last_calib = now_ms; + if (reason == 0) w._noise_floor_valid = false; + if (reason == 1) w._nf_refresh_requested = true; + if (reason == 2) w._nf_calib_active = true; + if (reason == 3) w._profiles.secondary.mode = mesh::RadioProfileMode::RxTx; + if (reason == 5) { w._rx_boosted_gain_valid = true; w.gain_apply_success = false; } + if (reason == 4) w.resetAGC(); // Recovery/legacy uses the forced API. + else CHECK(w.tryResetAGC(), "idle reset was unexpectedly refused"); + CHECK(w._nf_refresh_requested && w._nf_last_calib == 0, + "startup, pending, dual-profile, failed gain and recovery floors must still refresh"); + } } else if (!std::strcmp(scenario, "timeout-zero")) { RadioLibWrapper w; // A legitimate 30-second timeout lands exactly on uint32_t zero. diff --git a/test/test_noise_floor_sleep.py b/test/test_noise_floor_sleep.py index e21084e1a..cabf88202 100644 --- a/test/test_noise_floor_sleep.py +++ b/test/test_noise_floor_sleep.py @@ -39,6 +39,7 @@ class NoiseFloorSleepTests(unittest.TestCase): fixture = (ROOT / 'test/fixtures/noise_floor_sleep.cpp').read_text() variants = { 'production': getter, + 'agc_forces_floor': getter, 'old_batch_veto': getter[:getter.index('{') + 1] + ''' (void)now; if (!isCalibratingNoiseFloor()) return false; @@ -55,7 +56,9 @@ class NoiseFloorSleepTests(unittest.TestCase): for name, implementation in variants.items(): filename = name + '_' + family cpp, binary = work / (filename + '.cpp'), work / filename - cpp.write_text((harness + implementation + fixture).replace('\u2014', '-'), + variant_harness = harness.replace('resetAGCIfIdle(false)', 'resetAGCIfIdle()') \ + if name == 'agc_forces_floor' else harness + cpp.write_text((variant_harness + implementation + fixture).replace('\u2014', '-'), encoding='ascii') built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', '-O1', '-g', '-Wall', '-Wextra', '-Werror', @@ -96,6 +99,13 @@ class NoiseFloorSleepTests(unittest.TestCase): def test_timeout_timestamp_zero_is_not_an_unarmed_or_startup_sentinel(self): self.run_case('timeout-zero') + def test_adaptive_agc_keeps_valid_floor_cadence_but_not_changed_paths(self): + self.run_case('agc-idle') + + def test_old_agc_forced_collection_negative_control(self): + self.run_case('agc-idle', 'agc_forces_floor', + 'maintenance must preserve the floor') + def test_old_whole_batch_sleep_veto_negative_control(self): self.run_case('quiet', 'old_batch_veto', 'pending batch must permit idle') diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index 265b3c5d7..9bc26fa9b 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -80,7 +80,7 @@ struct RadioLibWrapper { _noise_floor_secondary_valid = true; bool _nf_window_armed = false, _nf_lazy_refresh = false, _nf_refresh_attempted = false; bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false; - bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false; + bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false, gain_apply_success = true; bool _wd_last_busy = false; uint8_t _startrx_fails = 0, _rx_mode_failures = 0; uint32_t _rx_retry_at = 0; @@ -117,7 +117,7 @@ struct RadioLibWrapper { unsigned long getEstAirtimeFor(int) { return 10; } void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; } void doResetAGC() { ++agc_resets; } - bool applyRxBoostedGainMode(bool) { ++gain_changes; return true; } + bool applyRxBoostedGainMode(bool) { ++gain_changes; return gain_apply_success; } uint8_t beginReconfigure() { return 0; } // counted SPI/RX boundaries are separate void endReconfigure(bool) {} bool supportsRxPowerSaving() const { return true; } @@ -145,7 +145,7 @@ struct RadioLibWrapper { void requestNoiseFloorRefresh(); void requestPeriodicNoiseFloorRefresh(); void recalibrateNoiseFloor(); - bool resetAGCIfIdle(); + bool resetAGCIfIdle(bool refresh_floor = true); bool tryResetAGC(); bool getNoiseFloorWakeDelay(uint32_t, uint32_t&) const; void resetAGC();