From 4f76345b676bc8cfd80ebecf4a075829c9ba6f84 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Mon, 26 Oct 2026 00:38:13 -0700 Subject: [PATCH] Apply infrastructure radio changes after a two-second reply handoff Persist set freq and set radio atomically, then apply through the existing safe radio scheduler without rebooting. Retain the old tuple until the delay and reply handoff finish, including queued room/sensor replies and TX-complete callbacks. End temporary sessions when accepting a permanent change. Display applied RF settings even when radio2 is off. Share radio validation across immediate and scheduled setters to preserve STM32 Mini flash capacity. Update command documentation, regression coverage, and CI. Validated production timing, persistence, display, CLI, fleet/reply, RX, sleep and Morse regressions. W12 repeater, room-server and sensor builds pass. Canonical STM32 Mini Repeater passes flash and RAM admission with 448 bytes of flash remaining. Physical W12 RF testing was not performed. --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_command_availability.md | 2 +- docs/cli_commands.md | 9 +- docs/companion_protocol.md | 3 +- docs/espnow_bridge_setup.md | 6 +- docs/terminal_chat_cli.md | 10 +- examples/simple_repeater/MyMesh.cpp | 1 + examples/simple_repeater/UITask.cpp | 16 +- examples/simple_room_server/MyMesh.cpp | 2 + examples/simple_room_server/UITask.cpp | 16 +- examples/simple_sensor/SensorMesh.cpp | 2 + examples/simple_sensor/UITask.cpp | 6 +- src/helpers/CommonCLI.cpp | 75 +++--- src/helpers/CommonCLI.h | 9 +- test/fixtures/cli_settings/main.cpp | 6 + test/test_cli_settings_contract.py | 2 + test/test_common_radio_persistence.py | 6 +- test/test_fleet_radio_schedules.py | 2 + test/test_infrastructure_radio_apply.py | 246 ++++++++++++++++++ test/test_local_cli_access.py | 33 ++- test/test_radio_rx_settings_transaction.py | 1 + .../test_repeater_radio_timing_integration.py | 27 ++ 22 files changed, 414 insertions(+), 67 deletions(-) create mode 100644 test/test_infrastructure_radio_apply.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 6be39d86d..45c2cec14 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -616,6 +616,7 @@ jobs: python3 -B test/test_companion_radio_gain_restore.py -v python3 -B test/test_room_sensor_radio_restore.py -v python3 -B test/test_common_radio_persistence.py -v + python3 -B test/test_infrastructure_radio_apply.py -v python3 -B test/test_common_cli_save_results.py -v python3 -B test/test_common_cli_tx_pending.py -v python3 -B test/test_radio_rx_settings_transaction.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index b2254c9ac..ec7587b5d 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -103,7 +103,7 @@ over the normal binary USB, BLE, or TCP connection: | [`get bootloader.ver`](cli_commands.md#view-the-bootloader-version-nrf52-only) | nRF52 Companion terminal and command `0x42`; unidentifiable metadata returns `unknown` | | [`get/set sdcard`](test-results/companion_cli_parity_2026-10-23.md#shared-sd-commands) | nRF52 builds with SD OTA storage; maintenance retains OTA and Ethernet ownership guards | | `erase` | Local terminal and binary command `0x42`; reboot after erasing stored settings | -| `set freq ` | Local terminal, binary command `0x42`, and authorized LoRa CLI; reboot to apply | +| `set freq ` | Local terminal, binary command `0x42`, and authorized LoRa CLI; applies automatically (infrastructure waits two seconds and for the reply to drain) | | [`get public.key`](cli_commands.md#view-this-nodes-public-key) | Companion text terminal and binary command `0x42`; read-only, no private-key export feature required | | `get prv.key` | Local terminal and binary command `0x42`; requires private key export enabled | | `get password` | ESP32 WiFi Companion reports management-password status without revealing it; other Companions report no admin password; infrastructure returns its password locally | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 02e225a04..82df4875b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1877,7 +1877,10 @@ existing Companion apps. On dual-radio builds, use `get radio2` and **Note:** Companion USB, BLE and WebConfig changes apply automatically when the radio is free. A busy radio keeps the saved target across disconnects. -Infrastructure retains its reboot-to-apply behavior. Companion LoRa CLI waits +Infrastructure saves immediately, waits two seconds, then applies when the radio +is free and its reply has drained. No reboot is needed; a permanent change ends +pending or active TempRadio sessions. The device screen shows the applied radio +settings during the wait. Companion LoRa CLI waits for the exact reply copies to drain before applying; active TempRadio leases reject this permanent change. If RXPS is enabled and the saved minimum @@ -2065,7 +2068,9 @@ preamble. LoRa OTA treats v1.17.1.5 as that capability boundary. **Default:** `869.618` **Note:** Companion local changes apply automatically when the radio is free. -Infrastructure requires a reboot to apply. Companion LoRa CLI applies after +Infrastructure saves immediately and switches after two seconds, once the radio +is free and its reply has drained. No reboot is needed. This ends pending or +active TempRadio sessions. Companion LoRa CLI applies after its exact reply copies drain; an active TempRadio lease rejects the change. **Connections:** USB, browser/TCP/Ethernet CLI, Companion binary command diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index b39f43bf3..afb102012 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -335,7 +335,8 @@ the same binary CLI command over BLE or USB when TCP is unreachable. Listener startup failures recover automatically without resetting a healthy client or the WiFi connection; see [the CLI diagnostics](cli_commands.md#browser-configuration-portal-esp32-repeater-and-room-server). `set freq ` works through command `0x42` and authorized LoRa CLI, like -`set radio`; it saves the frequency and requires reboot to apply. See +`set radio`; it saves the frequency and applies automatically when the radio is +free. LoRa CLI waits for its reply to drain before switching. See [local maintenance commands](terminal_chat_cli.md#local-maintenance-commands) for role differences and the text-terminal equivalents. diff --git a/docs/espnow_bridge_setup.md b/docs/espnow_bridge_setup.md index 7ef92c461..761c9fb6a 100644 --- a/docs/espnow_bridge_setup.md +++ b/docs/espnow_bridge_setup.md @@ -46,7 +46,6 @@ for the first board and `Bridge-B` for the second, or choose your own names. set espnow.enabled off set name Bridge-A set radio ,,, -reboot ``` Do not type the angle-bracket placeholders literally. @@ -59,8 +58,9 @@ Do not type the angle-bracket placeholders literally. | `cr` | Coding-rate value, 5-8. Match that network's setting. | | `name` | A label to tell the two repeaters apart. It does not pair them. | -**`set radio` requires a reboot to apply.** After reconnecting, `get radio` -shows the saved settings. Use permanent radio settings for this setup; +**`set radio` saves immediately and switches after two seconds, once the radio +is free and the reply has drained.** No reboot is needed. `get radio` shows the +saved settings; the device screen shows the applied settings. Use permanent radio settings for this setup; `tempradio` sessions expire. ## 3. Configure the ESP-NOW link on both boards diff --git a/docs/terminal_chat_cli.md b/docs/terminal_chat_cli.md index 4e192a701..a32c93f47 100644 --- a/docs/terminal_chat_cli.md +++ b/docs/terminal_chat_cli.md @@ -120,8 +120,10 @@ BLE and TCP use their existing Companion transport framing. `set freq ` is available on these local connections and through authorized LoRa administration, just like `set radio`. It saves the frequency without -changing bandwidth, spreading factor, or coding rate and requires a reboot to -apply. Infrastructure roles also apply the board's transmit-power limit. +changing bandwidth, spreading factor, or coding rate. Infrastructure waits two +seconds and for the radio reply to drain before applying; Companion applies +automatically when the radio is free. No reboot is needed. Infrastructure roles +also apply the board's transmit-power limit. Commands relayed to another node using `cmd` travel over LoRa and retain the remote restrictions on local-only maintenance and secret reads. These local @@ -280,7 +282,9 @@ source, supported build profiles and memory costs. set freq {frequency} ``` Set the saved LoRa frequency. Example: `set freq 915.8`. Available over local -connections and authorized LoRa CLI; reboot to apply. +connections and authorized LoRa CLI. Infrastructure switches after two seconds +and the reply has drained; Companion applies automatically when the radio is +free. No reboot is needed. ``` set tx {tx-power-dbm} diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index e248fe6d5..ca4727c73 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -5132,6 +5132,7 @@ void MyMesh::serviceFleetScheduleReply() { #endif void MyMesh::processScheduledRadioSettings(bool tx_gap) { + if (!_cli.primaryRadioApplyReady()) return; #if MESH_ENABLE_FLEET_CONTROL if (!tx_gap) serviceFleetScheduleReply(); #endif diff --git a/examples/simple_repeater/UITask.cpp b/examples/simple_repeater/UITask.cpp index b36702eb4..7cdb450bd 100644 --- a/examples/simple_repeater/UITask.cpp +++ b/examples/simple_repeater/UITask.cpp @@ -562,14 +562,14 @@ void UITask::renderCurrScreen() { uint8_t sf = _node_prefs->sf; uint8_t cr = _node_prefs->cr; const char* profile_tag = ""; - if (dual_radio) { - const uint8_t profile = secondary_page ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = dual_radio && secondary_page ? 1 : 0; const auto& params = profiles->params(profile); freq = params.freq; bw = params.bw; sf = params.sf; cr = params.cr; - profile_tag = mesh::ui::radioProfileDisplayTag(profile, + if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile, profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary); } @@ -722,8 +722,8 @@ uint32_t UITask::getFrameSignature() { float bw = _node_prefs->bw; uint8_t sf = _node_prefs->sf; uint8_t cr = _node_prefs->cr; - if (dual_radio) { - const uint8_t profile = secondary_page ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = dual_radio && secondary_page ? 1 : 0; const auto& params = profiles->params(profile); freq = params.freq; bw = params.bw; @@ -796,13 +796,13 @@ bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { ctx->bw = _node_prefs->bw; ctx->radio_label = ""; const mesh::RadioProfiles* profiles = configuredRadioProfiles(); - if (profiles != NULL && profiles->enabled()) { - const uint8_t profile = showingSecondaryRadioProfilePage() ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = profiles->enabled() && showingSecondaryRadioProfilePage() ? 1 : 0; const auto& params = profiles->params(profile); ctx->freq = params.freq; ctx->sf = params.sf; ctx->bw = params.bw; - ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile, + if (profiles->enabled()) ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile, profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary); } ctx->dual_radio = the_mesh.isDualRadioActive(); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index e28855aae..f6fcb76a5 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -2430,6 +2430,7 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { } void MyMesh::servicePendingPrimaryRadioSettings(bool tx_gap) { + if (!_cli.primaryRadioApplyReady(!hasOutbound() && _mgr->getOutboundTotal() == 0)) return; const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at); const bool radio_apply_ready = !radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at); bool radio_apply_failed = false; @@ -3044,6 +3045,7 @@ bool MyMesh::scheduleNormalRadio() { // one as soon as the command reply has drained from the outbound queue. set_radio_at = 0; revert_radio_at = futureMillis(1); + saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; return true; diff --git a/examples/simple_room_server/UITask.cpp b/examples/simple_room_server/UITask.cpp index 1c16313cf..63374e649 100644 --- a/examples/simple_room_server/UITask.cpp +++ b/examples/simple_room_server/UITask.cpp @@ -317,14 +317,14 @@ void UITask::renderCurrScreen() { uint8_t sf = _node_prefs->sf; uint8_t cr = _node_prefs->cr; const char* profile_tag = ""; - if (dual_radio) { - const uint8_t profile = secondary_page ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = dual_radio && secondary_page ? 1 : 0; const auto& params = profiles->params(profile); freq = params.freq; bw = params.bw; sf = params.sf; cr = params.cr; - profile_tag = mesh::ui::radioProfileDisplayTag(profile, + if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile, profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary); } @@ -405,8 +405,8 @@ uint32_t UITask::getFrameSignature() { float bw = _node_prefs->bw; uint8_t sf = _node_prefs->sf; uint8_t cr = _node_prefs->cr; - if (dual_radio) { - const uint8_t profile = secondary_page ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = dual_radio && secondary_page ? 1 : 0; const auto& params = profiles->params(profile); freq = params.freq; bw = params.bw; @@ -447,13 +447,13 @@ bool UITask::buildDashboardContext(ObserverDashboard::Context* ctx) { ctx->bw = _node_prefs->bw; ctx->radio_label = ""; const mesh::RadioProfiles* profiles = configuredRadioProfiles(); - if (profiles != NULL && profiles->enabled()) { - const uint8_t profile = showingSecondaryRadioProfilePage() ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = profiles->enabled() && showingSecondaryRadioProfilePage() ? 1 : 0; const auto& params = profiles->params(profile); ctx->freq = params.freq; ctx->sf = params.sf; ctx->bw = params.bw; - ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile, + if (profiles->enabled()) ctx->radio_label = mesh::ui::radioProfileDisplayTag(profile, profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary); } ctx->dual_radio = the_mesh.isDualRadioActive(); diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index a8f7b3eab..44c546e93 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1116,6 +1116,7 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t } void SensorMesh::servicePendingPrimaryRadioSettings(bool tx_gap) { + if (!_cli.primaryRadioApplyReady(!hasOutbound() && _mgr->getOutboundTotal() == 0)) return; const bool revert_radio_due = revert_radio_at && millisHasNowPassed(revert_radio_at); const bool radio_apply_ready = !radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at); bool radio_apply_failed = false; @@ -1488,6 +1489,7 @@ bool SensorMesh::scheduleNormalRadio() { // path wait for an idle outbound queue before touching the radio. set_radio_at = 0; revert_radio_at = futureMillis(1); + saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; return true; diff --git a/examples/simple_sensor/UITask.cpp b/examples/simple_sensor/UITask.cpp index ee09bcf99..d93bf693d 100644 --- a/examples/simple_sensor/UITask.cpp +++ b/examples/simple_sensor/UITask.cpp @@ -195,14 +195,14 @@ void UITask::renderCurrScreen() { uint8_t sf = _node_prefs->sf; uint8_t cr = _node_prefs->cr; const char* profile_tag = ""; - if (dual_radio) { - const uint8_t profile = secondary_page ? 1 : 0; + if (profiles != NULL) { + const uint8_t profile = dual_radio && secondary_page ? 1 : 0; const auto& params = profiles->params(profile); freq = params.freq; bw = params.bw; sf = params.sf; cr = params.cr; - profile_tag = mesh::ui::radioProfileDisplayTag(profile, + if (dual_radio) profile_tag = mesh::ui::radioProfileDisplayTag(profile, profile == 1 ? profiles->secondary_temporary : profiles->primary_temporary); } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index b065613fa..f11db64ba 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -191,6 +191,34 @@ void CommonCLI::loop() { #endif } +bool CommonCLI::applyAndSavePrimaryRadioParams(float freq, float bw, uint8_t sf, + uint8_t cr, uint16_t preamble, char* reply) { + const int8_t previous_power = _prefs->tx_power_dbm; + if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) { + mesh::cli::formatError(reply, 160, "radio settings could not be saved"); + return false; + } + // Arm the role's reply handoff now, while the originating CLI command is + // still in scope. The separate deadline must also hold at TX-complete gaps. + _primary_radio_apply_at = millis() + 2000; + if (!_primary_radio_apply_at) _primary_radio_apply_at = 1; + _callbacks->scheduleNormalRadio(); + if (_prefs->tx_power_dbm != previous_power) { + sprintf(reply, "OK - applying in 2s; TX power limited to %d dBm", + (int)_prefs->tx_power_dbm); + } else { + strcpy(reply, "OK - applying in 2s"); + } + return true; +} + +bool CommonCLI::primaryRadioApplyReady(bool reply_idle) { + if (_primary_radio_apply_at + && (!reply_idle || static_cast(millis() - _primary_radio_apply_at) < 0)) return false; + _primary_radio_apply_at = 0; + return true; +} + bool CommonCLI::hasActiveUserGpioTimer() const { #if defined(ESP32_PLATFORM) || defined(USER_GPIO_CONTROL) return _user_gpio.hasActiveTimer(); @@ -3200,11 +3228,17 @@ bool CommonCLI::applyAndSaveAdcMultiplier(float multiplier, char* reply, return true; } +bool CommonCLI::acceptsPrimaryRadioParams(float freq, float bw, uint8_t sf, + uint8_t cr, uint16_t preamble) { + // The positive range and bandwidth whitelist also reject NaN and infinities. + return freq >= 150.0f && freq <= 2500.0f + && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && isValidLoRaBandwidth(bw) + && _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble); +} + bool CommonCLI::savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) { - if (!isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f - || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw) - || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) return false; + if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) return false; const float old_freq = _prefs->freq, old_bw = _prefs->bw; const uint8_t old_sf = _prefs->sf, old_cr = _prefs->cr; const int8_t old_power = _prefs->tx_power_dbm; @@ -5061,20 +5095,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep char legacy[96]; if (mesh::RadioProfileCLI::parseSuffix(&config[6], 4, legacy, sizeof(legacy), preamble) && mesh::cli::parseRadioTupleStrict(legacy, freq, bw, sf, cr) - && freq >= 150.0f && freq <= 2500.0f - && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 - && isValidLoRaBandwidth(bw) - && _radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) { - const int8_t previous_power = _prefs->tx_power_dbm; - if (!savePrimaryRadioParams(freq, bw, sf, cr, preamble)) { - mesh::cli::formatError(reply, 160, "radio settings could not be saved"); return; - } - if (_prefs->tx_power_dbm != previous_power) { - sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm", - (int)_prefs->tx_power_dbm); - } else { - strcpy(reply, "OK - reboot to apply"); - } + && acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) { + if (!applyAndSavePrimaryRadioParams(freq, bw, sf, cr, preamble, reply)) return; appendRxPowerSavingAdjustmentNote(reply, _prefs, sf, bw); _radio_profiles.appendPrimaryChirpWarning(reply, 160, sf, bw, preamble); } else { @@ -5089,8 +5111,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (!mesh::RadioProfileCLI::parseSuffix(&config[8], 5, legacy, sizeof(legacy), preamble) || !parseScheduledRadioArgs(legacy, false, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) { strcpy(reply, "Error, use: set radioat f,bw,sf,cr,start (UTC or +minutes)"); - } else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw) - || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) { + } else if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) { strcpy(reply, "Error, invalid radio params"); } else { _callbacks->addScheduledRadioParams(false, freq, bw, sf, cr, start_time, end_time, reply, preamble); @@ -5108,8 +5129,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep if (!mesh::RadioProfileCLI::parseSuffix(&config[12], 6, legacy, sizeof(legacy), preamble) || !parseScheduledRadioArgs(legacy, true, freq, bw, sf, cr, start_time, end_time, _rtc->getCurrentTime())) { strcpy(reply, "Error, use: set tempradioat f,bw,sf,cr,start,end (UTC or +minutes)"); - } else if (freq < 150.0f || freq > 2500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8 || !isValidLoRaBandwidth(bw) - || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble)) { + } else if (!acceptsPrimaryRadioParams(freq, bw, sf, cr, preamble)) { strcpy(reply, "Error, invalid radio params"); } else { _callbacks->addScheduledRadioParams(true, freq, bw, sf, cr, start_time, end_time, reply, preamble); @@ -5513,17 +5533,8 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep float freq = 0.0f; if (mesh::cli::parseDecimalStrict(&config[5], freq) && freq >= 150.0f && freq <= 2500.0f) { - const int8_t previous_power = _prefs->tx_power_dbm; - _prefs->freq = freq; - _prefs->tx_power_dbm = mesh::clampLoRaTxPower( - _prefs->tx_power_dbm, _prefs->freq); - savePrefs(); - if (_prefs->tx_power_dbm != previous_power) { - sprintf(reply, "OK - reboot to apply; TX power limited to %d dBm", - (int)_prefs->tx_power_dbm); - } else { - strcpy(reply, "OK - reboot to apply"); - } + applyAndSavePrimaryRadioParams(freq, _prefs->bw, _prefs->sf, _prefs->cr, + _prefs->primary_radio_preamble, reply); } else { strcpy(reply, "Error, invalid frequency"); } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index cf856a828..a258de5e0 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -973,6 +973,12 @@ class CommonCLI { bool _common_save_succeeded = false; bool _tx_power_pending = false; uint32_t _tx_power_retry_at = 0; + uint32_t _primary_radio_apply_at = 0; + // Keep shared validation/save paths out of the large CLI dispatch on small MCUs. + bool acceptsPrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, + uint16_t preamble) __attribute__((noinline)); + bool applyAndSavePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, + uint16_t preamble, char* reply) __attribute__((noinline)); enum RxSetting : uint8_t { RxGain = 1, RxPowerSaving = 2, RxSideDetectors = 4, RxRfRx = 8, RxPendingMask = 15, RxRfRxDisabled = 128 @@ -1097,9 +1103,10 @@ public: bool hasPendingTxPower() const { return _tx_power_pending; } void servicePendingTxPower(); bool hasPendingRadioSettings() const { - return _tx_power_pending || (_rx_settings_pending & RxPendingMask) != 0 + return _primary_radio_apply_at || _tx_power_pending || (_rx_settings_pending & RxPendingMask) != 0 || _radio_profiles.hasPendingTuning(); } + bool primaryRadioApplyReady(bool reply_idle = true); void servicePendingRxSettings(); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); mesh::MainBoard* getBoard() { return _board; } diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index dfbf3e0fb..2969bce82 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -55,6 +55,7 @@ struct Prefs { uint8_t sf=7, cr=5, interference_threshold=0, agc_reset_interval=0; uint8_t path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=0; int8_t tx_power_dbm=10; + uint16_t primary_radio_preamble=0; uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000; bool cad_enabled=false, rx_boosted_gain=false, rx_powersaving_enabled=false; uint8_t extra_sf[4]={}; @@ -111,6 +112,11 @@ public: uint8_t _rx_settings_pending=0; uint32_t _rx_settings_retry_at=0; void savePrefs() { callbacks.savePrefs(); } + bool applyAndSavePrimaryRadioParams(float f,float bw,uint8_t sf,uint8_t cr,uint16_t preamble,char* reply) { + if (!savePrimaryRadioParams(f,bw,sf,cr,preamble)) return false; + strcpy(reply,"OK - applying in 2s"); return true; + } + @PRIMARY_VALIDATE@ bool trySavePrefs() { savePrefs(); return true; } void applyAndSaveTxPower(int8_t power, char* reply); void servicePendingTxPower(); diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index 27d2cc31a..6dfe8c0c1 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -325,6 +325,8 @@ namespace mesh { struct Utils { fixture = template.replace('@SET@', ' else '.join(set_blocks)) fixture = fixture.replace('@GET@', ' else '.join(get_blocks)) fixture = fixture.replace('@DISPATCH@', dispatch) + fixture = fixture.replace('@PRIMARY_VALIDATE@', extract_braced( + source, 'bool CommonCLI::acceptsPrimaryRadioParams(').replace('CommonCLI::', '')) fixture = fixture.replace('@KEY_EQUALS@', extract_braced(source, 'static bool configKeyEquals(')) fixture = fixture.replace('@TX_METHODS@', '\n'.join( extract_braced(source, signature).replace('CommonCLI::', 'CLI::') diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index cc0467bfc..3b0b6bde7 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -84,6 +84,7 @@ struct CommonCLI { void savePrefs(FILESYSTEM*,PrefsSaveRouting::Scope); bool saveCommonPrefs(); bool savePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t); + bool acceptsPrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t); static void recalculateRxPowerSavingFromLevel(NodePrefs* p) { p->rx_ps_rx_us=p->sf*100; p->rx_ps_sleep_us=p->cr*200; } @@ -176,6 +177,8 @@ int main() { assert(cli.loadTail({0,64,0,1,0xff})==64 && cli.prefs.gps_sync_interval_hours==48); for(float bad : {NAN,INFINITY,149.0f,2501.0f}) assert(!cli.savePrimaryRadioParams(bad,125,9,6,0)); + for(float bad : {NAN,INFINITY,-INFINITY,-125.0f}) + assert(!cli.savePrimaryRadioParams(920,bad,9,6,0)); assert(cli.fs.files["/com_prefs"]==committed); } ''' @@ -263,7 +266,8 @@ int main() { fields = re.sub(r'\b'+macro+r'\b', value, fields) methods = '\n'.join(extract_braced(source, signature) for signature in ( 'void CommonCLI::savePrefs(FILESYSTEM*', 'bool CommonCLI::saveCommonPrefs(', - 'bool CommonCLI::savePrimaryRadioParams(')) + 'bool CommonCLI::savePrimaryRadioParams(', + 'bool CommonCLI::acceptsPrimaryRadioParams(')) serializer = 'template\n' + extract_braced(source, 'static bool writeCommonPrefsImage(') tail = extract_braced(source, 'if (file.available() >= (int)sizeof(_prefs->bridge_format))') code = HARNESS.replace('@FIELDS@',fields).replace('@METHODS@',("\n#include \ntemplate\n" + extract_braced(source, "static void writeCommonRetryPrefs(") + "\n" + methods)) diff --git a/test/test_fleet_radio_schedules.py b/test/test_fleet_radio_schedules.py index 829c8f0d6..06fa61219 100644 --- a/test/test_fleet_radio_schedules.py +++ b/test/test_fleet_radio_schedules.py @@ -193,6 +193,7 @@ int main(int argc,char** argv){ PRIMARY_EXTRA = r''' struct PrimaryFixture { + @PRIMARY_ACCEPT@ fs::FS disk;Mesh mesh;PhysicalRadio physical;RadioProfileCLI profiles;MyMesh node; FleetChannel fleet{&disk,&node};LocalIdentity publisher;unsigned dispatched=0;char reply[160]={}; PrimaryFixture(){fake_ms=1000;node.rtc.now=mesh.clock.now;profiles.begin(&disk,&physical,&mesh.clock,true); @@ -400,6 +401,7 @@ uint16_t rxPowerSavingPreambleForParams(uint8_t,float){return 32;} start=cli.index(' } else if (memcmp(config, "radioat ", 8) == 0)') end=cli.index(' } else if (memcmp(config, "lat ", 4) == 0)',start) primary_extra=PRIMARY_EXTRA.replace('@PRIMARY_SET@',cli[start:end]) + primary_extra=primary_extra.replace('@PRIMARY_ACCEPT@',body(cli,'bool CommonCLI::acceptsPrimaryRadioParams(').replace('CommonCLI::','').replace('_radio_profiles','profiles')) physical,secondary=HARNESS.split('struct Fixture {',1) secondary=('struct Fixture {'+secondary).replace(' assert(argc==2);const std::string scenario=argv[1];Fixture f;', ' assert(argc==2);if(!strncmp(argv[1],"primary_",8))return primary(argc,argv);const std::string scenario=argv[1];Fixture f;') diff --git a/test/test_infrastructure_radio_apply.py b/test/test_infrastructure_radio_apply.py new file mode 100644 index 000000000..a293bcf2e --- /dev/null +++ b/test/test_infrastructure_radio_apply.py @@ -0,0 +1,246 @@ +"""Run permanent CLI setters, delay and room/sensor apply paths from production.""" +from pathlib import Path +import subprocess +import tempfile +import unittest + +from test_repeater_radio_timing_integration import method + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#define MAX_LORA_TX_POWER 22 +#include +#define MESH_DEBUG_PRINTLN(...) ((void)0) +static uint32_t now_ms=100; +uint32_t millis(){return now_ms;} +bool isValidLoRaBandwidth(float bw){return bw==62.5f || bw==125;} +struct Prefs { + float freq=910.51f,bw=62.5f; + uint8_t sf=7,cr=5,rx_ps_level=0; + uint16_t primary_radio_preamble=32,rx_ps_preamble=0; + uint32_t rx_ps_rx_us=100,rx_ps_sleep_us=200; + int8_t tx_power_dbm=30; + bool rx_powersaving_enabled=false; +}; +struct Callbacks {virtual bool scheduleNormalRadio()=0;}; +namespace mesh { +enum class RadioParamApplyResult{APPLIED,BUSY,FAILED}; +struct RadioProfileCLI { + static bool parseSuffix(const char*,unsigned,char*,size_t,uint16_t&); + bool acceptsPrimary(float,float,uint8_t,uint8_t,uint16_t){return true;} + void adoptPrimaryPreamble(uint16_t){} + void appendPrimaryChirpWarning(char*,size_t,uint8_t,float,uint16_t){} + RadioParamApplyResult applyPrimary(float,float,uint8_t,uint8_t,bool,uint16_t,const uint32_t*) { + return RadioParamApplyResult::APPLIED; + } +}; +@SUFFIX@ +} +struct CommonCLI { + Prefs* _prefs; + Callbacks* _callbacks; + mesh::RadioProfileCLI _radio_profiles; + uint32_t _primary_radio_apply_at=0; + bool save_ok=true; + unsigned saves=0; + bool saveCommonPrefs(){++saves;return save_ok;} + bool savePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t); + bool acceptsPrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t); + bool applyAndSavePrimaryRadioParams(float,float,uint8_t,uint8_t,uint16_t,char*); + bool primaryRadioApplyReady(bool reply_idle=true); + void command(const char*,char*); + mesh::RadioProfileCLI& radioProfiles(){return _radio_profiles;} + static void recalculateRxPowerSavingFromLevel(Prefs* p){p->rx_ps_rx_us=300;p->rx_ps_sleep_us=400;} + static bool calculateRxPowerSavingLevel(uint8_t,uint8_t,float,uint32_t,uint32_t*,uint32_t*){return true;} +}; +void appendRxPowerSavingAdjustmentNote(char*,Prefs*,uint8_t,float){} +uint32_t rxPowerSavingPreambleForParams(uint8_t,float){return 16;} +uint32_t nextRadioApplyRetryDelay(uint8_t& failures){++failures;return 1000;} +struct {bool supportsRxPowerSaving(){return false;}} radio_driver; +struct Role:Callbacks { + Prefs _prefs; + CommonCLI _cli{&_prefs,this}; + uint32_t set_radio_at=0,revert_radio_at=0,radio_apply_retry_at=0; + uint8_t radio_apply_failures=0,active_cr=5,pending_sf=7,pending_cr=5; + uint16_t pending_preamble=32; + float pending_freq=911,pending_bw=125,active_freq=910.51f; + bool temp_radio_applied=false,saved_radio_apply_pending=false,outbound=false,apply_ok=true; + struct Manager {unsigned queued=0;unsigned getOutboundTotal(){return queued;}} manager; + Manager* _mgr=&manager; + unsigned apply_attempts=0; + uint32_t futureMillis(uint32_t delay){return now_ms+delay;} + bool millisHasNowPassed(uint32_t at){return int32_t(now_ms-at)>=0;} + bool hasOutbound(){return outbound;} + bool applySavedRadioParams(){++apply_attempts;if(!apply_ok)return false;active_freq=_prefs.freq;return true;} + bool scheduleNormalRadio() override; + void servicePendingPrimaryRadioSettings(bool tx_gap=false); +}; +@METHODS@ +void CommonCLI::command(const char* config,char* reply) { + @RADIO@ + else @FREQ@ +} +int main(){ + for(const char* command:{"freq 915.25","radio 915.25,125,9,6,48"}) { + for(bool temporary:{false,true}) { + now_ms=100; + Role node;node.temp_radio_applied=temporary; + char reply[160]{}; + node._cli.save_ok=false; + node._cli.command(command,reply); + assert(strstr(reply,"Error") && node._prefs.freq==910.51f); + assert(node._prefs.tx_power_dbm==30 && node._prefs.rx_ps_rx_us==100); + assert(!node._cli._primary_radio_apply_at && !node.saved_radio_apply_pending); + node._cli.save_ok=true; + node._cli.command(command,reply); + assert(strstr(reply,"applying in 2s") && strstr(reply,"TX power limited to 22")); + assert(node._prefs.freq==915.25f && node.active_freq==910.51f); + assert(node.saved_radio_apply_pending); + if(command[0]=='f')assert(node._prefs.bw==62.5f && node._prefs.primary_radio_preamble==32); + else assert(node._prefs.bw==125 && node._prefs.sf==9 && node._prefs.cr==6 && node._prefs.primary_radio_preamble==48); + for(now_ms=101;now_ms<2100;now_ms+=333) { + node.servicePendingPrimaryRadioSettings(true); + assert(node.apply_attempts==0 && node.active_freq==910.51f); + } + now_ms=2100;node.outbound=true; + node.servicePendingPrimaryRadioSettings(); + assert(node.apply_attempts==0); // Keep the current reply on the old tuple. + node.outbound=false;node.manager.queued=1; + node.servicePendingPrimaryRadioSettings(true); + assert(node.apply_attempts==0); // A different packet finishing cannot lose our queued reply. + node.manager.queued=0;node.apply_ok=false; + node.servicePendingPrimaryRadioSettings(); + assert(node.apply_attempts==1 && node.saved_radio_apply_pending); + node.apply_ok=true;now_ms=3100; + node.servicePendingPrimaryRadioSettings(); + assert(node.active_freq==915.25f && !node.saved_radio_apply_pending && !node.temp_radio_applied); + } + } + // A second accepted edit restarts the two-second handoff; malformed edits + // neither save nor replace an already accepted deadline. + Role node; char reply[160];now_ms=100; + node._cli.command("freq 915",reply); + now_ms=1500;node._cli.command("freq 916",reply); + assert(node._cli._primary_radio_apply_at==3500); + unsigned saves=node._cli.saves; + for(const char* invalid:{"freq nan","freq 149","radio 915,125,13,5","radio 915,125,7,5,7"}) { + node._cli.command(invalid,reply); + assert(strstr(reply,"Error") && node._cli.saves==saves && node._cli._primary_radio_apply_at==3500); + } + now_ms=3499;node.servicePendingPrimaryRadioSettings(true);assert(node.apply_attempts==0); + now_ms=3500;node.servicePendingPrimaryRadioSettings();assert(node.active_freq==916); +} +''' + + +class InfrastructureRadioApplyTest(unittest.TestCase): + def test_room_and_sensor_permanent_cli_handoff(self): + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text() + helpers = "\n".join(method(common, signature) for signature in ( + "bool CommonCLI::savePrimaryRadioParams(", + "bool CommonCLI::acceptsPrimaryRadioParams(", + "bool CommonCLI::applyAndSavePrimaryRadioParams(", + "bool CommonCLI::primaryRadioApplyReady(")) + suffix = method((ROOT / "src/helpers/RadioProfileCLI.cpp").read_text(), + "bool RadioProfileCLI::parseSuffix(") + def setter(key): + anchor = common.index(f'memcmp(config, "{key} ",') + start = common.rfind('} else if (', 0, anchor) + len('} else ') + return method(common[start:], 'if (').replace('#ifdef WITH_BRIDGE\n', '') + for file, role in (("simple_room_server/MyMesh.cpp", "MyMesh"), + ("simple_sensor/SensorMesh.cpp", "SensorMesh")): + with self.subTest(role=role), tempfile.TemporaryDirectory() as temp: + source = (ROOT / "examples" / file).read_text() + methods = helpers + "\n" + "\n".join( + method(source, signature).replace(role + "::", "Role::") + for signature in (f"bool {role}::scheduleNormalRadio()", + f"void {role}::servicePendingPrimaryRadioSettings(")) + code = HARNESS.replace('@METHODS@', methods).replace('@SUFFIX@', suffix) + code = code.replace('@RADIO@', setter('radio')).replace('@FREQ@', setter('freq')) + cpp = Path(temp) / "test.cpp" + binary = Path(temp) / "test" + cpp.write_text(code) + subprocess.run(['c++', '-std=c++17', '-I'+str(ROOT/'src'), + str(cpp), '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + def test_screens_show_applied_tuple_with_radio2_off_or_on(self): + # Compile the actual render/signature/dashboard selection blocks. A + # saved edit must not move the screen before the PHY accepts it. + selections = [] + for role in ('simple_repeater', 'simple_room_server', 'simple_sensor'): + source = (ROOT / 'examples' / role / 'UITask.cpp').read_text() + offset = 0 + while True: + start = source.find('float freq = _node_prefs->freq;', offset) + if start < 0: + break + condition = source.index('if (', start) + block = method(source[condition:], 'if (') + end = condition + len(block) + selections.append(source[start:end] + '\nreturn {freq,bw,sf,cr};') + offset = end + if 'bool UITask::buildDashboardContext(' in source: + start = source.index('ctx->freq = _node_prefs->freq;', + source.index('bool UITask::buildDashboardContext(')) + condition = source.index('if (profiles', start) + end = condition + len(method(source[condition:], 'if (')) + selections.append('View context; View* ctx=&context;\n' + source[start:end] + + '\nreturn context;') + self.assertEqual(len(selections), 7) + code = r''' +#include +#include +#include +#include +#include +struct View{float freq,bw;uint8_t sf,cr;const char* radio_label="";}; +static View saved{920,125,9,6}; +static View* _node_prefs=&saved; +static mesh::RadioProfiles state; +static const mesh::RadioProfiles* configuredRadioProfiles(){return &state;} +static bool secondary_page=false; +static bool showingSecondaryRadioProfilePage(){return secondary_page;} +''' + for i, selection in enumerate(selections): + # Dashboard declares its own profiles pointer. + setup = '' if 'const mesh::RadioProfiles* profiles' in selection else ( + 'const auto* profiles=configuredRadioProfiles();') + code += f'View render{i}(){{' + setup + ( + 'const bool dual_radio=state.enabled();' + 'uint32_t signature=DisplayFrameSignature::INITIAL;') + selection + '}\n' + code += 'int main(){\n' + for i in range(len(selections)): + code += f''' + state.primary.freq=910.51f;state.primary.bw=62.5f;state.primary.sf=7;state.primary.cr=5; + state.secondary.params.freq=911;state.secondary.params.bw=125;state.secondary.params.sf=8;state.secondary.params.cr=6; + state.secondary.mode=mesh::RadioProfileMode::Off;secondary_page=true; + auto v{i}=render{i}();assert(v{i}.freq==910.51f && v{i}.bw==62.5f && v{i}.sf==7); + state.primary.freq=920;state.primary.bw=125;state.primary.sf=9; + v{i}=render{i}();assert(v{i}.freq==920 && v{i}.bw==125 && v{i}.sf==9); + state.primary_temporary=true;state.primary.freq=912; + v{i}=render{i}();assert(v{i}.freq==912); + state.secondary.mode=mesh::RadioProfileMode::Rx; + v{i}=render{i}();assert(v{i}.freq==911 && v{i}.sf==8); + secondary_page=false;v{i}=render{i}();assert(v{i}.freq==912); +''' + code += '}\n' + with tempfile.TemporaryDirectory() as temp: + cpp = Path(temp) / 'display.cpp' + binary = Path(temp) / 'display' + cpp.write_text(code) + subprocess.run(['c++', '-std=c++17', '-I'+str(ROOT/'src'), str(cpp), + '-o', str(binary)], check=True) + subprocess.run([str(binary)], check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index ddb5ab100..6bf59fcff 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -394,11 +394,31 @@ class LocalCliAccessTest(unittest.TestCase): struct Prefs { float freq=910.525f,bw=62.5f; uint8_t sf=7,cr=5; + uint16_t primary_radio_preamble=32; int8_t tx_power_dbm=30; } prefs; Prefs* _prefs=&prefs; int saves=0; -void savePrefs(){++saves;} +int schedules=0; +bool save_ok=true; +bool savePrimaryRadioParams(float f,float bw,uint8_t sf,uint8_t cr,uint16_t preamble) { + ++saves; + if (!save_ok) return false; + _prefs->freq=f; _prefs->bw=bw; _prefs->sf=sf; _prefs->cr=cr; + _prefs->primary_radio_preamble=preamble; + _prefs->tx_power_dbm=mesh::clampLoRaTxPower(_prefs->tx_power_dbm,f); + return true; +} +bool applyAndSavePrimaryRadioParams(float f,float bw,uint8_t sf,uint8_t cr,uint16_t preamble,char* reply){ + const auto previous_power=_prefs->tx_power_dbm; + if(!savePrimaryRadioParams(f,bw,sf,cr,preamble)) { + strcpy(reply,"Error: radio settings could not be saved");return false; + } + ++schedules; + strcpy(reply,previous_power!=_prefs->tx_power_dbm ? + "OK - applying in 2s; TX power limited" : "OK - applying in 2s"); + return true; +} void set(const char* config,uint32_t sender_timestamp,char* reply) { ''' + setter + r''' } @@ -408,7 +428,7 @@ int main() { prefs=Prefs{}; set("freq 915.25",stamp,reply); assert(prefs.freq==915.25f && prefs.tx_power_dbm==22); - assert(strstr(reply,"reboot to apply") && strstr(reply,"TX power limited")); + assert(strstr(reply,"applying in 2s") && strstr(reply,"TX power limited")); assert(prefs.bw==62.5f && prefs.sf==7 && prefs.cr==5); for(const char* bad:{"freq 149.9","freq 2500.1","freq 920oops", "freq NaN","freq inf","freq 1e3","freq "}) { @@ -418,10 +438,15 @@ int main() { } for(const char* boundary:{"freq 150","freq 2500"}) { set(boundary,stamp,reply); - assert(strstr(reply,"OK - reboot to apply")); + assert(strstr(reply,"OK - applying in 2s")); } } - assert(saves==9); + assert(saves==9 && schedules==9); + const float previous=prefs.freq; + save_ok=false; + set("freq 920",0,reply); + assert(strstr(reply,"Error") && prefs.freq==previous && schedules==9); + assert(prefs.primary_radio_preamble==32); } ''' with tempfile.TemporaryDirectory() as temp: diff --git a/test/test_radio_rx_settings_transaction.py b/test/test_radio_rx_settings_transaction.py index 67414d71d..70d9e25d7 100644 --- a/test/test_radio_rx_settings_transaction.py +++ b/test/test_radio_rx_settings_transaction.py @@ -139,6 +139,7 @@ public: Callbacks* _callbacks = &callbacks; uint8_t _rx_settings_pending = 0; uint32_t _rx_settings_retry_at = 0; + uint32_t _primary_radio_apply_at = 0; bool _tx_power_pending = false, _common_save_result_known = false, _common_save_succeeded = false; struct Profiles { bool pending = false; diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 1759b559c..219a063da 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -90,6 +90,8 @@ struct CLI { CLI& radioProfiles() { return *this; } RadioPrefs* prefs = nullptr; int* saves = nullptr; + uint32_t _primary_radio_apply_at = 0; + @PRIMARY_READY@ bool save_success = true; unsigned save_attempts = 0; unsigned fail_on_save = 0; @@ -204,6 +206,26 @@ static void startTemp(MyMesh& m, int minutes) { assert(m.temp_radio_applied); } int main() { + for (uint32_t start : {100U, 0xFFFFFC18U, 0xFFFFF830U}) { + MyMesh m; + now_ms = start; + m._cli._primary_radio_apply_at = now_ms + 2000; + if (!m._cli._primary_radio_apply_at) m._cli._primary_radio_apply_at = 1; + m.scheduleNormalRadio(); + m.processScheduledRadioSettings(true); + assert(m.restores == 0 && m.saved_radio_apply_pending); + now_ms = start + 1999; + m.processScheduledRadioSettings(true); // A TX gap cannot bypass the delay. + assert(m.restores == 0); + now_ms = start + 2001; + m.temp_radio_reply_barrier.held = true; + m.processScheduledRadioSettings(true); + assert(m.restores == 0); // Even after 2s, let the exact reply finish. + m.temp_radio_reply_barrier.held = false; + m.processScheduledRadioSettings(true); + assert(m.restores == 1 && !m.saved_radio_apply_pending); + } + for (unsigned failure = 0; failure < #ifdef USE_LR2021 4 @@ -530,6 +552,11 @@ int main() { ''' +HARNESS = HARNESS.replace("@PRIMARY_READY@", method( + (ROOT / "src/helpers/CommonCLI.cpp").read_text(), + "bool CommonCLI::primaryRadioApplyReady(").replace("CommonCLI::", "") + .replace("bool reply_idle)", "bool reply_idle=true)")) + class RepeaterRadioTimingIntegrationTest(unittest.TestCase): def compile_and_run(self, source): with tempfile.TemporaryDirectory(prefix="meshcore-temp-timing-") as temp: