diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 974c18b55..382d152bc 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -493,6 +493,7 @@ jobs: python3 -B test/test_radio_profile_spiffs_missing.py -v python3 -B test/test_radio_reply_mutation.py python3 -B test/test_radio_profile_scan.py + python3 -B test/test_radio_profile_runtime_recommendation.py -v python3 -B test/test_radio_profile_idle_dispatcher.py -v python3 -B test/test_radio_rxps_rejection.py -v python3 -B test/test_repeater_radio_timing_integration.py diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index ed06ecdc8..c25f2ae64 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -868,6 +868,19 @@ The rapid startup hops briefly interrupt reception on each channel; a new packet beginning during this test may be missed. An already detected packet defers the test instead of being interrupted. +Displayed preamble recommendations and warnings wait until the active pair has +run for at least two minutes, measured from its first successful RX-to-RX hop. +Four successful hops in each direction are still required. A pair change starts +a fresh wait; an unchanged or rejected request does not. Use a temporary lease +longer than two minutes if you want its recommendation before it expires. + +This is a display-only delay. The bounded startup test, guarded maximum switch +budget, automatic preambles, ordinary scanning and MCU-idle behavior do not wait +two minutes. Live switch and scan diagnostics remain available, marked pending +with `wait=Ns` until advice is ready. Waiting does not discard an early maximum +or replace it with an average. No extra retunes are forced at the deadline; a +successful normal hop completes the display wait. + Each idle scan starts with **4.6 symbols on the slower profile**, then spends the remaining budgeted time on the faster profile, also with a **4.6-symbol floor**. Slower @@ -911,8 +924,9 @@ receiver's setting does not lengthen packets sent by other nodes. `get radio2.timing` (alias `get radio.timing`) reports the active pair in `radio,radio2` order: dwell in chirps, recommended preambles in symbols, and -the switching/loop allowances. Before calibration it reports `timing self-test -pending` and the provisional preambles. On nRF52 boards, enabling `radio2` +the switching/loop allowances. During the two-minute advice wait it reports +`timing self-test pending`, the remaining wait and the current provisional +switch allowance/preambles, without a final `need=` recommendation. On nRF52 boards, enabling `radio2` uses RTC2 for both retune timing and scan visits (about 31 us resolution); turning `radio2` off stops RTC2 and returns to the usual `micros()` clock. Previously, the nRF52 `micros()` fallback counted 1,024 Hz RTOS ticks: a real diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index 82e29096c..986a7836c 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -55,6 +55,7 @@ class RadioProfiles { // the self-tested, guarded switch time in the idle-scan timing model. static constexpr uint32_t LoopBudgetUs = 0; static constexpr uint8_t SwitchTestSamplesPerDirection = 4; + static constexpr uint32_t SwitchRecommendationWindowMs = 120000; struct ChirpTiming { bool valid = false; @@ -83,20 +84,41 @@ class RadioProfiles { uint32_t longest_switch_us = 0; uint32_t switch_test_max_us[2] = {}; uint8_t switch_test_samples[2] = {}; + uint32_t switch_runtime_started_ms = 0; + bool switch_runtime_started = false; + bool switch_runtime_ready = false; void resetSwitchTest() { switch_test_max_us[0] = switch_test_max_us[1] = 0; switch_test_samples[0] = switch_test_samples[1] = 0; + switch_runtime_started_ms = 0; + switch_runtime_started = switch_runtime_ready = false; } - void sampleSwitch(uint8_t from, uint8_t to, uint32_t elapsed_us) { + void sampleSwitch(uint8_t from, uint8_t to, uint32_t elapsed_us, uint32_t now_ms = 0) { if (!enabled() || from > 1 || to > 1 || from == to || !elapsed_us) return; if (switch_test_samples[from] < SwitchTestSamplesPerDirection) ++switch_test_samples[from]; if (elapsed_us > switch_test_max_us[from]) switch_test_max_us[from] = elapsed_us; + // Delay only the displayed recommendation. Startup readiness, guarded + // maxima, automatic preambles and scanner behavior remain unchanged. + if (!switch_runtime_started) { + switch_runtime_started = true; + switch_runtime_started_ms = now_ms; + } + if (switchTestReady() + && uint32_t(now_ms - switch_runtime_started_ms) >= SwitchRecommendationWindowMs) + switch_runtime_ready = true; } bool switchTestReady() const { return switch_test_samples[0] >= SwitchTestSamplesPerDirection && switch_test_samples[1] >= SwitchTestSamplesPerDirection; } + bool recommendationReady() const { return switch_runtime_ready; } + uint32_t recommendationRemainingMs(uint32_t now_ms) const { + if (recommendationReady()) return 0; + if (!switch_runtime_started) return SwitchRecommendationWindowMs; + const uint32_t elapsed = now_ms - switch_runtime_started_ms; + return elapsed < SwitchRecommendationWindowMs ? SwitchRecommendationWindowMs - elapsed : 0; + } uint32_t switchBudgetUs() const { const uint32_t observed = switch_test_max_us[0] > switch_test_max_us[1] ? switch_test_max_us[0] : switch_test_max_us[1]; diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 2f014bc2e..606c6b81a 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -630,9 +630,11 @@ void RadioProfileCLI::appendPrimaryChirpWarning(char* reply, size_t capacity, ui void RadioProfileCLI::appendChirpWarning(char* reply, size_t capacity, const RadioProfiles& preview) { if (!capacity || !preview.enabled()) return; - if (!preview.switchTestReady()) { + if (!preview.recommendationReady()) { const size_t used = strlen(reply); - if (used < capacity) snprintf(reply + used, capacity - used, "; timing self-test pending"); + if (used < capacity) snprintf(reply + used, capacity - used, + "; timing self-test pending; wait=%lus", + (unsigned long)((preview.recommendationRemainingMs(millis()) + 999) / 1000)); return; } const auto timing = preview.chirpTiming(); @@ -761,10 +763,12 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, const auto& p = *radio_->profiles(); const auto t = p.chirpTiming(); if (!p.enabled()) snprintf(reply, capacity, "> off"); - else if (!p.switchTestReady()) snprintf(reply, capacity, - "> timing self-test pending; test=%u/%u,%u/%u; provisional preamble=%u,%u", + else if (!p.recommendationReady()) snprintf(reply, capacity, + "> timing self-test pending; test=%u/%u,%u/%u; wait=%lus; provisional switch=%luus preamble=%u,%u", p.switch_test_samples[0], RadioProfiles::SwitchTestSamplesPerDirection, p.switch_test_samples[1], RadioProfiles::SwitchTestSamplesPerDirection, + (unsigned long)((p.recommendationRemainingMs(millis()) + 999) / 1000), + (unsigned long)p.switchBudgetUs(), radio_->profilePreamble(0), radio_->profilePreamble(1)); else if (!t.valid) snprintf(reply, capacity, "Error: timing unavailable"); else { @@ -823,7 +827,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (used < capacity) snprintf(reply + used, capacity - used, "; test=%u/%u,%u/%u%s budget=%luus", p.switch_test_samples[0], RadioProfiles::SwitchTestSamplesPerDirection, p.switch_test_samples[1], RadioProfiles::SwitchTestSamplesPerDirection, - p.switchTestReady() ? " ready" : " pending", (unsigned long)p.switchBudgetUs()); + p.recommendationReady() ? " ready" : " pending", (unsigned long)p.switchBudgetUs()); appendChirpWarning(reply, capacity, p); } return true; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 021a561d2..65b12cac7 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -562,7 +562,7 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { // then continue raising the observed maximum as later good hops run. // Deferred and rolled-back operations are not samples. if (applied && resume && old_profile != profile) { - _profiles.sampleSwitch(old_profile, profile, elapsed); + _profiles.sampleSwitch(old_profile, profile, elapsed, millis()); } return applied ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; } diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index f3c3bedd6..c0abcb5ea 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -43,11 +43,14 @@ struct Fixture { } void advance(uint32_t ms, bool epoch=true) { g_mock_millis+=ms; if(epoch)clock.epoch+=ms/1000; cli.loop(); } void finishTimingTest(uint32_t switch_us=545) { + const uint32_t start = g_mock_millis - mesh::RadioProfiles::SwitchRecommendationWindowMs; for (unsigned i=0;i\n#include \n") + cpp.write_text(source, encoding="ascii") + built = subprocess.run([os.environ.get("CXX", "g++"), "-std=c++17", + "-Wall", "-Wextra", "-fsanitize=address,undefined", "-no-pie", + "-fno-omit-frame-pointer", "-I", folder, "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary)], capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True) + self.assertEqual(ran.returncode, 0, ran.stderr) + + def test_clock_boundaries_resets_and_unchanged_maximum_budget(self): + self.compile_run(r''' +#include +#include +#include +#include +using P=mesh::RadioProfiles; +P pair() { + P p;p.primary={909.5f,500,0,7,5};p.secondary.params=p.primary; + p.secondary.params.freq=910.5f;p.secondary.mode=mesh::RadioProfileMode::Rx; + return p; +} +void train(P& p,uint32_t now,uint32_t a=100,uint32_t b=100) { + for(unsigned n=0;n