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

+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);