Use 4.6-chirp minimum dwell for both radio profiles

This commit is contained in:
mikecarper committed 2026-09-14 18:06:59 -07:00
1 parent b5440c75c7
commit 239f6a84f5
14 files changed
+545 -70

No files matched your search

+85 -36
View File
@@ -34,17 +34,28 @@ struct RadioProfileConfig {
class RadioProfiles {
public:
// Normal RX uses a microsecond window, so fractional symbols are supported.
static constexpr double SlowListenSymbols = 4.8;
static constexpr uint8_t MinFastListenSymbols = 4;
static constexpr double MinListenSymbols = 4.6;
static constexpr double SlowListenSymbols = MinListenSymbols;
static constexpr double MinFastListenSymbols = MinListenSymbols;
static constexpr uint8_t AcquisitionSymbols = 8;
static constexpr uint16_t MaxPreamble = 65528;
// The integrated fast RX/buffered-SPI path measures ~0.549 ms/hop on XIAO
// and ~8.263 ms on Indicator (expander GPIO), excluding application scheduling.
// Keep existing defaults unchanged during board/application qualification.
// Thus 6 ms is not an upper bound: board-specific budgeting needs follow-up.
// 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.
// Packet receptions can extend a visit; these budgets describe an idle scan.
static constexpr uint32_t SwitchBudgetUs = 6000;
static constexpr uint32_t LoopBudgetUs = 4000;
static constexpr uint32_t SwitchBudgetUs = 600;
static constexpr uint32_t LoopBudgetUs = 300;
struct ChirpTiming {
bool valid = false;
uint8_t slow = 0;
double symbol_us[2] = {};
uint32_t listen_us[2] = {};
double preamble[2] = {}; // Rounded recommendations, not silently capped.
double cycle_us = 0;
uint32_t switch_us = 0, loop_us = 0;
};
RadioProfileParams primary;
RadioProfileConfig secondary;
@@ -104,37 +115,75 @@ class RadioProfiles {
static double roundPreamble(double symbols) {
return ceil((symbols < 32 ? 32 : symbols) / 8.0) * 8.0;
}
double automaticPreamble(uint8_t profile) const {
const double symbol = symbolUs(params(profile));
static uint32_t minimumListenUs(const RadioProfileParams& p, bool slow = false) {
const double symbol = symbolUs(p);
const double symbols = slow ? SlowListenSymbols : MinFastListenSymbols;
return isfinite(symbol) && symbol > 0 && symbol <= UINT32_MAX / symbols
? (uint32_t)ceil(symbols * symbol) : 0;
}
static double preambleForVisits(const RadioProfileParams& p, const RadioProfileParams& other,
bool slow, double own_us, double other_us, double overhead_us) {
const double symbol = symbolUs(p);
if (symbol <= 0) return 32;
// Allow two slow-channel visits per slow preamble: one short detection
// opportunity was insufficient in the production V4 test. The fast
// preamble covers the blind interval during the slow visit and two sets
// of acquisition symbols (the extra eight come from the hardware test).
const double overhead = 2.0 * SwitchBudgetUs + LoopBudgetUs;
if (profile == slowerProfile()) {
const double fast_visit = MinFastListenSymbols * symbolUs(params(profile ^ 1));
return roundPreamble(2.0 * (SlowListenSymbols * symbol + fast_visit + overhead) / symbol);
}
const double slow_visit = SlowListenSymbols * symbolUs(params(profile ^ 1));
double symbols = roundPreamble((slow_visit + overhead) / symbol + 2 * AcquisitionSymbols);
// Production V4 + XIAO testing lost SF8/500 packets at 72 and 80;
// 88 passed with the paired SF7/62.5 profile. Keep that measured floor
// even when the theoretical blind interval permits a shorter preamble.
const auto& other = params(profile ^ 1);
if (params(profile).sf == 8 && params(profile).bw == 500
&& other.sf == 7 && other.bw == 62.5f && symbols < 88) symbols = 88;
return symbols;
double result = slow ? roundPreamble(2.0 * (own_us + other_us + overhead_us) / symbol)
: roundPreamble((other_us + overhead_us) / symbol + 2 * AcquisitionSymbols);
// Production V4/XIAO empirical floor; retain prior measured margin.
if (!slow && p.sf == 8 && p.bw == 500 && other.sf == 7 && other.bw == 62.5f && result < 88)
result = 88;
return result;
}
// A two-profile idle-scan timing policy, NOT a fitted zero-loss model.
// Use a 4.6-symbol minimum on both profiles. Reserve
// two return opportunities on the slow profile, and blind time + 16
// acquisition symbols on the fast one. The 4.6/300-us pair bench received
// 197/200: a sweep fitting inside 32 is not a guarantee of acquisition.
// Explicit visit times let diagnostics account for the actual scheduler,
// 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) {
ChirpTiming t;
if (!valid(a) || !valid(b)) return t;
t.symbol_us[0] = symbolUs(a); t.symbol_us[1] = symbolUs(b);
t.slow = t.symbol_us[0] >= t.symbol_us[1] ? 0 : 1;
const uint32_t minimum_a = minimumListenUs(a, t.slow == 0);
const uint32_t minimum_b = minimumListenUs(b, t.slow == 1);
t.listen_us[0] = visit_a_us > minimum_a ? visit_a_us : minimum_a;
t.listen_us[1] = visit_b_us > minimum_b ? visit_b_us : minimum_b;
t.switch_us = switch_us; t.loop_us = loop_us;
const double overhead = 2.0 * switch_us + loop_us;
t.cycle_us = double(t.listen_us[0]) + t.listen_us[1] + overhead;
t.preamble[0] = preambleForVisits(a, b, t.slow == 0, t.listen_us[0], t.listen_us[1], overhead);
t.preamble[1] = preambleForVisits(b, a, t.slow == 1, t.listen_us[1], t.listen_us[0], overhead);
t.valid = true;
return t;
}
double automaticPreamble(uint8_t profile) const {
if (profile > 1) return 32;
// Keep the hot retune path to one profile's calculation; the full
// validated diagnostic structure is only needed by CLI/reporting.
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);
}
uint32_t listenUs(uint8_t profile, uint16_t slow_preamble = 0) const {
const uint8_t slow = slowerProfile();
const double minimum = symbolUs(params(profile))
* (profile == slow ? SlowListenSymbols : MinFastListenSymbols);
const double minimum = minimumListenUs(params(profile), enabled() && profile == slow);
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 - SlowListenSymbols)
* symbolUs(params(slow)) - 2.0 * SwitchBudgetUs - LoopBudgetUs;
return available > minimum ? (uint32_t)floor(available) : (uint32_t)ceil(minimum);
const double available = double(slow_preamble) / 2.0 * symbolUs(params(slow))
- 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);
}
uint16_t preamble(uint8_t profile, uint16_t single_profile_default) const {
const auto& p = params(profile);
@@ -153,9 +202,9 @@ class RadioProfiles {
}
if (enabled()) {
const uint8_t slow = slowerProfile();
const double available = (double(preamble(slow, 32)) / 2.0 - SlowListenSymbols)
* symbolUs(params(slow)) - 2.0 * SwitchBudgetUs - LoopBudgetUs;
if (available < MinFastListenSymbols * symbolUs(params(slow ^ 1))) return false;
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;
}
return true;
}
+4
View File
@@ -2644,6 +2644,7 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
const uint32_t seconds = (uint32_t)temp_timeout_mins * 60UL;
mesh::RepeaterRadioTiming::formatDuration(duration, sizeof(duration), seconds);
snprintf(reply, 160, "OK - temp params for %s", duration);
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
_callbacks->appendTempRadioTimingNote(reply, 160, seconds);
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
} else {
@@ -3668,6 +3669,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
strcpy(reply, "OK - reboot to apply");
}
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
} else {
strcpy(reply, "Error, invalid radio params");
}
@@ -3686,6 +3688,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
_callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply, preamble);
if (strncmp(reply, "OK", 2) == 0) {
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
}
@@ -3704,6 +3707,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep
} else {
_callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply, preamble);
if (strncmp(reply, "OK", 2) == 0) {
_radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble);
appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw);
}
}
+53 -4
View File
@@ -208,20 +208,50 @@ bool RadioProfileCLI::parseSuffix(const char* input, unsigned fields, char* lega
}
void RadioProfileCLI::appendPreamble(char* reply, size_t capacity, uint8_t profile) const {
if (!radio_ || !radio_->profiles()) return;
if (!capacity || !radio_ || !radio_->profiles()) return;
const size_t used = strlen(reply);
if (used < capacity) snprintf(reply + used, capacity - used, ",preamble=%u",
(unsigned)radio_->profilePreamble(profile));
appendChirpWarning(reply, capacity, *radio_->profiles());
}
void RadioProfileCLI::appendSavedPreamble(char* reply, size_t capacity, uint8_t sf, float bw) const {
if (!radio_ || !radio_->profiles()) return;
if (!capacity || !radio_ || !radio_->profiles()) return;
auto preview = *radio_->profiles();
preview.primary.sf = sf; preview.primary.bw = bw;
preview.primary.preamble = primary_preamble_;
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)");
appendChirpWarning(reply, capacity, preview);
}
void RadioProfileCLI::appendPrimaryChirpWarning(char* reply, size_t capacity, uint8_t sf, float bw,
uint16_t preamble) const {
if (!radio_ || !radio_->profiles()) return;
auto preview = *radio_->profiles();
preview.primary.sf = sf; preview.primary.bw = bw; preview.primary.preamble = preamble;
appendChirpWarning(reply, capacity, preview);
}
void RadioProfileCLI::appendChirpWarning(char* reply, size_t capacity, const RadioProfiles& preview) {
if (!capacity || !preview.enabled()) return;
const auto timing = preview.chirpTiming();
if (!timing.valid) return;
size_t 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;
if (used < capacity) {
if (a && b) snprintf(reply + used, capacity - used, "; WARN recommended preamble: radio=%.0f,radio2=%.0f",
timing.preamble[0], timing.preamble[1]);
else if (a || b) snprintf(reply + used, capacity - used, "; WARN recommended preamble: %s=%.0f",
a ? "radio" : "radio2", timing.preamble[a ? 0 : 1]);
}
used = strlen(reply);
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");
}
void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProfileConfig& config,
@@ -240,6 +270,7 @@ void RadioProfileCLI::formatConfig(char* reply, size_t capacity, const RadioProf
(unsigned long)(minutes / 1440), (unsigned long)(minutes / 60 % 24),
(unsigned long)(minutes % 60), (unsigned long)minutes);
}
appendChirpWarning(reply, capacity, preview);
}
bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity) {
@@ -261,9 +292,24 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity)
const bool crossing = !strcmp(key, "radio2.cross");
const bool status = !strcmp(key, "radio2.status");
const bool scan = !strcmp(key, "radio2.scan");
if (!base && !temporary && !scheduled && !crossing && !status && !scan) return false;
const bool timing = !strcmp(key, "radio2.timing") || !strcmp(key, "radio.timing");
if (!base && !temporary && !scheduled && !crossing && !status && !scan && !timing) return false;
if (!radio_ || !radio_->profiles()) { snprintf(reply, capacity, "Error: radio profiles unsupported"); return true; }
if (command == text && *args && verb == Get && (temporary || base)) verb = Set;
if (timing) {
if (verb != Get || *args) { snprintf(reply, capacity, "Error: radio timing is read-only"); return true; }
const auto& p = *radio_->profiles();
const auto t = p.chirpTiming();
if (!p.enabled()) snprintf(reply, capacity, "> off");
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);
appendChirpWarning(reply, capacity, p);
}
return true;
}
if (crossing) {
if (verb == Get && !*args) {
snprintf(reply, capacity, "> %s", cross_ == RadioCrossMode::Auto ? "auto" : cross_ == RadioCrossMode::On ? "on" : "off");
@@ -284,6 +330,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity)
snprintf(reply, capacity, "> slow=%s; listen_us=%lu,%lu; preamble=%u,%u",
slow ? "radio2" : "radio", (unsigned long)p.listenUs(0, preamble),
(unsigned long)p.listenUs(1, preamble), radio_->profilePreamble(0), radio_->profilePreamble(1));
appendChirpWarning(reply, capacity, p);
}
return true;
}
@@ -395,7 +442,8 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity)
auto& s = schedules_[first+i]; s = {};
s.config = config; s.start = start; s.end = end; s.temporary = scheduled_temp;
s.remaining_ms = scheduled_temp ? (end-epoch)*1000UL : 0; s.active = true;
snprintf(reply, capacity, "OK - %s %u queued, preamble=%u", key, i+1, preview.preamble(1, 32)); return true;
snprintf(reply, capacity, "OK - %s %u queued, preamble=%u", key, i+1, preview.preamble(1, 32));
appendChirpWarning(reply, capacity, preview); return true;
}
snprintf(reply, capacity, "Error: all four schedule slots occupied"); return true;
}
@@ -415,6 +463,7 @@ bool RadioProfileCLI::handle(const char* command, char* reply, size_t capacity)
publish();
snprintf(reply, capacity, "OK - radio2 %s; preamble=%u", modeName(config.mode), preview.preamble(1, 32));
} else snprintf(reply, capacity, "Error: settings could not be saved");
if (!strncmp(reply, "OK", 2)) appendChirpWarning(reply, capacity, preview);
return true;
}
+3
View File
@@ -64,6 +64,9 @@ class RadioProfileCLI {
uint32_t secondaryRemainingSeconds() const { return (temp_remaining_ms_ + 999) / 1000; }
void appendPreamble(char* reply, size_t capacity, uint8_t profile = 0) const;
void appendSavedPreamble(char* reply, size_t capacity, uint8_t sf, float bw) const;
static void appendChirpWarning(char* reply, size_t capacity, const RadioProfiles& preview);
void appendPrimaryChirpWarning(char* reply, size_t capacity, uint8_t sf, float bw,
uint16_t preamble) const;
// Strip exactly one optional trailing preamble before a legacy primary
// parser runs. Missing means auto. Reject extra fields and malformed input.
static bool parseSuffix(const char* input, unsigned fields, char* legacy,