diff --git a/docs/radio_chirp_math_validation.md b/docs/radio_chirp_math_validation.md index 15d85b96..f8ee7e48 100644 --- a/docs/radio_chirp_math_validation.md +++ b/docs/radio_chirp_math_validation.md @@ -1,5 +1,10 @@ # Shared radio/radio2 chirp timing and preamble warnings +Historical validation record: the nominal 600 us policy described below has +been replaced by the activation self-test and 10% guarded measured budget. +See [radio profiles](radio_profiles.md#preamble) for +current behavior. + Implemented in the `keymindCascade` working tree, 2026-09-14. The initial 4.1/4000-us implementation record below is retained as history; the [4.6/300-us policy update](#automatic-46-chirp--300-us-policy-update) is now current. diff --git a/docs/radio_dwell_policy_validation.md b/docs/radio_dwell_policy_validation.md index f1a0209f..692da929 100644 --- a/docs/radio_dwell_policy_validation.md +++ b/docs/radio_dwell_policy_validation.md @@ -1,5 +1,9 @@ # Automatic dwell policy: 4.6 chirps on both profiles +Historical validation record: the nominal 600 us switch allowance below has +been replaced by the activation self-test and a 10% guarded measured budget. +See [radio profiles](radio_profiles.md#preamble) for current behavior. + The shared `RadioProfiles` function uses a 4.6-symbol minimum for both logical profiles of one physical transceiver, rounded upward to a whole microsecond. The slower profile is selected by symbol duration, with primary winning ties. diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 2e60cd8f..536717ab 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -282,14 +282,27 @@ it automatically. Explicit values are used as entered, subject to the radio's limits and safe airtime arithmetic. Automatic dual-profile preambles round **up to a multiple of eight**, with a -32-symbol minimum. Each idle scan starts with **4.6 symbols on the slower +32-symbol minimum and a 128-symbol maximum. When a second profile activates, +the radio first self-tests four successful RX-to-RX switches in each direction +using the actual tuples. It defers the test while receiving, transmitting or +busy, and never counts a failed switch. The 128-symbol automatic preamble is +provisional until the test completes. The largest successful hop, rounded up +with a **10% guard**, then supplies the per-switch allowance for both the +automatic preambles and the scan dwell. There is no fixed switch-time floor. +The measurement is reset when either profile changes, including when a +`tempradio2` session starts or ends; it is not saved to flash. +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. + +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 means a longer LoRa symbol (`2^SF / bandwidth`), regardless of profile number. The slower preamble sets the return deadline. The calculation allows two slow -visits within that preamble, reserving 0.6 ms per switch and 0.3 ms of main-loop +visits within that preamble, reserving the self-tested switch time per hop and 0.3 ms of main-loop margin. Fast-channel transmit preambles include sixteen acquisition symbols. -For a primary SF7 / 62.5 kHz profile: +For a primary SF7 / 62.5 kHz profile with a *measured* 600 us guarded switch allowance: | Secondary profile | Automatic secondary preamble | Previously tested explicit preamble | Primary preamble | | --- | ---: | ---: | ---: | @@ -324,26 +337,42 @@ 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. For SF7/62.5 on `radio` and SF7/500 on `radio2`: +the switching/loop allowances. Before calibration it reports `timing self-test +pending` and the provisional preambles. For SF7/62.5 on `radio` and SF7/500 on +`radio2` after a 600 us guarded measurement: ```text -> chirps=4.60,85.34; need=32,64; switch=600us; loop=300us (estimate); WARN recommended preamble: radio2=64 +> chirps=4.60,85.34; need=32,64; switch=600us; loop=300us (estimate); test=4/4,4/4 ready; WARN recommended preamble: radio2=64 ``` `WARN recommended preamble` identifies **each** profile whose recommendation exceeds the standard 32 symbols or its explicitly selected value. For example, -with SF7/62.5 on the primary profile, `set radio2 910.5,500,7,5,rx,32` replies: +with SF7/62.5 on the primary profile, `set radio2 910.5,500,7,5,rx,32` +initially replies with `timing self-test pending`. Once active and measured, +`get radio2` warns: ```text -OK - radio2 rx; preamble=32; WARN recommended preamble: radio2=64; short override: radio2 +> 910.500,500.000,7,5,rx,32; WARN recommended preamble: radio2=64; short override: radio2 ``` Successful `set radio ...` replies also include the recommended preamble for each affected profile, calculated with the requested settings. A `short override` note identifies an explicit value below the estimate; it is not silently rewritten. Warnings are also included in profile settings/getters, scan info, -temporary settings and schedule acknowledgments. `off` disables the dual-profile -warning. A timing readout does not change settings or transmit a packet. +temporary settings and schedule acknowledgments. These preamble-bearing replies +also include `switch=547us` (or the current measured value) after the self-test +for that exact profile pair. The first ACK for a new pair says `timing self-test +pending` instead: it is sent before activation, so it cannot contain the future +measurement. Query `get radio2` after activation to see the measured value beside +the resulting preamble. `off` disables the dual-profile +warning. `get radio2.status` shows self-test progress and the resulting guarded +switch budget; its separate `max` field remains a lifetime diagnostic that can +include failed/rolled-back retunes and is not used for calibration. If the +recommendation exceeds 128, it additionally reports +`WARN need >128`; an automatic preamble stays at 128, which may not be enough +for reliable reception on that pair. Explicit preambles are not rewritten and +still use the radio's ordinary validation limits. A timing readout does not +change settings or transmit a packet. The shared `RadioProfiles::calculateChirpTiming()` function accepts both tuples, optional visit times in microseconds, a per-switch allowance and loop margin. @@ -380,14 +409,15 @@ For the tested SF7/62.5 + SF8/500 pair with slow preamble 32, this yields 9,421 us slow and 21,847 us fast (42.67 fast chirps). Swapping `radio` and `radio2` swaps the allocations automatically. The 0.3 ms reserve is not a sleep. -The calculator accepts board-specific allowances, but production scheduling -uses the requested **600 us per switch plus 300 us per cycle**. This is a -nominal fast-switch allowance, not a worst-case bound. Diagnostics use the -larger of 600 us and the observed maximum -switch time; an overrun increases the warning estimate without automatically -changing wire preambles or saved configuration. Recent V4 tests peaked at -836 us (827 us in the 4.6/0.3 ms run), and Indicator reached 8428 us: -both can exceed the nominal allowance. +Production scheduling and automatic preambles use the **self-tested maximum +successful switch plus 10%**, together with 300 us per cycle. Subsequent +successful hops can only raise that allowance until the next profile change. +The measured recommendation is rounded up to eight symbols and capped at 128 +for automatic settings. The calculator and warnings retain the *uncapped* +recommendation so a slow board is not shown as safe merely because it hit the +limit. Older V4 tests peaked at 836 us (827 us in the 4.6/0.3 ms run), and +Indicator reached 8428 us; these are historical measurements, not default +budgets for other boards. The model preserves two slow-profile return opportunities and the existing 16-symbol fast-profile acquisition allowance. These are conservative policies, not a fitted success curve, and neither packet overlap nor arbitrary application @@ -451,8 +481,8 @@ This does not disable the TCXO supply or its initial startup delay, and it is not conditional on 500 kHz bandwidth. Keeping the oscillator on uses additional power during the short standby intervals. -The requested 0.6 ms switch budget is a nominal scheduling allowance, not a -forced settling delay or a measured worst-case bound. The earlier standalone +The switch-time self-test is not a forced settling delay or a proof of RF +acquisition. The earlier standalone [keep-warm scan experiment](mixed_scan_validation.md) averaged about 1.19 ms of overhead per hop (scan-cycle time minus both receive windows, divided by two). That experiment used a leaner receive path; it is not a measurement or @@ -502,10 +532,9 @@ The mixed-bandwidth timing figures above predate it. Four-channel SF6 scans still missed packets; faster switching is not proof of loss-free acquisition. See [production and USB validation](radio_profile_switch_validation.md#production-integration-and-usb-recovery-v8) for current measurements, lifecycle tests and limitations. -The Indicator result also shows that the -nominal 0.6 ms allowance is not an upper bound for every board; its scheduling -and preambles need board-specific qualification. Measured overruns raise the -recommended preambles shown by the CLI without rewriting configured values. +The Indicator result shows why each board now measures its own switch time. +That measurement adjusts automatic preambles and dwell, but it cannot verify +RF acquisition or lengthen packets transmitted by other nodes. Single-channel noise-floor calibration and RSSI interference comparison are suspended while scanning. Retries use the radio's preamble/header activity diff --git a/examples/kiss_modem/KissModem.cpp b/examples/kiss_modem/KissModem.cpp index 9f2655e0..ec309e7b 100644 --- a/examples/kiss_modem/KissModem.cpp +++ b/examples/kiss_modem/KissModem.cpp @@ -726,7 +726,7 @@ bool KissModem::validateRadio2Config(const Radio2Config& config) const { if (config.profile.mode == mesh::RadioProfileMode::Off) return true; if (!_radio.validateProfile(config.profile.params)) return false; mesh::RadioProfiles preview = *profiles; - preview.secondary = config.profile; + preview.setSecondary(config.profile, false); return preview.automaticPreambleFits(); } diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index c7c43b69..d2f4b985 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -40,13 +40,11 @@ class RadioProfiles { static constexpr double MinFastListenSymbols = MinListenSymbols; static constexpr uint8_t AcquisitionSymbols = 8; static constexpr uint16_t MaxPreamble = 65528; - // Recent V4 single-pass hops measured up to 836 us, including post-packet - // cache refresh; Indicator measured up to 8428 us (expander GPIO). - // Use the requested 0.6 ms nominal allowance for fast switching. This is - // not a worst-case bound; measured overruns raise diagnostic recommendations. + static constexpr uint16_t MaxAutomaticPreamble = 128; + // Successful RX-to-RX hops self-test the board-specific switch allowance. // Packet receptions can extend a visit; these budgets describe an idle scan. - static constexpr uint32_t SwitchBudgetUs = 600; static constexpr uint32_t LoopBudgetUs = 300; + static constexpr uint8_t SwitchTestSamplesPerDirection = 4; struct ChirpTiming { bool valid = false; @@ -73,6 +71,29 @@ class RadioProfiles { uint32_t tx_packets[2] = {}; uint32_t switch_failures = 0; uint32_t longest_switch_us = 0; + uint32_t switch_test_max_us[2] = {}; + uint8_t switch_test_samples[2] = {}; + + void resetSwitchTest() { + switch_test_max_us[0] = switch_test_max_us[1] = 0; + switch_test_samples[0] = switch_test_samples[1] = 0; + } + void sampleSwitch(uint8_t from, uint8_t to, uint32_t elapsed_us) { + 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; + } + bool switchTestReady() const { + return switch_test_samples[0] >= SwitchTestSamplesPerDirection + && switch_test_samples[1] >= SwitchTestSamplesPerDirection; + } + 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]; + if (!observed) return 0; + const uint64_t guarded = (uint64_t(observed) * 11 + 9) / 10; + return guarded > UINT32_MAX ? UINT32_MAX : uint32_t(guarded); + } bool enabled() const { return secondary.mode != RadioProfileMode::Off; } bool canTransmit(uint8_t profile, bool reply_rx_override = false) const { @@ -147,7 +168,7 @@ class RadioProfiles { // including a longer fast visit selected by an explicit slow preamble. static ChirpTiming calculateChirpTiming(const RadioProfileParams& a, const RadioProfileParams& b, uint32_t visit_a_us = 0, uint32_t visit_b_us = 0, - uint32_t switch_us = SwitchBudgetUs, uint32_t loop_us = LoopBudgetUs) { + uint32_t switch_us = 0, uint32_t loop_us = LoopBudgetUs) { ChirpTiming t; if (!valid(a) || !valid(b)) return t; t.symbol_us[0] = symbolUs(a); t.symbol_us[1] = symbolUs(b); @@ -171,7 +192,7 @@ class RadioProfiles { const uint8_t slow = slowerProfile(); return preambleForVisits(params(profile), params(profile ^ 1), profile == slow, minimumListenUs(params(profile), profile == slow), minimumListenUs(params(profile ^ 1), (profile ^ 1) == slow), - 2.0 * SwitchBudgetUs + LoopBudgetUs); + 2.0 * switchBudgetUs() + LoopBudgetUs); } uint32_t listenUs(uint8_t profile, uint16_t slow_preamble = 0) const { const uint8_t slow = slowerProfile(); @@ -179,49 +200,50 @@ class RadioProfiles { if (!enabled() || profile == slow) return (uint32_t)ceil(minimum); if (!slow_preamble) slow_preamble = preamble(slow, 32); const double available = double(slow_preamble) / 2.0 * symbolUs(params(slow)) - - minimumListenUs(params(slow), true) - 2.0 * SwitchBudgetUs - LoopBudgetUs; + - minimumListenUs(params(slow), true) - 2.0 * switchBudgetUs() - LoopBudgetUs; // Rejected previews must not cause an out-of-range floating-to-int cast. return available > UINT32_MAX ? UINT32_MAX : available > minimum ? (uint32_t)floor(available) : (uint32_t)minimum; } ChirpTiming chirpTiming() const { if (!enabled()) return {}; - // Measured overruns raise advisory requirements only. Do not silently - // change wire preambles or persisted settings based on one slow hop. - const uint32_t budget = longest_switch_us > SwitchBudgetUs ? longest_switch_us : SwitchBudgetUs; - return calculateChirpTiming(primary, secondary.params, listenUs(0), listenUs(1), budget); + return calculateChirpTiming(primary, secondary.params, listenUs(0), listenUs(1), switchBudgetUs()); } uint16_t preamble(uint8_t profile, uint16_t single_profile_default) const { const auto& p = params(profile); if (p.preamble) return p.preamble; if (!enabled()) return single_profile_default; + // Do not expose an unmeasured short preamble while the startup test runs. + if (!switchTestReady()) return MaxAutomaticPreamble; double symbols = automaticPreamble(profile); if (symbols < single_profile_default) symbols = single_profile_default; - if (symbols > MaxPreamble) symbols = MaxPreamble; + symbols = roundPreamble(symbols); + if (symbols > MaxAutomaticPreamble) symbols = MaxAutomaticPreamble; return (uint16_t)symbols; } bool automaticPreambleFits() const { for (uint8_t i = 0; i < (enabled() ? 2 : 1); ++i) { if (!valid(params(i)) || !safePreamble(params(i), preamble(i, 32))) return false; - if (!enabled()) continue; - if (!params(i).preamble && automaticPreamble(i) > MaxPreamble) return false; } + // Recommendations over 128 are reported as unsafe, but the profile can + // still run at the cap when its physical preamble is legal. if (enabled()) { const uint8_t slow = slowerProfile(); const double available = double(preamble(slow, 32)) / 2.0 * symbolUs(params(slow)) - - minimumListenUs(params(slow), true) - 2.0 * SwitchBudgetUs - LoopBudgetUs; - if (available < minimumListenUs(params(slow ^ 1))) return false; + - minimumListenUs(params(slow), true) - 2.0 * switchBudgetUs() - LoopBudgetUs; + if (available < minimumListenUs(params(slow ^ 1)) + && params(slow).preamble) return false; } return true; } void setPrimary(const RadioProfileParams& p, bool temporary) { - if (primary != p || primary_temporary != temporary) ++generation[0]; + if (primary != p || primary_temporary != temporary) { ++generation[0]; resetSwitchTest(); } primary = p; primary_temporary = temporary; } void setSecondary(const RadioProfileConfig& p, bool temporary) { if (secondary.params != p.params || secondary.mode != p.mode - || secondary_temporary != temporary) ++generation[1]; + || secondary_temporary != temporary) { ++generation[1]; resetSwitchTest(); } secondary = p; secondary_temporary = temporary; } diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index b7c5ce09..2891c790 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -376,6 +376,7 @@ void RadioProfileCLI::appendSavedPreamble(char* reply, size_t capacity, uint8_t auto preview = *radio_->profiles(); preview.primary.sf = sf; preview.primary.bw = bw; preview.primary.preamble = primary_preamble_; + if (preview.primary != radio_->profiles()->primary) preview.resetSwitchTest(); const size_t used = strlen(reply); if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=%u%s", preview.preamble(0, rxPowerSavingPreambleForParams(sf, bw)), primary_preamble_ ? "" : " (auto)"); @@ -387,14 +388,26 @@ void RadioProfileCLI::appendPrimaryChirpWarning(char* reply, size_t capacity, ui if (!radio_ || !radio_->profiles()) return; auto preview = *radio_->profiles(); preview.primary.sf = sf; preview.primary.bw = bw; preview.primary.preamble = preamble; + if (preview.primary != radio_->profiles()->primary) preview.resetSwitchTest(); appendChirpWarning(reply, capacity, preview); } void RadioProfileCLI::appendChirpWarning(char* reply, size_t capacity, const RadioProfiles& preview) { if (!capacity || !preview.enabled()) return; + if (!preview.switchTestReady()) { + const size_t used = strlen(reply); + if (used < capacity) snprintf(reply + used, capacity - used, "; timing self-test pending"); + return; + } const auto timing = preview.chirpTiming(); if (!timing.valid) return; size_t used = strlen(reply); + const bool over_a = timing.preamble[0] > RadioProfiles::MaxAutomaticPreamble; + const bool over_b = timing.preamble[1] > RadioProfiles::MaxAutomaticPreamble; + if (used < capacity && (over_a || over_b)) + snprintf(reply + used, capacity - used, "; WARN need >128: %s", + over_a && over_b ? "radio,radio2" : over_a ? "radio" : "radio2"); + used = strlen(reply); const bool short_a = preview.primary.preamble && preview.primary.preamble < timing.preamble[0]; const bool short_b = preview.secondary.params.preamble && preview.secondary.params.preamble < timing.preamble[1]; const bool a = timing.preamble[0] > 32 || short_a, b = timing.preamble[1] > 32 || short_b; @@ -408,13 +421,22 @@ void RadioProfileCLI::appendChirpWarning(char* reply, size_t capacity, const Rad if (used < capacity && (short_a || short_b)) snprintf(reply + used, capacity - used, "; short override: %s", short_a && short_b ? "radio,radio2" : short_a ? "radio" : "radio2"); + // Preamble-bearing acknowledgements and getters share this formatter. Only + // report the measured allowance for the same calibrated profile pair; a new + // pair still has to activate and complete its self-test after the first ACK. + // Dedicated timing/status getters already include this value under their + // existing switch/budget labels. + used = strlen(reply); + if (used < capacity && !strstr(reply, "switch=") && !strstr(reply, "budget=")) + snprintf(reply + used, capacity - used, "; switch=%luus", + (unsigned long)preview.switchBudgetUs()); } void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config, bool temporary, uint32_t remaining) const { if (config.mode == RadioProfileMode::Off) { snprintf(reply, capacity, "> off"); return; } RadioProfiles preview = *radio_->profiles(); - preview.secondary = config; + preview.setSecondary(config, temporary); uint16_t preamble = preview.preamble(1, 32); snprintf(reply, capacity, "> %.3f,%.3f,%u,%u,%s,%u%s", config.params.freq, config.params.bw, config.params.sf, config.params.cr, modeName(config.mode), preamble, @@ -499,11 +521,21 @@ 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", + p.switch_test_samples[0], RadioProfiles::SwitchTestSamplesPerDirection, + p.switch_test_samples[1], RadioProfiles::SwitchTestSamplesPerDirection, + radio_->profilePreamble(0), radio_->profilePreamble(1)); else if (!t.valid) snprintf(reply, capacity, "Error: timing unavailable"); else { snprintf(reply, capacity, "> chirps=%.2f,%.2f; need=%.0f,%.0f; switch=%luus; loop=%luus (estimate)", t.listen_us[0] / t.symbol_us[0], t.listen_us[1] / t.symbol_us[1], t.preamble[0], t.preamble[1], (unsigned long)t.switch_us, (unsigned long)t.loop_us); + size_t used = strlen(reply); + if (used < capacity) snprintf(reply + used, capacity - used, "; test=%u/%u,%u/%u%s", + p.switch_test_samples[0], RadioProfiles::SwitchTestSamplesPerDirection, + p.switch_test_samples[1], RadioProfiles::SwitchTestSamplesPerDirection, + p.switchTestReady() ? " ready" : " pending"); appendChirpWarning(reply, capacity, p); } return true; @@ -545,6 +577,14 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, (unsigned long)p.tx_packets[0], (unsigned long)p.tx_packets[1], (unsigned long)p.switches, (unsigned long)p.switch_failures, (unsigned long)p.longest_switch_us, radio_->profilePreamble(0), radio_->profilePreamble(1)); + if (p.enabled()) { + size_t used = strlen(reply); + 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()); + appendChirpWarning(reply, capacity, p); + } return true; } if ((base || temporary) && verb == Get && !*args) { @@ -630,7 +670,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity, if (valid && scheduled) valid = cli::parseUnsignedIntegerStrict(parts[5], start); if (valid && scheduled_temp) valid = cli::parseUnsignedIntegerStrict(parts[6], end); if (valid) valid = radio_->validateProfile(config.params); - RadioProfiles preview = *radio_->profiles(); preview.secondary = config; + RadioProfiles preview = *radio_->profiles(); preview.setSecondary(config, false); if (valid) valid = preview.automaticPreambleFits(); if (!valid) { snprintf(reply, capacity, "Error: use %s f,bw,sf,cr,rx|rxtx%s[,preamble|auto]", key, scheduled_temp ? ",start,end" : scheduled ? ",start" : temporary ? ",minutes" : ""); return true; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 5d493e9b..a9cfea30 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -338,6 +338,11 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { } const uint32_t elapsed = _profile_visit_us - started; if (elapsed > _profiles.longest_switch_us) _profiles.longest_switch_us = elapsed; + // Learn the actual board's RX-to-RX retune cost on the first four good hops + // in each direction. Deferred and rolled-back operations are not samples. + if (applied && resume && old_profile != profile) { + _profiles.sampleSwitch(old_profile, profile, elapsed); + } return applied ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; } @@ -386,6 +391,28 @@ void RadioLibWrapper::serviceProfileScan() { setProfileStandbyWarm(true); endReconfigure(resume); } + if (_profiles.enabled() && !_profiles.switchTestReady()) { + // A bounded startup self-test uses the real RX-to-RX retune path for the + // active pair. Keep the 128-symbol provisional preamble until four good + // samples in each direction are available. A packet, TX, or BUSY radio + // defers the remaining hops without counting an invalid measurement. + for (uint8_t hops = 0; hops < 2 * mesh::RadioProfiles::SwitchTestSamplesPerDirection; + ++hops) { + if (_profiles.switchTestReady()) break; + const uint8_t from = _active_profile; + const uint8_t samples = _profiles.switch_test_samples[from]; + const auto test_result = tuneProfile(from ^ 1); + if (test_result == mesh::RadioParamApplyResult::FAILED) { + _profile_retry_at = millis() + 1000; + tuneProfile(0); + return; + } + if (test_result != mesh::RadioParamApplyResult::APPLIED + || (samples < mesh::RadioProfiles::SwitchTestSamplesPerDirection + && _profiles.switch_test_samples[from] == samples)) return; + } + if (!_profiles.switchTestReady()) return; + } uint8_t target = _active_profile; const bool restart_scan = _profiles.enabled() && (_profile_scan_generation[0] != _profiles.generation[0] @@ -463,7 +490,7 @@ bool RadioLibWrapper::setParams(float freq, float bw, uint8_t sf, uint8_t cr, mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(const mesh::RadioProfileParams& p, bool temporary, const uint32_t* timings) { auto preview = _profiles; - preview.primary = p; + preview.setPrimary(p, temporary); if (!validateProfile(p) || !preview.automaticPreambleFits()) return mesh::RadioParamApplyResult::FAILED; const auto previous = _profiles.primary; const bool was_temp = _profiles.primary_temporary; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 1e4670c7..e796bb12 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -133,10 +133,7 @@ protected: const uint32_t minimum_visit = NoiseFloorSettleMillis * 1000UL; if (primary_visit < minimum_visit) primary_visit = minimum_visit; if (secondary_visit < minimum_visit) secondary_visit = minimum_visit; - uint32_t switch_us = _profiles.longest_switch_us; - if (switch_us < mesh::RadioProfiles::SwitchBudgetUs) { - switch_us = mesh::RadioProfiles::SwitchBudgetUs; - } + const uint32_t switch_us = _profiles.switchBudgetUs(); const uint64_t cycle_us = uint64_t(primary_visit) + secondary_visit + 2ULL * switch_us + mesh::RadioProfiles::LoopBudgetUs; return estimator.samplesRemaining() * (cycle_us / 1000000.0f); diff --git a/test/fixtures/radio_profiles/chirp_math.cpp b/test/fixtures/radio_profiles/chirp_math.cpp index 398c43ff..e97f834b 100644 --- a/test/fixtures/radio_profiles/chirp_math.cpp +++ b/test/fixtures/radio_profiles/chirp_math.cpp @@ -11,7 +11,6 @@ int main() { assert(Profiles::MinListenSymbols==4.6); assert(Profiles::SlowListenSymbols==4.6); assert(Profiles::MinFastListenSymbols==4.6); - assert(Profiles::SwitchBudgetUs==600); assert(Profiles::LoopBudgetUs==300); auto a=params(10,125),b=params(10,125); auto t=Profiles::calculateChirpTiming(a,b,1,0,1000,4000); @@ -41,7 +40,11 @@ int main() { for (unsigned sf0=5;sf0<=12;++sf0) for(unsigned sf1=5;sf1<=12;++sf1) { Profiles p;p.primary=params(sf0,bw0);p.secondary.params=params(sf1,bw1); p.secondary.mode=mesh::RadioProfileMode::Rx; - auto minimum=Profiles::calculateChirpTiming(p.primary,p.secondary.params); + for (unsigned n=0;nProfiles::MaxAutomaticPreamble + || p.automaticPreamble(1)>Profiles::MaxAutomaticPreamble) { + assert(p.preamble(0,32)<=128 && p.preamble(1,32)<=128); + continue; + } auto actual=p.chirpTiming(); assert(actual.valid); assert(p.listenUs(actual.slow)==Profiles::minimumListenUs(p.params(actual.slow),true)); assert(p.listenUs(actual.slow^1)>=Profiles::minimumListenUs(p.params(actual.slow^1))); const double available=std::floor(p.preamble(actual.slow,32)*actual.symbol_us[actual.slow]/2 - -p.listenUs(actual.slow)-2*Profiles::SwitchBudgetUs-Profiles::LoopBudgetUs); + -p.listenUs(actual.slow)-2*p.switchBudgetUs()-Profiles::LoopBudgetUs); const uint32_t fast_floor=Profiles::minimumListenUs(p.params(actual.slow^1)); assert(p.listenUs(actual.slow^1)==(available>fast_floor ? (uint32_t)available : fast_floor)); assert(2*actual.cycle_us<=p.preamble(actual.slow,32)*actual.symbol_us[actual.slow]+1e-6); @@ -64,13 +72,21 @@ int main() { } Profiles p;p.primary=params(7,62.5);p.secondary.params=params(7,500); p.secondary.mode=mesh::RadioProfileMode::Rx; + assert(p.preamble(0,32)==128 && p.preamble(1,32)==128 && !p.switchTestReady()); + for (unsigned n=0;nbefore.preamble[0]); - assert(p.preamble(0,32)==wire0 && p.preamble(1,32)==wire1); - p.longest_switch_us=0;p.primary.preamble=64; + p.sampleSwitch(0,1,8428);auto overrun=p.chirpTiming(); + assert(overrun.switch_us==9271 && overrun.preamble[1]>before.preamble[1]); + assert(p.preamble(0,32)>=wire0 && p.preamble(1,32)>=wire1); + p.resetSwitchTest(); + for (unsigned n=0;n21847); p.secondary.params.preamble=32; assert(p.preamble(1,32)==32 && p.chirpTiming().preamble[1]>32); @@ -97,5 +113,29 @@ int main() { assert(p.automaticPreambleFits() && p.listenUs(1)==1418); // Long requested visits remain visible, not replaced by policy minima. t=Profiles::calculateChirpTiming(params(7,62.5),params(8,500),20000,30000); - assert(t.listen_us[0]==20000 && t.listen_us[1]==30000 && t.cycle_us==51500); + assert(t.listen_us[0]==20000 && t.listen_us[1]==30000 && t.cycle_us==50300); + Profiles measured; + measured.primary=params(7,500); + measured.secondary.params=params(7,500); + measured.secondary.mode=mesh::RadioProfileMode::Rx; + assert(!measured.switchTestReady() && measured.switchBudgetUs()==0); + assert(measured.preamble(0,32)==128 && measured.preamble(1,32)==128); + measured.sampleSwitch(0,0,10000); measured.sampleSwitch(0,1,0); + assert(measured.switch_test_samples[0]==0); + for (unsigned n=0;n=32 && measured.preamble(0,32)%8==0); + assert(measured.preamble(1,32)>=32 && measured.preamble(1,32)%8==0); + measured.sampleSwitch(0,1,8428); + assert(measured.switchBudgetUs()==9271); + assert(measured.automaticPreamble(0)>128); + assert(measured.preamble(0,32)==128 && measured.chirpTiming().preamble[0]>128); + measured.secondary.params.preamble=88; // explicit overrides remain unchanged + assert(measured.preamble(1,32)==88); + auto replacement=measured.secondary;replacement.params.freq=910.5; + measured.setSecondary(replacement,true); + assert(!measured.switchTestReady() && measured.switchBudgetUs()==0); + assert(measured.preamble(0,32)==128 && measured.preamble(1,32)==88); } diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 8a84ad8a..3bd39715 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -42,6 +42,13 @@ struct Fixture { return reply; } 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) { + for (unsigned i=0;i off")); assert(!strcmp(f.cmd("get radio2.cross"),"> auto")); - assert(strstr(f.cmd("set radio2 910.5,500,7,5,rxtx"),"WARN recommended preamble: radio2=64")); + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rxtx"),"timing self-test pending")); assert(!f.radio.p.enabled()); f.advance(2000); assert(f.radio.p.enabled()); + assert(strstr(f.cmd("get radio2.timing"),"self-test pending")); + f.finishTimingTest(); assert(strstr(f.cmd("get radio2"),"rxtx,64 (auto)")); + assert(strstr(f.reply,"switch=600us")); + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rxtx"),"preamble=64;")); + assert(strstr(f.reply,"switch=600us")); assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9421,21847")); assert(strstr(f.cmd("get radio2.timing"),"chirps=4.60,85.34; need=32,64")); assert(strstr(f.reply,"switch=600us")); @@ -347,6 +359,22 @@ int main(int argc, char** argv) { f.cmd("set radio2 off"); f.advance(2000); assert(!f.radio.p.enabled()); f.cmd("get radio2.status"); } + { + Fixture f; + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx"),"timing self-test pending")); + assert(!strstr(f.reply,"switch=")); + f.advance(2000); + f.finishTimingTest(497); // 497 us observed with a 10% guard -> 547 us. + assert(f.radio.p.switchBudgetUs()==547); + assert(strstr(f.cmd("get radio2"),"switch=547us")); + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx"),"switch=547us")); + assert(strstr(f.cmd("get radio2.timing"),"switch=547us")); + assert(!strstr(strstr(f.reply,"switch=")+1,"switch=")); + assert(strstr(f.cmd("get radio2.status"),"budget=547us")); + assert(!strstr(f.reply,"switch=547us")); + assert(strstr(f.cmd("set radio2 911.5,500,7,5,rx"),"timing self-test pending")); + assert(!strstr(f.reply,"switch=547us")); + } { Fixture f; const char* invalid[] = {"910.5,500,7,5", "NaN,500,7,5,rx", "910.5,0,7,5,rx", @@ -367,20 +395,20 @@ int main(int argc, char** argv) { Fixture f; assert(!strcmp(f.cmd("get radio2.timing"),"> off")); // A short explicit preamble is not rewritten, and cannot hide a warning. - assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx,32"),"WARN recommended preamble: radio2=64")); - assert(strstr(f.reply,"short override: radio2")); + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx,32"),"timing self-test pending")); f.advance(2000); + f.finishTimingTest(); assert(f.radio.p.secondary.params.preamble==32); assert(strstr(f.cmd("get radio2"),"short override: radio2")); - f.radio.p.longest_switch_us=8428; - assert(strstr(f.cmd("get radio2.timing"),"switch=8428us")); - assert(strstr(f.reply,"WARN recommended preamble: radio=")); + f.radio.p.sampleSwitch(0,1,8428); + assert(strstr(f.cmd("get radio2.timing"),"switch=9271us")); + assert(strstr(f.reply,"WARN recommended preamble: radio2=")); assert(f.radio.p.secondary.params.preamble==32); // Primary getters use the saved primary's SF/BW instead of a temporary one. f.radio.p.primary.sf=10; f.radio.p.primary.bw=125; strcpy(f.reply,"> radio"); f.cli.appendSavedPreamble(f.reply,sizeof(f.reply),7,62.5); - assert(strstr(f.reply,"WARN recommended preamble: radio=")); + assert(strstr(f.reply,"timing self-test pending")); // Bounded appenders cannot corrupt neighboring bytes, even with tiny replies. for (size_t n=1;n<=160;++n) { char bytes[162]; memset(bytes,0x55,sizeof(bytes)); bytes[0]=0; @@ -389,23 +417,22 @@ int main(int argc, char** argv) { } f.radio.p.primary.sf=10;f.radio.p.primary.bw=125; f.radio.p.secondary.params.sf=10;f.radio.p.secondary.params.bw=125; - f.radio.p.longest_switch_us=0; + f.radio.p.resetSwitchTest(); f.finishTimingTest(); assert(!strstr(f.cmd("get radio2.timing"),"WARN")); // A selected value below 32 still gets the actual recommendation, not // merely a warning label with no preamble to use. - assert(strstr(f.cmd("set radio2 910.5,125,10,5,rx,16"),"WARN recommended preamble: radio2=32")); - assert(strstr(f.reply,"short override: radio2")); + assert(strstr(f.cmd("set radio2 910.5,125,10,5,rx,16"),"timing self-test pending")); } { Fixture f; f.radio.p.primary.sf=7;f.radio.p.primary.bw=500; - assert(strstr(f.cmd("set radio2 910.5,62.5,7,5,rx"),"WARN recommended preamble: radio=64")); + assert(strstr(f.cmd("set radio2 910.5,62.5,7,5,rx"),"timing self-test pending")); f.advance(2000); + f.finishTimingTest(); assert(strstr(f.cmd("get radio2.timing"),"need=64,32")); strcpy(f.reply,"OK - reboot to apply"); f.cli.appendPrimaryChirpWarning(f.reply,sizeof(f.reply),7,500,32); - assert(strstr(f.reply,"WARN recommended preamble: radio=64")); - assert(strstr(f.reply,"short override: radio")); + assert(strstr(f.reply,"timing self-test pending")); strcpy(f.reply,"OK - reboot to apply"); f.cli.appendPrimaryChirpWarning(f.reply,sizeof(f.reply),7,62.5,0); assert(!strstr(f.reply,"WARN")); // Preview requested tuple, not the live one. @@ -417,13 +444,25 @@ int main(int argc, char** argv) { { Fixture f; // The tested pair is allocated automatically without changing explicit 32. - assert(strstr(f.cmd("set radio2 910.5,500,8,5,rx,32"),"WARN recommended preamble: radio2=88")); + assert(strstr(f.cmd("set radio2 910.5,500,8,5,rx,32"),"timing self-test pending")); f.advance(2000); + f.finishTimingTest(); assert(f.radio.p.secondary.params.preamble==32); assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9421,21847")); assert(strstr(f.cmd("get radio2.timing"),"chirps=4.60,42.67; need=32,88")); assert(strstr(f.reply,"loop=300us")); } + { + Fixture f; + f.radio.p.primary.bw=500; + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rx"),"timing self-test pending")); + f.advance(2000); + f.finishTimingTest(8428); + assert(f.radio.p.switchBudgetUs()==9271); + assert(f.radio.p.preamble(0,32)==128); + assert(strstr(f.cmd("get radio2.timing"),"WARN need >128: radio")); + assert(strstr(f.cmd("get radio2.status"),"WARN need >128: radio")); + } { Fixture f; f.cmd("set radio2 910.5,500,9,5,rxtx,40"); f.advance(2000); diff --git a/test/fixtures/radio_profiles/mesh_tests.h b/test/fixtures/radio_profiles/mesh_tests.h index e60b93f0..44c4d254 100644 --- a/test/fixtures/radio_profiles/mesh_tests.h +++ b/test/fixtures/radio_profiles/mesh_tests.h @@ -692,10 +692,13 @@ TEST(RadioProfiles, CrossPolicyAndReceiveOnlyMatrix) { } } -TEST(RadioProfiles, AutomaticPreamblesIncludeNominalSwitchingMargin) { +TEST(RadioProfiles, AutomaticPreamblesUseSelfTestedSwitchingMargin) { DualProfileTestRadio radio; auto& p = radio.config; - EXPECT_EQ(600, p.SwitchBudgetUs); + for (unsigned i = 0; i < p.SwitchTestSamplesPerDirection; ++i) { + p.sampleSwitch(0, 1, 545); p.sampleSwitch(1, 0, 545); + } + EXPECT_EQ(600, p.switchBudgetUs()); EXPECT_EQ(300, p.LoopBudgetUs); const uint16_t expected[] = {64, 88, 32}; for (int sf = 7; sf <= 9; ++sf) { @@ -705,9 +708,9 @@ TEST(RadioProfiles, AutomaticPreamblesIncludeNominalSwitchingMargin) { EXPECT_EQ(0, p.preamble(1, 32) % 8); EXPECT_EQ(9421, p.listenUs(0)); EXPECT_EQ(21847, p.listenUs(1)); - EXPECT_LE(2 * (p.listenUs(0) + p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs), + EXPECT_LE(2 * (p.listenUs(0) + p.listenUs(1) + 2 * p.switchBudgetUs() + p.LoopBudgetUs), p.preamble(0, 32) * p.symbolUs(p.primary)); - EXPECT_LE(p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs + EXPECT_LE(p.listenUs(1) + 2 * p.switchBudgetUs() + p.LoopBudgetUs + p.AcquisitionSymbols * p.symbolUs(p.primary), p.preamble(0, 32) * p.symbolUs(p.primary)); } p.secondary.params.preamble = 40; @@ -719,6 +722,9 @@ TEST(RadioProfiles, AutomaticPreamblesIncludeNominalSwitchingMargin) { TEST(RadioProfiles, SlowerProfileDeterminesOrderAndFasterReceiveWindow) { DualProfileTestRadio radio; auto& p = radio.config; + for (unsigned i = 0; i < p.SwitchTestSamplesPerDirection; ++i) { + p.sampleSwitch(0, 1, 545); p.sampleSwitch(1, 0, 545); + } EXPECT_EQ(0, p.slowerProfile()); std::swap(p.primary, p.secondary.params); EXPECT_EQ(1, p.slowerProfile()); diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index 31b8d68a..007c8ef7 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -49,7 +49,7 @@ struct RadioLibWrapper { uint32_t _profile_scan_generation[2]={}; uint16_t _physical_preamble=32; float _cur_freq=909.5, _cur_bw=62.5; - unsigned applies=0, failRxStarts=0; + unsigned applies=0, failRxStarts=0, switch_apply_us=1200; unsigned coding_writes=0; bool coding_success=true; bool setCodingRate(uint8_t) { ++coding_writes; return coding_success; } @@ -72,7 +72,7 @@ struct RadioLibWrapper { state=STATE_RX; _profile_visit_us=micros(); _rx_ps_armed=_rx_ps_enabled; } void stopReceiveDutyCycle() { _rx_ps_armed=false; } - bool applyParams(float f,float,uint8_t,uint8_t) { ++applies; elapsed_us+=1200; return !fail || f==909.5f; } + bool applyParams(float f,float,uint8_t,uint8_t) { ++applies; elapsed_us+=switch_apply_us; return !fail || f==909.5f; } void cacheParams(float f,float b,uint8_t s,uint8_t c) { _cur_freq=f;_cur_bw=b;_cur_sf=s;_cur_cr=c; } bool restoreAfterDeepInit() { return true; } void recalibrateNoiseFloor() { _noise_floor_valid=false; } @@ -98,6 +98,35 @@ struct RadioLibWrapper { @METHODS@ int main() { using Result=mesh::RadioParamApplyResult; + { + RadioLibWrapper w; w.enable(); + assert(w._profiles.switchTestReady()); + assert(w._profiles.switch_test_samples[0]==4 && w._profiles.switch_test_samples[1]==4); + assert(w._profiles.switchBudgetUs()==1320); // measured 1200 us plus 10%, no nominal floor + assert(w._profiles.preamble(0,32)>=32 && w._profiles.preamble(0,32)<=128); + assert(w._profiles.preamble(1,32)>=32 && w._profiles.preamble(1,32)<=128); + assert(w._profiles.preamble(0,32)%8==0 && w._profiles.preamble(1,32)%8==0); + } + { + RadioLibWrapper w; w._profiles.primary.bw=w._cur_bw=500; w.switch_apply_us=8428; + w.enable(); + assert(w._profiles.switchTestReady() && w._profiles.switchBudgetUs()==9271); + assert(w._profiles.preamble(0,32)==128 && w._profiles.preamble(1,32)<=128); + assert(w._profiles.chirpTiming().preamble[0]>128); + } + { + RadioLibWrapper w; w.packet=true; w.enable(); + assert(!w._profiles.switchTestReady() && w._profiles.switch_test_samples[0]==0); + w.packet=false; w.serviceProfileScan(); assert(w._profiles.switchTestReady()); + w._profiles.setSecondary({},false); + assert(!w._profiles.switchTestReady()); + } + { + RadioLibWrapper w; w.fail=true; w.enable(); + assert(w._profiles.switch_failures==1 && w._profiles.switch_test_samples[0]==0); + w.fail=false; elapsed_us+=1000000; w.serviceProfileScan(); + assert(w._profiles.switchTestReady()); + } for (unsigned blocked=0; blocked<5; ++blocked) { RadioLibWrapper w; w._cw_active = blocked==0; w.busy = blocked==1; w.packet = blocked==2; diff --git a/tools/hil/profile_switch_README.md b/tools/hil/profile_switch_README.md index 40abc716..49a4617f 100644 --- a/tools/hil/profile_switch_README.md +++ b/tools/hil/profile_switch_README.md @@ -805,10 +805,23 @@ timing, packet reception, and TX checks before claiming the 0.6 ms goal. the same 200 µs TCXO/fast-retune settings as the T1000-E variant. It never transmits, loads an identity, or writes saved settings. Its only frequencies are 909.5 and 909.75 MHz; the `mod`, `freq`, `both`, and `preamble` modes change -SF7/SF8, frequency, both, or 32/48 preamble symbols respectively. A run adds -eight warmup hops to the requested count and times the remainder with the -nRF52840's 64 MHz cycle counter. A separate RSSI read checks that each resumed -receiver responds after seven milliseconds of settling; it is outside timing. +SF7/SF8, frequency, both, or 32/48 preamble symbols respectively. A run first +times eight rapid startup-style hops (four in each direction), then the +requested longer sample with 10 ms between hops. Results report each sample's +mean/maximum and the production wrapper's 10%-guarded startup and final +budgets. A separate RSSI read checks that each resumed receiver responds after +seven milliseconds of settling; it is outside timing. + +The [four-hop comparison](profile_switch_t1000_spotcheck_results.json) ran +three 1,000-hop combined frequency/SF trials. The rapid eight-hop samples +averaged 504.346 us and peaked at 507.156 us; the 3,000 later hops averaged +502.907 us and peaked at 503.172 us. The firmware's startup allowance was +547 us in all three trials and covered every later hop. The cycle-counter +measurement includes a post-retune BUSY check that the firmware's `micros()` +sample ends before, so its 10% allowance is not exactly 110% of the reported +cycle-counter maximum. This validates four samples per direction on this +T1000-E only, not on every LR1110 or GPIO-expander board. The exact original +Full Companion application was restored after the test. Build with `pio run -e profile_switch_t1000_lr1110`. Flash its application-only ZIP with serial DFU after verifying the board's unique USB serial number and diff --git a/tools/hil/profile_switch_t1000_compare.py b/tools/hil/profile_switch_t1000_compare.py new file mode 100644 index 00000000..2e46dc24 --- /dev/null +++ b/tools/hil/profile_switch_t1000_compare.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Compare four rapid RX hops per direction with a longer T1000-E switch run.""" + +import argparse +import json +import time + +import serial + + +def read_reply(port, key, timeout, sequence=None): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + line = port.readline().decode("utf-8", errors="replace").strip() + if not line.startswith("{"): + continue + reply = json.loads(line) + if "error" in reply: + raise RuntimeError(reply["error"]) + if key in reply and (sequence is None or reply[key] == sequence): + return reply + raise TimeoutError(f"No {key} reply from {port.port}") + + +def aggregate(result, prefix): + sides = [result[f"{prefix}to_primary"], result[f"{prefix}to_secondary"]] + count = sum(side["n"] for side in sides) + return { + "n": count, + "mean_us": sum(side["n"] * side["mean_us"] for side in sides) / count, + "max_us": max(side["max_us"] for side in sides), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--port", required=True) + parser.add_argument("--count", type=int, default=1000) + parser.add_argument("--rounds", type=int, default=3) + parser.add_argument("--mode", choices=("mod", "freq", "both", "preamble"), default="both") + args = parser.parse_args() + if not 16 <= args.count <= 1000 or not 1 <= args.rounds <= 20: + parser.error("count must be 16..1000 and rounds 1..20") + + port = serial.Serial() + port.port = args.port + port.baudrate = 115200 + port.timeout = 0.25 + port.write_timeout = 2 + port.rts = False + port.dtr = True + port.open() + try: + port.reset_input_buffer() + port.write(b"info\n") + info = read_reply(port, "bench", 5) + if info.get("bench") != "t1000-lr1110-production-retune-v1" or not info.get("ready"): + raise RuntimeError(f"Wrong or unready device: {info}") + print(json.dumps(info), flush=True) + last_sequence = info.get("last_sequence") + if not isinstance(last_sequence, int) or not 0 <= last_sequence <= 0xFFFFFFFF: + raise RuntimeError("Bench firmware must report its last_sequence; rebuild and flash it") + if last_sequence + args.rounds > 0xFFFFFFFF: + raise RuntimeError("Bench run sequence exhausted; reboot the bench firmware") + trials = [] + for round_number in range(1, args.rounds + 1): + sequence = last_sequence + round_number + port.write(f"run {args.mode} {args.count} {sequence}\n".encode("ascii")) + try: + result = read_reply(port, "result", args.count * 0.05 + 20, sequence) + except TimeoutError: + port.write(f"result result {sequence}\n".encode("ascii")) + result = read_reply(port, "result", 5, sequence) + spot = aggregate(result, "spot_") + long = aggregate(result, "") + if (spot["n"] != 8 or long["n"] != args.count or not result["restored"] + or any(result[k] for k in ("failures", "busy_timeouts", "rx_errors", "cache_errors", "rssi_errors"))): + raise RuntimeError(f"Trial {round_number} failed validation: {result}") + trial = { + "round": round_number, + "mode": args.mode, + "spot": spot, + "spot_budget_us": result["spot_budget_us"], + "long": long, + "long_budget_us": result["long_budget_us"], + "spot_budget_covers_long_max": result["spot_budget_us"] >= long["max_us"], + } + trials.append(trial) + print(json.dumps(trial), flush=True) + summary = { + "trials": len(trials), + "total_long_hops": sum(t["long"]["n"] for t in trials), + "spot_mean_us": sum(t["spot"]["mean_us"] for t in trials) / len(trials), + "spot_max_us": max(t["spot"]["max_us"] for t in trials), + "long_mean_us": sum(t["long"]["mean_us"] for t in trials) / len(trials), + "long_max_us": max(t["long"]["max_us"] for t in trials), + "spot_budget_covered_every_trial": all(t["spot_budget_covers_long_max"] for t in trials), + } + print(json.dumps({"summary": summary}), flush=True) + finally: + port.close() + + +if __name__ == "__main__": + main() diff --git a/tools/hil/profile_switch_t1000_lr1110.cpp b/tools/hil/profile_switch_t1000_lr1110.cpp index ddde8b10..980d160f 100644 --- a/tools/hil/profile_switch_t1000_lr1110.cpp +++ b/tools/hil/profile_switch_t1000_lr1110.cpp @@ -201,7 +201,7 @@ void run(const char* mode, unsigned count, uint32_t sequence) { chip.hilInjectRxTimeout = injectTimeoutNextRun; injectTimeoutNextRun = false; - Timing total[2]; + Timing spot[2], total[2]; uint32_t failures = 0, busyDeferrals = 0, rxErrors = 0, cacheErrors = 0; uint32_t busyTimeouts = 0, rssiErrors = 0; const uint32_t rfBefore = radioHal.rfWrites; @@ -227,9 +227,10 @@ void run(const char* mode, unsigned count, uint32_t sequence) { const uint64_t packetCyclesBefore = chip.fastPacketCycles; const uint64_t setRxCyclesBefore = chip.fastSetRxCycles; unsigned completed = 0; + uint32_t spotBudgetUs = 0; for (unsigned attempt = 0; completed < count + 8 && attempt < (count + 8) * 4; ++attempt) { if (!waitBusy()) { ++busyTimeouts; break; } - delay(10); // Keep host I/O and dwell outside the measured retune. + if (completed >= 8) delay(10); // The first eight hops match the rapid startup self-test. const uint8_t target = driver.receiveProfile() ^ 1; const uint32_t started = DWT->CYCCNT; const auto result = driver.hop(target); @@ -245,13 +246,19 @@ void run(const char* mode, unsigned count, uint32_t sequence) { if (chip.getSpreadingFactor() != (target && modulation ? 8 : 7) || chip.getFreqMHz() != (target && frequency ? 909.75f : 909.5f) || chip.preambleSymbols() != (target && preambleChange ? 48 : 32)) ++cacheErrors; - delay(7); // RSSI needs a settled receiver; this is outside switch timing. - float rssi = 0; - if (chip.getRssiInst(&rssi) != RADIOLIB_ERR_NONE || !isfinite(rssi) - || rssi < -180.0f || rssi > 20.0f) ++rssiErrors; - if (completed++ >= 8) total[target].add(elapsed); + if (completed < 8) spot[target].add(elapsed); + else { + delay(7); // RSSI needs a settled receiver; this is outside switch timing. + float rssi = 0; + if (chip.getRssiInst(&rssi) != RADIOLIB_ERR_NONE || !isfinite(rssi) + || rssi < -180.0f || rssi > 20.0f) ++rssiErrors; + total[target].add(elapsed); + } + ++completed; + if (completed == 8) spotBudgetUs = driver.profiles()->switchBudgetUs(); } + const uint32_t longBudgetUs = driver.profiles()->switchBudgetUs(); driver.profiles()->setSecondary({}, true); auto restored = driver.hop(0); for (unsigned retry = 0; restored == mesh::RadioParamApplyResult::BUSY && retry < 20; ++retry) { @@ -266,7 +273,8 @@ void run(const char* mode, unsigned count, uint32_t sequence) { "\"primary\":\"909.5,125,7,5\",\"secondary_freq_mhz\":%.2f,\"secondary_sf\":%u," "\"secondary_preamble\":%u," "\"tcxo_us\":%lu,\"spi_hz\":%lu,\"bulk_spi\":%s," - "\"requested\":%u,\"completed\":%u,\"primary_busy_deferrals\":%lu," + "\"requested\":%u,\"completed\":%u,\"spot_budget_us\":%lu,\"long_budget_us\":%lu," + "\"primary_busy_deferrals\":%lu," "\"busy_deferrals\":%lu,\"drain_calls\":%lu,\"drained_packets\":%lu," "\"busy_high_checks\":%lu," "\"receiving_true_checks\":%lu,\"failures\":%lu," @@ -278,13 +286,16 @@ void run(const char* mode, unsigned count, uint32_t sequence) { "\"standby_mean_us\":%.3f,\"apply_mean_us\":%.3f,\"receiving_check_mean_us\":%.3f," "\"start_mode_mean_us\":%.3f,\"start_rx_mean_us\":%.3f," "\"clear_mean_us\":%.3f,\"packet_mean_us\":%.3f,\"set_rx_mean_us\":%.3f," + "\"spot_to_primary\":{\"n\":%lu,\"min_us\":%.3f,\"mean_us\":%.3f,\"max_us\":%.3f}," + "\"spot_to_secondary\":{\"n\":%lu,\"min_us\":%.3f,\"mean_us\":%.3f,\"max_us\":%.3f}," "\"to_primary\":{\"n\":%lu,\"min_us\":%.3f,\"mean_us\":%.3f,\"max_us\":%.3f}," "\"to_secondary\":{\"n\":%lu,\"min_us\":%.3f,\"mean_us\":%.3f,\"max_us\":%.3f}}\n", (unsigned long)sequence, mode, double(secondary.params.freq), unsigned(secondary.params.sf), unsigned(secondary.params.preamble), (unsigned long)configuredTcxoUs, (unsigned long)radioHal.spiHz, radioHal.bulkTransfer ? "true" : "false", - count, completed, (unsigned long)primaryBusyDeferrals, (unsigned long)busyDeferrals, + count, completed, (unsigned long)spotBudgetUs, (unsigned long)longBudgetUs, + (unsigned long)primaryBusyDeferrals, (unsigned long)busyDeferrals, (unsigned long)drainCalls, (unsigned long)drainedPackets, (unsigned long)(driver.busyHighChecks - busyHighBefore), (unsigned long)(driver.receivingTrueChecks - receivingTrueBefore), @@ -320,6 +331,8 @@ void run(const char* mode, unsigned count, uint32_t sequence) { chip.getOptimizedProfileSwitches() == fastBefore ? 0.0 : double(chip.fastSetRxCycles - setRxCyclesBefore) * 1000000.0 / (chip.getOptimizedProfileSwitches() - fastBefore) / SystemCoreClock, + (unsigned long)spot[0].n, spot[0].minUs(), spot[0].meanUs(), spot[0].maxUs(), + (unsigned long)spot[1].n, spot[1].minUs(), spot[1].meanUs(), spot[1].maxUs(), (unsigned long)total[0].n, total[0].minUs(), total[0].meanUs(), total[0].maxUs(), (unsigned long)total[1].n, total[1].minUs(), total[1].meanUs(), total[1].maxUs()); } @@ -370,12 +383,13 @@ void loop() { "\"init_rc\":%d,\"board\":\"Seeed T1000-E LR1110\"," "\"rx_frequencies_mhz\":[909.5,909.75],\"autonomous_tx\":false,\"tx_command\":false," "\"sd_fwid\":%u,\"app_base\":%u,\"cycle_hz\":%lu,\"tcxo_us\":%lu," - "\"spi_hz\":%lu,\"bulk_spi\":%s}\n", + "\"spi_hz\":%lu,\"bulk_spi\":%s,\"last_sequence\":%lu}\n", ready ? "true" : "false", initError, unsigned(*reinterpret_cast(0x300c)), unsigned(*reinterpret_cast(0x3008)), (unsigned long)SystemCoreClock, (unsigned long)configuredTcxoUs, - (unsigned long)radioHal.spiHz, radioHal.bulkTransfer ? "true" : "false"); + (unsigned long)radioHal.spiHz, radioHal.bulkTransfer ? "true" : "false", + (unsigned long)nextSequence); Serial.print(info); } else if (sscanf(line, "fs %u %c", &count, &trailing) == 1 && count <= 1) { chip.hilUseFsOnRetune = count; diff --git a/tools/hil/profile_switch_t1000_spotcheck_results.json b/tools/hil/profile_switch_t1000_spotcheck_results.json new file mode 100644 index 00000000..07e5198b --- /dev/null +++ b/tools/hil/profile_switch_t1000_spotcheck_results.json @@ -0,0 +1,45 @@ +{ + "schema": 1, + "experiment": "startup_four_per_direction_vs_long_run", + "board": "Seeed T1000-E LR1110", + "base_commit": "79fe4362", + "source": "working tree, including the radio2 self-test changes", + "firmware": "profile_switch_t1000_lr1110", + "settings": { + "mode": "both", + "primary": "909.5 MHz, 125 kHz, SF7, CR4/5", + "secondary": "909.75 MHz, 125 kHz, SF8, CR4/5", + "rx_only": true, + "tcxo_us": 200, + "spi_hz": 16000000, + "bulk_spi": true, + "spot_hops_per_direction": 4, + "long_hops_per_trial": 1000, + "trials": 3 + }, + "trial_results": [ + {"spot_mean_us": 504.096, "spot_max_us": 507.156, "spot_model_budget_us": 547, + "long_mean_us": 502.9065, "long_max_us": 503.172, "long_model_budget_us": 547}, + {"spot_mean_us": 504.4395, "spot_max_us": 506.531, "spot_model_budget_us": 547, + "long_mean_us": 502.9065, "long_max_us": 503.125, "long_model_budget_us": 547}, + {"spot_mean_us": 504.502, "spot_max_us": 506.469, "spot_model_budget_us": 547, + "long_mean_us": 502.9065, "long_max_us": 503.125, "long_model_budget_us": 547} + ], + "summary": { + "spot_hops": 24, + "long_hops": 3000, + "spot_mean_us": 504.345833, + "spot_max_us": 507.156, + "long_mean_us": 502.9065, + "long_max_us": 503.172, + "spot_model_budget_us": 547, + "budget_covers_long_max": true, + "failures": 0, + "busy_timeouts": 0, + "rx_errors": 0, + "cache_errors": 0, + "rssi_errors": 0, + "original_full_companion_restored": true + }, + "timing_note": "Cycle-counter results include the post-retune BUSY-low check. The firmware's guarded budget comes from its shorter micros() RX-reentry interval, so it is not exactly 110% of the cycle-counter maximum. The first eight rapid hops omit the 10 ms dwell and 7 ms RSSI settle used by the long-run collector; both delays are outside measured retune time." +}