feat(radio): calibrate dual-profile preambles from live switch timing

This commit is contained in:
mikecarper committed 2026-09-22 00:09:37 -07:00
1 parent 79fe4362f8
commit bea968b4fa
16 files changed
+508 -93

No files matched your search

+5
View File
@@ -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.
+4
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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;
}
+42 -2
View File
@@ -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; }
+28 -1
View File
@@ -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;
+1 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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());
+31 -2
View File
@@ -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;
+17 -4
View File
@@ -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
+105
View File
@@ -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()
+25 -11
View File
@@ -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."
}