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
+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);
|
||||
|
||||
Reference in new issue
Block a user