feat(radio): calibrate dual-profile preambles from live switch timing
This commit is contained in:
1 parent
79fe4362f8
commit
bea968b4fa
16 files changed
+508
-93
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+52
-23
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+41
-19
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+48
-8
@@ -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;n<Profiles::SwitchTestSamplesPerDirection;++n) {
|
||||
p.sampleSwitch(0,1,545);p.sampleSwitch(1,0,545);
|
||||
}
|
||||
assert(p.switchBudgetUs()==600);
|
||||
auto minimum=Profiles::calculateChirpTiming(p.primary,p.secondary.params,0,0,p.switchBudgetUs());
|
||||
assert(minimum.valid);
|
||||
for(unsigned i=0;i<2;++i) {
|
||||
const double symbols=4.6;
|
||||
@@ -50,12 +53,17 @@ int main() {
|
||||
assert(p.automaticPreamble(i)==minimum.preamble[i]);
|
||||
}
|
||||
if (!p.automaticPreambleFits()) continue;
|
||||
if (p.automaticPreamble(0)>Profiles::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;n<Profiles::SwitchTestSamplesPerDirection;++n) {
|
||||
p.sampleSwitch(0,1,545);p.sampleSwitch(1,0,545);
|
||||
}
|
||||
assert(p.listenUs(0)==9421 && p.listenUs(1)==21847);
|
||||
assert(p.chirpTiming().switch_us==600 && p.chirpTiming().loop_us==300 && p.chirpTiming().preamble[1]==64);
|
||||
auto before=p.chirpTiming();const auto wire0=p.preamble(0,32),wire1=p.preamble(1,32);
|
||||
p.longest_switch_us=8428;auto overrun=p.chirpTiming();
|
||||
assert(overrun.switch_us==8428 && overrun.preamble[0]>before.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;n<Profiles::SwitchTestSamplesPerDirection;++n) {
|
||||
p.sampleSwitch(0,1,545);p.sampleSwitch(1,0,545);
|
||||
}
|
||||
p.primary.preamble=64;
|
||||
assert(p.chirpTiming().preamble[0]==64 && p.listenUs(1)>21847);
|
||||
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<Profiles::SwitchTestSamplesPerDirection;++n) {
|
||||
measured.sampleSwitch(0,1,531); measured.sampleSwitch(1,0,480);
|
||||
}
|
||||
assert(measured.switchTestReady() && measured.switchBudgetUs()==585); // ceil(531 * 1.1)
|
||||
assert(measured.preamble(0,32)>=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);
|
||||
}
|
||||
+53
-14
@@ -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<mesh::RadioProfiles::SwitchTestSamplesPerDirection;++i) {
|
||||
radio.p.sampleSwitch(0,1,switch_us);
|
||||
radio.p.sampleSwitch(1,0,switch_us);
|
||||
}
|
||||
assert(radio.p.switchTestReady());
|
||||
}
|
||||
};
|
||||
static void permanentScheduleOrder() {
|
||||
for (bool fail_first_save : {false, true}) {
|
||||
@@ -324,9 +331,14 @@ int main(int argc, char** argv) {
|
||||
Fixture f;
|
||||
assert(!strcmp(f.cmd("get radio2"),"> 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);
|
||||
|
||||
+10
-4
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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<volatile uint16_t*>(0x300c)),
|
||||
unsigned(*reinterpret_cast<volatile uint32_t*>(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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
Reference in new issue
Block a user