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: