diff --git a/docs/radio_dwell_policy_validation.md b/docs/radio_dwell_policy_validation.md new file mode 100644 index 00000000..f1a0209f --- /dev/null +++ b/docs/radio_dwell_policy_validation.md @@ -0,0 +1,62 @@ +# Automatic dwell policy: 4.6 chirps on both profiles + +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. +The fast visit receives the remaining half-slow-preamble budget, subject to +the same 4.6-symbol minimum. The nominal allowances are 600 us per switch and +300 us per cycle, not added delays. + +The scheduler, automatic preambles, configuration acceptance and diagnostic +calculator use the same minima. Preserve explicit preambles, safe-airtime +validation, too-short slow-preamble rejection, recommendation rounding to +multiples of eight (minimum 32), and the existing SF8/500 versus SF7/62.5 +88-symbol recommendation floor. Measured switching overruns raise advisory +warnings without rewriting configured values. Single-profile RX still does +not perform a scan. + +For SF7/62.5 plus SF8/500 with slow preamble 32, the allocation remains +9,421 us slow and 21,847 us fast, in either profile order. The fast visit +already exceeds 4.6 fast symbols. Raising the fast minimum matters when +less budget is available: equal SF7/500 profiles with slow preamble 30 no +longer fit, because only 1,162 us would remain versus the new 1,178 us floor. +Slow preamble 32 fits with a 1,418 us fast visit. + +## Hardware context + +The preceding V4 RX / fixed RAK4631 and T1000-E TX bench used explicit +32-symbol preambles on both profiles, 100 stationary controls per profile +and a complete 100/profile scan. Controls passed 100/100 each; scanning +received slow 97/100 and fast 100/100 (197/200 total). Switching averaged +582 us and peaked at 827 us. Successful-packet mean SNR was +12.27/+13.47 dB. +No RF retry or USB result replay was used. Radios were left idle. + +That test used the same 9,421/21,847 us windows, so the higher fast floor +does not change this particular allocation. It does not establish lossless +performance or qualify other SF/BW combinations. Raw capture SHA-256: +`1c2e0eae3f304fcaeb6addfeef4f2f09a3c2bec8d78c60d3f151c952d0f6d5f5`. +No additional RF run is required to apply the user-requested policy change. + +## Software verification + +The portable C++ test sweeps 1,600 SF/BW pairs and checks both 4.6-symbol +minima, exact fast-window allocation, preamble/diagnostic consistency and +accepted return deadlines. It also covers reversed/equal symbol times, +explicit preambles, newly infeasible short settings, long requested visits, +invalid parameters and advisory-only switching overruns. CLI tests exercise +warnings, both timing aliases, saved/temporary configuration, persistence +and bounded replies. Native integration covers routing, retries and packet +ownership alongside the timing model. + +Verification completed on 2026-09-14 (local date): + +- Exact commit snapshot: 24/24 host production tests passed, including primary + CLI settings, shared timing, command dispatch, persistence and reply checks. +- Native radio/routing/retry integration: 63/63 passed. +- Existing HIL host regressions: 64/64 passed in the working checkout. +- Both firmware builds passed from the isolated commit snapshot, without the + unrelated local lab/Wi-Fi/extra modulation-cache edits: V4 repeater RAM + 125,336 bytes / flash 1,597,497 bytes; USB companion RAM 62,452 bytes / + flash 828,257 bytes. Both passed runtime-RAM checks. +- Staged whitespace and documentation-link checks passed. No radios were + flashed for this function update. diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index 0b0cd41e..b7050aa2 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -124,38 +124,119 @@ 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.8 symbols on the slower -profile**, then spends the remaining safe time on the faster profile. Slower +32-symbol minimum. 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 6 ms per switch and 4 ms of main-loop +visits within that preamble, reserving 0.6 ms per switch and 0.3 ms of main-loop margin. Fast-channel transmit preambles include sixteen acquisition symbols. For a primary SF7 / 62.5 kHz profile: -| Secondary profile | Automatic secondary preamble | Tested explicit preamble | Primary preamble | +| Secondary profile | Automatic secondary preamble | Previously tested explicit preamble | Primary preamble | | --- | ---: | ---: | ---: | -| SF9 / 500 kHz | 48 | 32 | 32 | +| SF9 / 500 kHz | 32 | 32 | 32 | | SF8 / 500 kHz | 88 | 64 | 32 | -| SF7 / 500 kHz | 120 | 80 | 32 | +| SF7 / 500 kHz | 64 | 80 | 32 | -With preamble 32 on that slower profile, its visit is 9.831 ms and the faster -visit is 6.937 ms, plus the actual switching time. Increasing the slow preamble +With preamble 32 on that slower profile, its visit is 9.421 ms and the faster +visit is 21.847 ms, plus the actual switching time. Increasing the slow preamble also increases the time available on the fast channel. If the symbols are equal in length, the primary profile goes first. Detected packets hold the current channel until reception finishes. The SF8 / 500 kHz value also retains the measured 88-symbol floor from the production V4/XIAO tests, where 72 and 80 each missed a packet. -The shorter explicit values come from the +The explicit values come from the [production validation](radio_profiles_validation.md): the final fast-channel -attempts each received 50/50 packets at 4.8 slow symbols. Automatic values retain -the additional switching and loop-jitter margin. +attempts each received 50/50 packets at 4.8 slow symbols. Automatic values use +the switching and loop-jitter allowances below. +Those explicit-preamble tests used the older 4.8-symbol policy; they do not +validate the new 4.6-symbol slow dwell / 0.3 ms reserve. The +[4.6/0.3 ms two-profile bench](radio_dwell_policy_validation.md#hardware-context) received 197/200 +at SF7/62.5 + SF8/500 with explicit 32-symbol preambles. The newer four-channel +5.1/6.1/7.7 tests also had misses, so this is a timing policy, not a claim of zero packet loss. The automatic fast preamble covers the blind interval during the slow visit, both switches and the acquisition margin. These settings do not guarantee reception during overlapping packets or long pauses in the firmware loop. **Transmitters on each channel also need a long enough preamble.** Changing the receiver's setting does not lengthen packets sent by other nodes. +### Chirp calculator and warnings + +`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`: + +```text +> chirps=4.60,85.34; need=32,64; switch=600us; loop=300us (estimate); 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: + +```text +OK - radio2 rx; preamble=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. + +The shared `RadioProfiles::calculateChirpTiming()` function accepts both tuples, +optional visit times in microseconds, a per-switch allowance and loop margin. +Using microseconds throughout: + +```text +T[i] = 2^SF[i] * 1000 / BW_kHz[i] +slow = profile with the longer T (radio wins ties) +L[slow] = max(requested_visit_us[slow], ceil(4.6 * T[slow])) +L[fast] = max(requested_visit_us[fast], ceil(4.6 * T[fast])) +O = 2 * switch_us + loop_margin_us +C = L[radio] + L[radio2] + O + +P[slow] = round_up_to_8(max(32, 2 * C / T[slow])) +P[fast] = round_up_to_8(max(32, (L[slow] + O) / T[fast] + 16)) +``` + +The previous SF8/500 versus SF7/62.5 measured 88-symbol floor remains in force. +Automatic selection uses the minimum visits to choose a preamble, then allocates +the fast visit from half the slow preamble's duration. Diagnostics feed the +actual allocated visits back into the same function, including longer visits +caused by an explicit slow preamble. Too-short slow preambles that cannot fit +both minimum visits remain rejected by the existing configuration validation. + +The automatic scheduler uses these same role-specific minima and computes: + +```text +slow_dwell = ceil(4.6 * T[slow]) +fast_dwell = max(ceil(4.6 * T[fast]), + floor(P_configured[slow] * T[slow] / 2 - slow_dwell - O)) +``` + +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. +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 +stalls can be made safe by a finite preamble recommendation. + +See the [implementation and validation notes](radio_dwell_policy_validation.md). + ## Schedule the second profile Use UTC Unix timestamps (seconds): @@ -199,7 +280,7 @@ profile. Packets/retries bound to an expired or changed session are discarded. Dual mode suspends RX power saving and MCU idle sleep so receive visits can run promptly. The saved RX power-saving setting returns when profile 2 is off. The scan uses **normal receive**, following the -[CAD/RX measurements](cad_scan_validation.md): 4.8 symbols on the slower +[CAD/RX measurements](cad_scan_validation.md), with the updated 4.6-symbol dwell on the slower channel, followed by the remaining fast-channel visit. It does not use CAD for scanning. The separate CAD check before an initial transmission still follows `cad`. @@ -212,8 +293,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 6 ms switch budget is still a default scheduling allowance, not a -forced delay, and has not been reduced for this change. The earlier standalone +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 [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 @@ -247,8 +328,9 @@ Other variants do not opt in to faster SPI or the fast-RX state machine. 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 -generic 6 ms allowance is not an upper bound for every board; its scheduling -and preambles need board-specific qualification, not a smaller common budget. +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. Single-channel noise-floor calibration and RSSI interference comparison are suspended while scanning. Retries use the radio's preamble/header activity diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index bfbc8a40..8a2fd1fa 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3060,6 +3060,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, preview.primary.preamble = _temp_radio_preamble; const size_t used = strlen(reply); if (used < reply_size) snprintf(reply + used, reply_size - used, ",preamble=%u", preview.preamble(0, rxPowerSavingPreambleForParams(_temp_radio_sf, _temp_radio_bw))); + mesh::RadioProfileCLI::appendChirpWarning(reply, reply_size, preview); } else if (isTempRadioActive()) { uint32_t seconds = (_temp_radio_revert_at - _ms->getMillis()) / 1000UL; snprintf(reply, reply_size, "TempRadio active: %.3f,%.2f,%u,%u %lus left", @@ -3094,7 +3095,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, "ERR usage: tempradio freq,bw,sf,cr,minutes (minutes 1-10080)"); return true; } - if (scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size)) _temp_radio_preamble = preamble; + if (scheduleTempRadio(freq, bw, sf, cr, timeout_mins, reply, reply_size)) { + _temp_radio_preamble = preamble; + _radio_profiles.appendPrimaryChirpWarning(reply, reply_size, sf, bw, preamble); + } return true; } @@ -8333,6 +8337,9 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } } if (!strcmp(command, "get radio")) _radio_profiles.appendSavedPreamble(reply, reply_capacity, _prefs.sf, _prefs.bw); + if (!strncmp(command, "set radio ", 10) && !strncmp(reply, "OK", 2)) + _radio_profiles.appendPrimaryChirpWarning(reply, reply_capacity, _prefs.sf, _prefs.bw, + _radio_profiles.primaryPreamble()); return true; } diff --git a/src/RadioProfiles.h b/src/RadioProfiles.h index a3a1ddf2..f165ff3f 100644 --- a/src/RadioProfiles.h +++ b/src/RadioProfiles.h @@ -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; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f1ee7d33..81d72430 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -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); } } diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index 9036167c..7416ede6 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -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; } diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index 3fc8870f..3dd678a9 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -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, diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index 2f5dc399..e615d7dd 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -17,6 +17,15 @@ struct RadioProfileCLI { bool savePrimaryPreamble(uint16_t) { return true; } void appendPreamble(char*, size_t) {} void appendSavedPreamble(char*, size_t, uint8_t, float) {} + mutable unsigned warning_calls = 0; + mutable uint8_t warning_sf = 0; + mutable float warning_bw = 0; + mutable uint16_t warning_preamble = 0; + void appendPrimaryChirpWarning(char* reply, size_t capacity, uint8_t sf, float bw, uint16_t preamble) const { + ++warning_calls; warning_sf = sf; warning_bw = bw; warning_preamble = preamble; + const size_t used = strlen(reply); + if (used < capacity) snprintf(reply + used, capacity - used, "; WARN recommended preamble: radio=32"); + } bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) const { return true; } }; } @@ -119,10 +128,19 @@ int main() { unsigned saves=cli.callbacks.saves; cli.call(sender,"set radio 916,125,8,6",reply); assert(!strncmp(reply,"OK",2) && cli.callbacks.saves==saves+1); + assert(strstr(reply,"WARN recommended preamble: radio=32")); + assert(cli._radio_profiles.warning_calls==1 && cli._radio_profiles.warning_sf==8); + assert(cli._radio_profiles.warning_bw==125 && cli._radio_profiles.warning_preamble==0); cli.call(sender,"get radio",reply); float freq=0,bw=0; int sf=0,cr=0; assert(sscanf(reply,"> %f,%f,%d,%d",&freq,&bw,&sf,&cr)==4); assert(freq==916 && bw==125 && sf==8 && cr==6); + cli.call(sender,"set radio 916,125,8,6,16",reply); + assert(strstr(reply,"WARN recommended preamble: radio=32")); + assert(cli._radio_profiles.warning_calls==2 && cli._radio_profiles.warning_preamble==16); + cli.call(sender,"set radio 916,125,13,6",reply); + assert(!strncmp(reply,"Error",5) && !strstr(reply,"WARN")); + assert(cli._radio_profiles.warning_calls==2); saves=cli.callbacks.saves; cli.call(sender,"set extra.sf 9,10",reply); #if defined(USE_LR2021) diff --git a/test/fixtures/radio_profiles/chirp_math.cpp b/test/fixtures/radio_profiles/chirp_math.cpp new file mode 100644 index 00000000..398c43ff --- /dev/null +++ b/test/fixtures/radio_profiles/chirp_math.cpp @@ -0,0 +1,101 @@ +#include +#include +#include +#include + +using Profiles=mesh::RadioProfiles; +mesh::RadioProfileParams params(unsigned sf,float bw) { + mesh::RadioProfileParams p; p.freq=909.5;p.sf=sf;p.bw=bw;p.cr=5;return p; +} +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); + assert(t.valid && t.slow==0 && t.symbol_us[0]==8192); + assert(t.listen_us[0]==37684 && t.listen_us[1]==37684); + assert(t.preamble[0]==32 && t.preamble[1]==32); + // The function accepts board-specific budgets; a V4 measurement must not + // become a universal allowance for the GPIO-expander Indicator. + a=params(6,125);b=a; + auto fast=Profiles::calculateChirpTiming(a,b,0,0,1000,4000); + auto indicator=Profiles::calculateChirpTiming(a,b,0,0,9000,4000); + assert(indicator.preamble[0]>fast.preamble[0]); + assert(indicator.preamble[1]>fast.preamble[1]); + assert(fast.listen_us[0]>=4.6*512 && fast.listen_us[1]>=4.6*512); + b.bw=0;assert(!Profiles::calculateChirpTiming(a,b).valid); + b.bw=NAN;assert(!Profiles::calculateChirpTiming(a,b).valid); + b=params(13,125);assert(!Profiles::calculateChirpTiming(a,b).valid); + b=params(10,1e-30f);assert(Profiles::minimumListenUs(b)==0); + a=params(7,62.5);b=params(8,500); + t=Profiles::calculateChirpTiming(a,b); + assert(t.preamble[1]==88); // Existing measured acquisition floor retained. + auto swapped=Profiles::calculateChirpTiming(b,a); + assert(swapped.slow==1 && swapped.preamble[0]==88); + assert(swapped.preamble[1]==t.preamble[0]); + for (float bw0: {7.8f,62.5f,125.0f,250.0f,500.0f}) + for (float bw1: {7.8f,62.5f,125.0f,250.0f,500.0f}) + 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); + assert(minimum.valid); + for(unsigned i=0;i<2;++i) { + const double symbols=4.6; + assert(minimum.listen_us[i]==std::ceil(symbols*minimum.symbol_us[i])); + assert(minimum.preamble[i]>=32 && std::fmod(minimum.preamble[i],8)==0); + assert(p.automaticPreamble(i)==minimum.preamble[i]); + } + if (!p.automaticPreambleFits()) 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); + 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); + assert(actual.preamble[0]<=p.preamble(0,32)); + assert(actual.preamble[1]<=p.preamble(1,32)); + } + Profiles p;p.primary=params(7,62.5);p.secondary.params=params(7,500); + p.secondary.mode=mesh::RadioProfileMode::Rx; + 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; + 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); + p.secondary.mode=mesh::RadioProfileMode::Off; + assert(!p.chirpTiming().valid); + assert(p.listenUs(0)==9421); // Same minimum helper; single-profile RX does not scan. + // Exact tested SF8/500 pair, explicit 32 preserved; order is not hard-coded. + p.primary=params(7,62.5);p.primary.preamble=32; + p.secondary.params=params(8,500);p.secondary.params.preamble=32; + p.secondary.mode=mesh::RadioProfileMode::Rx; + assert(p.automaticPreambleFits() && p.listenUs(0)==9421 && p.listenUs(1)==21847); + assert(p.preamble(0,32)==32 && p.preamble(1,32)==32); + assert(p.chirpTiming().preamble[1]==88); + std::swap(p.primary,p.secondary.params); + assert(p.slowerProfile()==1 && p.listenUs(1)==9421 && p.listenUs(0)==21847); + p.secondary.params.preamble=8; + assert(!p.automaticPreambleFits()); + // This formerly fit the 4.1 fast floor (1050 us), but not 4.6 (1178 us). + p.primary=params(7,500);p.secondary.params=params(7,500); + p.primary.preamble=30; + assert(p.listenUs(0)==1178 && p.listenUs(1)>=1178); + assert(!p.automaticPreambleFits()); + p.primary.preamble=32; + 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); +} diff --git a/test/fixtures/radio_profiles/cli_test.cpp b/test/fixtures/radio_profiles/cli_test.cpp index 7a58a79e..a6d863fa 100644 --- a/test/fixtures/radio_profiles/cli_test.cpp +++ b/test/fixtures/radio_profiles/cli_test.cpp @@ -47,10 +47,17 @@ int main() { Fixture f; assert(!strcmp(f.cmd("get radio2"),"> off")); assert(!strcmp(f.cmd("get radio2.cross"),"> auto")); - f.cmd("set radio2 910.5,500,7,5,rxtx"); + assert(strstr(f.cmd("set radio2 910.5,500,7,5,rxtx"),"WARN recommended preamble: radio2=64")); assert(!f.radio.p.enabled()); f.advance(2000); assert(f.radio.p.enabled()); - assert(strstr(f.cmd("get radio2"),"rxtx,120 (auto)")); - assert(strstr(f.cmd("get radio2.scan"),"slow=radio; listen_us=9831,6937")); + assert(strstr(f.cmd("get radio2"),"rxtx,64 (auto)")); + 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")); + assert(strstr(f.reply,"loop=300us")); + assert(strstr(f.reply,"estimate")); + assert(strstr(f.reply,"WARN recommended preamble: radio2=64")); + char timing_reply[160]; strcpy(timing_reply, f.reply); + assert(!strcmp(f.cmd("get radio.timing"),timing_reply)); f.cmd("set tempradio2 911.5,500,8,5,rx,2,64"); assert(!f.radio.p.secondary_temporary); f.advance(2000); assert(f.radio.p.secondary_temporary && f.radio.p.secondary.params.freq==911.5f); @@ -74,8 +81,72 @@ int main() { f.cmd("set tempradio2 910.5,500,7,5,rxtx,0",false); f.cmd("set tempradio2 910.5,500,7,5,rxtx,10081",false); f.cmd("set radio2.status 1",false); + f.cmd("set radio2.timing 1",false); + f.cmd("set radio.timing 1",false); + assert(strstr(f.cmd("get radio2.timing extra"),"read-only")); assert(!f.cli.handle("set radio2junk 910.5",f.reply)); } + { + 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")); + f.advance(2000); + 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=")); + 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=")); + // 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; + mesh::RadioProfileCLI::appendChirpWarning(bytes,n,f.radio.p); + assert(memchr(bytes,0,n)); assert(bytes[n]==0x55); + } + 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; + 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")); + } + { + 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")); + f.advance(2000); + 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")); + 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. + f.radio.p.primary.sf=5;f.radio.p.primary.bw=500; + f.radio.p.secondary.params.sf=5;f.radio.p.secondary.params.bw=500; + assert(strstr(f.cmd("get radio2.timing"),"WARN recommended preamble: radio=")); + assert(strstr(f.reply,",radio2=")); + } + { + 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")); + f.advance(2000); + 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.cmd("set radio2 910.5,500,9,5,rxtx,40"); f.advance(2000); diff --git a/test/fixtures/radio_profiles/mesh_tests.h b/test/fixtures/radio_profiles/mesh_tests.h index da973e2d..034888fb 100644 --- a/test/fixtures/radio_profiles/mesh_tests.h +++ b/test/fixtures/radio_profiles/mesh_tests.h @@ -94,17 +94,19 @@ TEST(RadioProfiles, CrossPolicyAndReceiveOnlyMatrix) { } } -TEST(RadioProfiles, AutomaticPreamblesIncludeMeasuredSwitchingMargin) { +TEST(RadioProfiles, AutomaticPreamblesIncludeNominalSwitchingMargin) { DualProfileTestRadio radio; auto& p = radio.config; - const uint16_t expected[] = {120, 88, 48}; + EXPECT_EQ(600, p.SwitchBudgetUs); + EXPECT_EQ(300, p.LoopBudgetUs); + const uint16_t expected[] = {64, 88, 32}; for (int sf = 7; sf <= 9; ++sf) { p.secondary.params.sf = sf; EXPECT_EQ(expected[sf-7], p.preamble(1, 32)); EXPECT_EQ(32, p.preamble(0, 32)); EXPECT_EQ(0, p.preamble(1, 32) % 8); - EXPECT_EQ(9831, p.listenUs(0)); - EXPECT_EQ(6937, p.listenUs(1)); + 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), p.preamble(0, 32) * p.symbolUs(p.primary)); EXPECT_LE(p.listenUs(1) + 2 * p.SwitchBudgetUs + p.LoopBudgetUs @@ -122,10 +124,10 @@ TEST(RadioProfiles, SlowerProfileDeterminesOrderAndFasterReceiveWindow) { EXPECT_EQ(0, p.slowerProfile()); std::swap(p.primary, p.secondary.params); EXPECT_EQ(1, p.slowerProfile()); - EXPECT_EQ(9831, p.listenUs(1)); - EXPECT_EQ(6937, p.listenUs(0)); + EXPECT_EQ(9421, p.listenUs(1)); + EXPECT_EQ(21847, p.listenUs(0)); p.secondary.params.preamble = 40; - EXPECT_EQ(15129, p.listenUs(0)); + EXPECT_EQ(30039, p.listenUs(0)); p.secondary.params.preamble = 8; EXPECT_FALSE(p.automaticPreambleFits()); // no safe time for the faster channel } diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index bb06e0b0..d7f2b588 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -1,11 +1,14 @@ """Compile real Companion command dispatch with local and LoRa callers.""" from pathlib import Path +import os import subprocess import tempfile import unittest from test_replay_reset_integration import extract_braced ROOT = Path(__file__).resolve().parents[1] +# MinGW does not ship these runtimes; keep sanitizer coverage on Linux. +SANITIZER_FLAGS = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-pie", "-no-pie"] PREAMBLE = r''' #include @@ -32,6 +35,7 @@ struct RadioProfileCLI { bool savePrimaryPreamble(uint16_t) { return true; } uint16_t primaryPreamble() const { return 0; } void appendSavedPreamble(char*, size_t, uint8_t, float) {} + void appendPrimaryChirpWarning(char*, size_t, uint8_t, float, uint16_t) const {} }; struct Packet {}; void resetLazyPersistenceAfterSuccess(unsigned& when, uint8_t& failures) { when=0; failures=0; } @@ -246,7 +250,7 @@ int main() { binary = Path(temp) / "test" result = subprocess.run([ "c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"), - "-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary), + *SANITIZER_FLAGS, "-o", str(binary), ], input=code, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) result = subprocess.run([str(binary)], capture_output=True, text=True) @@ -289,7 +293,7 @@ int main() { binary = Path(temp) / "test" result = subprocess.run([ "c++", "-std=c++17", "-include", "initializer_list", "-x", "c++", "-", - "-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary), + *SANITIZER_FLAGS, "-o", str(binary), ], input=code, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) result = subprocess.run([str(binary)], capture_output=True, text=True) @@ -308,7 +312,7 @@ int main() { "c++", "-std=c++17", "-x", "c++", "-", "-I"+str(ROOT / "src"), "-DENABLE_PRIVATE_KEY_EXPORT="+str(export), *(["-DWITH_WEBCONFIG=1"] if webconfig else []), - "-fsanitize=address,undefined", "-fno-pie", "-no-pie", "-o", str(binary), + *SANITIZER_FLAGS, "-o", str(binary), ], input=PREAMBLE+implementation+SCENARIOS, text=True, capture_output=True) self.assertEqual(result.returncode, 0, result.stderr) result = subprocess.run([str(binary)], capture_output=True, text=True) diff --git a/test/test_radio_chirp_math.py b/test/test_radio_chirp_math.py new file mode 100644 index 00000000..6f591735 --- /dev/null +++ b/test/test_radio_chirp_math.py @@ -0,0 +1,22 @@ +"""Compile the portable production chirp math, independent of radio hardware.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +ROOT=Path(__file__).resolve().parents[1] + +class RadioChirpMathTest(unittest.TestCase): + def test_floor_budgets_rounding_and_profile_order(self): + compiler=shutil.which('g++') or shutil.which('clang++') + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory() as folder: + exe=Path(folder)/'chirps.exe' + done=subprocess.run([compiler,'-std=c++17','-Wall','-Wextra','-I',str(ROOT/'src'), + str(ROOT/'test/fixtures/radio_profiles/chirp_math.cpp'),'-o',str(exe)],capture_output=True,text=True) + self.assertEqual(done.returncode,0,done.stderr) + done=subprocess.run([str(exe)],capture_output=True,text=True) + self.assertEqual(done.returncode,0,done.stderr) + +if __name__=='__main__':unittest.main() diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 0beefe67..8b5b2e2c 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -302,6 +302,7 @@ struct RadioProfileCLI { return cli::parseRadioPreambleSuffix(input, fields, legacy, size, preamble); } bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) { return true; } + void appendPrimaryChirpWarning(char*, size_t, uint8_t, float, uint16_t) const {} }; class Utils { public: static int parseTextParts(char*, const char*[], int, char separator=','); }; @PARTS@