diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 133e55b62..73433434f 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -618,6 +618,7 @@ jobs: python3 -B test/test_common_radio_persistence.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 python3 -B test/test_common_prefs_uncertain_reply.py -v python3 -B test/test_common_prefs_apply_reply.py -v python3 -B test/test_common_prefs_commit.py -v @@ -862,6 +863,7 @@ jobs: python3 -B test/test_esp32_async_web_build_paths.py -v python3 -B test/test_wifi_ota_start.py -v python3 -B test/test_webconfig_ap_start.py -v + python3 -B test/test_service_start_retry.py -v python3 -B test/test_webconfig_recovery_auth.py -v python3 -B test/test_webconfig_password_sessions.py -v python3 -B test/test_webconfig_login_throttle.py -v diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 318ece21e..3335d5d17 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -449,9 +449,10 @@ target-default builds use `none`. A saved setting takes precedence after an upgrade. The WiFi `set` commands work on MQTT observers and on FULL standalone -ESP32 repeater/room-server builds. On a standalone build, changing the SSID or -password stops an active WebConfig session; run `start webconfig` again to use -the new credentials. `set wifi.pwd` with no value selects an open network. +ESP32 repeater/room-server builds. Changing the SSID or password automatically +restarts an active WebConfig session after its requests drain and reconnects +enabled MQTT with the latest saved credentials. An explicit WebUI or WiFi off +command cancels pending starts. `set wifi.pwd` with no value selects an open network. Standalone WiFi also accepts an exact 64-character hexadecimal WPA/WPA2 PSK; ordinary passphrases remain limited to 63 characters. Other 64-character values and all longer values are rejected. MQTT observer WiFi passwords retain their @@ -1871,7 +1872,12 @@ node after the save succeeds. See [regulator recovery and hardware checks](power existing Companion apps. On dual-radio builds, use `get radio2` and `get radio.timing` for profile and timing diagnostics. -**Note:** Requires reboot to apply. If RXPS is enabled and the saved minimum +**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 +for the exact reply copies to drain before applying; active TempRadio leases +reject this permanent change. +If RXPS is enabled and the saved minimum level/preamble cannot safely cover the new radio timing, the command reply reports the effective level and preamble, or `RXPS continuous-fast` when no level through 10 is safe. Slower settings recalculate from the saved RXPS @@ -2055,7 +2061,9 @@ preamble. LoRa OTA treats v1.17.1.5 as that capability boundary. **Default:** `869.618` -**Note:** Requires reboot to apply +**Note:** Companion local changes apply automatically when the radio is free. +Infrastructure requires a reboot to apply. 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 `0x42` (USB/BLE/TCP), and authorized LoRa CLI. `set freq` saves the frequency; @@ -2080,7 +2088,9 @@ roles also clamp transmit power to the board's limit for that frequency. Like Station G2/G3 targets default to `off`. **Notes:** -- The saved setting is applied immediately and persists across reboots. +- The saved setting is applied when the radio is free and persists across reboots. + A busy radio keeps the change pending and applies it automatically; do not + repeat the command. Unsupported hardware and failed saves remain errors. - Periodic AGC resets restore the saved runtime setting; they do not replace it with the target's compile-time default. - Existing installations retain their previously saved value after an upgrade. @@ -2160,7 +2170,9 @@ Station G2/G3 targets default to `off`. Radio-change replies and the Full Companion terminal status report any effective level/preamble adjustment. Effective preamble 64 or 128 appears only when the active tuple's physical wire preamble has that length. -- The selected mode is applied immediately, persisted, and restored after reboot. +- The selected mode is persisted and applied when the radio is free. Busy + requests apply automatically without another command and survive reboot. + The runtime-only RXEN diagnostic also waits for a safe gap but is not saved. --- diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 0a20daa7a..4b2327208 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -49,6 +49,17 @@ report a storage error if saving fails, and restore their previous values. Settings that also change GPS or radio power-saving state restore that state; a busy radio rollback remains pending for recovery instead of reporting success. +Valid local radio frequency, bandwidth, spreading-factor, coding-rate, boosted +RX gain and RX power-saving changes no longer require a reboot or repeated +requests when the radio is busy. The saved change applies automatically at a +safe gap; a later accepted change replaces the target. An app disconnect does +not cancel a saved change. Temporary radio operations finish before the saved +settings are restored. Invalid values and failed saves still report errors. +A remote frequency or tuple change waits for its exact LoRa reply copies to +finish before retuning. It is rejected during a TempRadio lease. If saving or +restoring the saved preamble fails, the old runtime target is retained and its +storage repair retries automatically; the command reports an unconfirmed save. + | Capability | ESP32 full | nRF52 full | | --- | --- | --- | | USB Binary Companion | Yes | Yes | @@ -427,6 +438,12 @@ start webconfig stop webconfig ``` +Changing saved WiFi credentials schedules an automatic reconnect for active +services. WebConfig and MQTT keep an accepted start request while an earlier +stop finishes or a temporary startup failure clears. A later explicit off or +stop cancels that request. Hardware and ownership conflicts still report an +error. Bluetooth advertising also retries a failed restart automatically. + ESP32 WiFi Companions with WebConfig, including Full Companion, expose a **CLI** tab at the node's LAN address. It defaults to on; use `get wifi.cli`, `set wifi.cli on`, or `set wifi.cli off`. Older builds that report the browser diff --git a/docs/radio_profiles.md b/docs/radio_profiles.md index ac2fbd454..32e30920a 100644 --- a/docs/radio_profiles.md +++ b/docs/radio_profiles.md @@ -1273,13 +1273,19 @@ supported features and restores the board's default clock. These commands do not change the chosen SPI controller or the pins and do not adjust CPU clock, RX power saving, channel settings, or whether `radio2` is enabled. -Changes require local USB or BLE access. A busy radio rejects a change without -saving it. An accepted change restarts receive safely and verifies it before +Changes require local USB or BLE access. Valid changes made while the radio +is busy are saved once and applied automatically at a safe gap, including +between queued transmissions. Separate feature changes compose with the latest +accepted policy. `OK - saved; pending` means no repeat command is needed. +Unsupported settings and storage failures remain errors. + +An immediately applied change restarts receive safely and verifies it before saving; a receive or storage failure restores the prior policy. Switching measurements restart after a policy change, so check `get radio2.timing` again after its timing test completes. Packet and error history remains available. -If a saved policy cannot be applied during boot, its application retries at -most once per second; the aggregate getter reports `pending saved policy`. +The aggregate getter reports `pending saved policy` until the saved target is +applied. Hardware failures retry with a delay; a temporary radio owner retains +control until it finishes. ## Power saving and diagnostics diff --git a/examples/InfrastructureWireless.h b/examples/InfrastructureWireless.h index b208fe250..1106afbc8 100644 --- a/examples/InfrastructureWireless.h +++ b/examples/InfrastructureWireless.h @@ -32,7 +32,8 @@ public: if (the_mesh.isWebConfigActive()) mask |= mesh::wireless::WiFi; #endif #ifdef WITH_MQTT_BRIDGE - if (the_mesh.isMqttBridgeRunning()) mask |= mesh::wireless::WiFi; + if (the_mesh.isMqttBridgeRunning() || the_mesh.isMqttBridgeStartPending()) + mask |= mesh::wireless::WiFi; #endif #ifdef WITH_UDP_BRIDGE if (the_mesh.isUDPBridgeNetworkEnabled()) mask |= mesh::wireless::WiFi; @@ -74,7 +75,8 @@ public: resume_web_ = the_mesh.isWebConfigActive(); #endif #ifdef WITH_MQTT_BRIDGE - resume_mqtt_ = the_mesh.isMqttBridgeRunning(); + resume_mqtt_ = the_mesh.isMqttBridgeRunning() + || the_mesh.isMqttBridgeStartPending(); #endif #ifdef WITH_UDP_BRIDGE resume_udp_ = the_mesh.isUDPBridgeConfigured(); diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 36d441f9d..e9e5dd6ad 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -158,7 +158,6 @@ static uint32_t nextRadioApplyRetryDelay(uint8_t& failure_count) { return delay_ms > 30000UL ? 30000UL : delay_ms; } -static const uint32_t COMMAND_RADIO_APPLY_TIMEOUT_MS = 5000UL; #if RXPS_FIXED_ENABLED #if RXPS_FIXED_LEVEL < 1 || RXPS_FIXED_LEVEL > 10 @@ -1975,6 +1974,7 @@ bool MyMesh::sendFloodScoped(const mesh::GroupChannel& channel, mesh::Packet* pk void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* original, uint8_t priority) { mesh::Mesh::onRadioProfileCopyQueued(packet, original, priority); + _primary_radio_reply.trackCopy(original, packet); _morse_reply.copy(original, packet); #if MESH_ENABLE_FLEET_CONTROL if (original && original == _fleet_admission_original) _fleet_admission_copy = packet; @@ -1983,19 +1983,25 @@ void MyMesh::onRadioProfileCopyQueued(mesh::Packet* packet, const mesh::Packet* void MyMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); + if (_primary_radio_reply.complete(packet) && !_primary_radio_reply.waiting()) + finishPrimaryRadioReply(true); // Use the completed packet's idle gap before a busy queue starts another TX. - servicePendingTxPower(); + servicePendingLocalRadioSettings(); _morse_reply.complete(packet); } void MyMesh::onSendFail(mesh::Packet* packet) { mesh::Mesh::onSendFail(packet); + if (_primary_radio_reply.fail(packet)) finishPrimaryRadioReply(false); + else if (!_primary_radio_reply.waiting() && _primary_radio_reply.succeeded()) + finishPrimaryRadioReply(true); _morse_reply.fail(packet); } bool MyMesh::allowDirectRetry(const mesh::Packet* packet, const uint8_t* next_hop_hash, uint8_t next_hop_hash_len) const { return !_morse_reply.ownsReply(packet) + && !_primary_radio_reply.contains(packet) && mesh::Mesh::allowDirectRetry(packet, next_hop_hash, next_hop_hash_len); } @@ -2174,6 +2180,7 @@ void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32 const char *text, char* reply) { markConnectionActive(from); // in case this is from a server, and we have a connection if (from.isRemoteCLIAllowed()) { + _primary_radio_reply_admission = false; const uint32_t before = _morse_reply.generation(); _morse_reply.beginCommand(from.id.pub_key); // A timestamp received over the radio must never grant local-console access. @@ -2188,6 +2195,10 @@ void MyMesh::onCLICommandRecv(const ContactInfo &from, mesh::Packet *pkt, uint32 } void MyMesh::onCLIReplyPrepared(const ContactInfo& from, mesh::Packet* reply) { + if (_primary_radio_reply_admission) { + _primary_radio_reply.prepare(reply); + if (reply) reply->radio_reply = true; + } if (!_morse_reply_admission || !_morse_reply.pending() || memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) != 0) return; _morse_reply.prepareReply(reply); @@ -2195,6 +2206,10 @@ void MyMesh::onCLIReplyPrepared(const ContactInfo& from, mesh::Packet* reply) { } void MyMesh::onCLIReplyQueued(const ContactInfo& from, mesh::Packet* reply, bool queued) { + if (_primary_radio_reply_admission) { + _primary_radio_reply.arm(queued ? reply : nullptr); + _primary_radio_reply_admission = false; + } if (!_morse_reply_admission) return; if (memcmp(from.id.pub_key, _morse_reply.actor(), PUB_KEY_SIZE) == 0) _morse_reply.armReply(reply, queued); @@ -2883,14 +2898,6 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _radio_available = true; radio_apply_retry_at = 0; radio_apply_failures = 0; - command_radio_apply_pending = false; - command_radio_freq = 0.0f; - command_radio_bw = 0.0f; - command_radio_sf = 0; - command_radio_cr = 0; - command_radio_repeat = 0; - command_radio_apply_deadline = 0; - command_radio_reply_route = NULL; _scheduled_reboot_at = 0; #if COMPANION_FEATURE_TEMP_RADIO _temp_radio_set_at = 0; @@ -3453,14 +3460,13 @@ void MyMesh::configureRadioFromPrefs() { #endif if (!_radio_available) { - saved_radio_apply_pending = false; board.setLoRaFemLnaEnabled(_prefs.radio_fem_rxgain); board.setLoRaFemPaGainEnabled(_prefs.radio_fem_txgain); MESH_DEBUG_PRINTLN("Radio unavailable: companion services are running in recovery mode"); return; } - saved_radio_apply_pending = !applySavedRadioParams(); + saved_radio_apply_pending |= !_primary_radio_blocked && !applySavedRadioParams(); radio_driver.setCADScanTimeoutMillis(_prefs.cad_scan_timeout_ms); _radio->setCADEnabled(_prefs.cad_enabled != 0); if (!saved_radio_apply_pending) { @@ -3509,109 +3515,149 @@ bool MyMesh::applySavedRadioParams() { == mesh::RadioParamApplyResult::APPLIED; } -void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, - uint8_t cr, uint8_t repeat, - BaseSerialInterface* route) { - const float previous_freq = _prefs.freq; - const float previous_bw = _prefs.bw; - const uint8_t previous_sf = _prefs.sf; - const uint8_t previous_cr = _prefs.cr; - const uint8_t previous_repeat = _prefs.client_repeat; - const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; - const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; - const bool previous_recovery_pending = saved_radio_apply_pending; - _prefs.sf = sf; - _prefs.cr = cr; - _prefs.freq = freq; - _prefs.bw = bw; - _prefs.client_repeat = repeat; - recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, - _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, - &_prefs.rx_ps_sleep_us); - if (!savePrefs()) { - _prefs.freq = previous_freq; - _prefs.bw = previous_bw; - _prefs.sf = previous_sf; - _prefs.cr = previous_cr; - _prefs.client_repeat = previous_repeat; - _prefs.rx_ps_rx_us = previous_rx_us; - _prefs.rx_ps_sleep_us = previous_sleep_us; - // Hardware accepted the candidate before this save. Restore the durable - // tuple now, or let the existing backed-off recovery finish once RX is - // idle. A failed save must never acknowledge a persistent radio change. - const bool restored = applySavedRadioParams(); - saved_radio_apply_pending = !restored || previous_recovery_pending; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - writeErrFrame(ERR_CODE_FILE_IO_ERROR, route); - return; - } - - // Preserve any earlier saved-radio recovery while committing the tuple. - saved_radio_apply_pending = previous_recovery_pending; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - - MESH_DEBUG_PRINTLN("OK: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", - (uint32_t)(freq * 1000.0f), (uint32_t)(bw * 1000.0f), - (uint32_t)sf, (uint32_t)cr); - writeOKFrame(route); -} - -void MyMesh::deferRadioCommand() { - command_radio_reply_route = _serial->captureReplyRoute(); - if (command_radio_reply_route == NULL) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - command_radio_apply_pending = true; - command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS); -} - -void MyMesh::cancelPendingRadioParamApply() { - if (!command_radio_apply_pending) return; - - command_radio_apply_pending = false; - command_radio_apply_deadline = 0; - command_radio_reply_route = NULL; - // The requested tuple was not committed. Reassert the persisted tuple in - // case a failed hardware apply only restored part of the old configuration. +__attribute__((noinline)) void MyMesh::queueSavedRadioApply() { saved_radio_apply_pending = true; radio_apply_retry_at = 0; radio_apply_failures = 0; } -void MyMesh::servicePendingRadioParamApply() { - if (!command_radio_apply_pending) return; - if (!_serial || !_serial->isReplyRouteAvailable(command_radio_reply_route)) { - cancelPendingRadioParamApply(); - return; +void MyMesh::finishPrimaryRadioReply(bool delivered) { + _primary_radio_reply.clear(); + if (delivered) { + _primary_radio_blocked = false; + queueSavedRadioApply(); + } +} + +MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams( + float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat, uint16_t preamble, bool remote) { +#if COMPANION_FEATURE_TEMP_RADIO + // A permanent remote change cannot own the acknowledgement and a lease's + // restoration at once. Local changes retain the existing deferred behavior. + if (remote && (_temp_radio_applied || _temp_radio_set_at || _temp_radio_revert_at)) + return RadioSettingResult::RadioRejected; +#endif + if (freq < 150.0f || !CommonRadioPrefs::isValidBandwidth(bw) + || !_radio_profiles.acceptsPrimary(freq, bw, sf, cr, preamble) + || (repeat && !isValidClientRepeatFreq(uint32_t(freq * 1000.0f)))) + return RadioSettingResult::RadioRejected; + uint32_t rx_us = _prefs.rx_ps_rx_us, sleep_us = _prefs.rx_ps_sleep_us; + if (_prefs.rx_ps_level + && !recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, sf, bw, + _prefs.rx_ps_preamble, &rx_us, &sleep_us) + && _prefs.rx_powersaving_enabled) return RadioSettingResult::RadioRejected; + + const uint16_t previous_preamble = _radio_profiles.primaryPreamble(); + if (!_radio_profiles.savePrimaryPreamble(preamble)) return RadioSettingResult::SaveFailed; + if (remote && _primary_radio_blocked && freq == _prefs.freq && bw == _prefs.bw + && sf == _prefs.sf && cr == _prefs.cr && repeat == _prefs.client_repeat + && preamble == previous_preamble) { + // Replaying an unconfirmed request sends a fresh acknowledgement without + // saving again. Only this response's exact copies can release its target. + _primary_radio_reply.clear(); + _primary_radio_reply_admission = true; + return RadioSettingResult::Saved; + } + const auto result = _radio_available && !remote && !_primary_radio_blocked +#if COMPANION_FEATURE_TEMP_RADIO + && !_temp_radio_applied && !_temp_radio_set_at && !_temp_radio_revert_at +#endif + ? tryApplyRadioParams(freq, bw, sf, cr) : mesh::RadioParamApplyResult::BUSY; + if (result == mesh::RadioParamApplyResult::FAILED) { + const bool restored = _radio_profiles.restorePrimaryPreamble(previous_preamble); + queueSavedRadioApply(); + return restored ? RadioSettingResult::RadioRejected : RadioSettingResult::SaveUnconfirmed; } - // Leave enough queue headroom for the eventual command response. - if (_serial->isReplyRouteWriteBusy(command_radio_reply_route)) return; + const float previous_freq = _prefs.freq, previous_bw = _prefs.bw; + const uint8_t previous_sf = _prefs.sf, previous_cr = _prefs.cr; + const uint8_t previous_repeat = _prefs.client_repeat; + const uint32_t previous_rx = _prefs.rx_ps_rx_us, previous_sleep = _prefs.rx_ps_sleep_us; + _prefs.freq = freq; _prefs.bw = bw; _prefs.sf = sf; _prefs.cr = cr; + _prefs.client_repeat = repeat; + _prefs.rx_ps_rx_us = rx_us; _prefs.rx_ps_sleep_us = sleep_us; + if (savePrefs()) { + _primary_radio_reply.clear(); + _primary_radio_blocked = remote; + _primary_radio_reply_admission = remote; + if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply(); + return RadioSettingResult::Saved; + } - const bool expired = static_cast(static_cast( - _ms->getMillis() - command_radio_apply_deadline)) >= 0; - mesh::RadioParamApplyResult result = tryApplyRadioParams( - command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr); + _prefs.freq = previous_freq; _prefs.bw = previous_bw; + _prefs.sf = previous_sf; _prefs.cr = previous_cr; + _prefs.client_repeat = previous_repeat; + _prefs.rx_ps_rx_us = previous_rx; _prefs.rx_ps_sleep_us = previous_sleep; + const bool preamble_restored = _radio_profiles.restorePrimaryPreamble(previous_preamble); + if ((result == mesh::RadioParamApplyResult::APPLIED && !applySavedRadioParams()) + || !preamble_restored) queueSavedRadioApply(); + return preamble_restored ? RadioSettingResult::SaveFailed : RadioSettingResult::SaveUnconfirmed; +} + +bool MyMesh::applyRxPowerSavingRfRxDisabled(const char* value, char* reply, size_t capacity) { + if (strcmp(value, "on") && strcmp(value, "off")) { + mesh::cli::formatError(reply, capacity, "use set radio.rxps.rfrx_disabled on|off"); + } else if (!radio_driver.supportsRxPowerSavingRfRxDisable()) { + mesh::cli::formatError(reply, capacity, "unsupported"); + } else { + const bool disabled = !strcmp(value, "on"); + const auto result = _radio_available + ? radio_driver.trySetRxPowerSavingRfRxDisabled(disabled) : mesh::RadioParamApplyResult::BUSY; + if (result == mesh::RadioParamApplyResult::FAILED) { + mesh::cli::formatError(reply, capacity, "RXEN change failed"); + } else { + rf_rx_disable_pending = result == mesh::RadioParamApplyResult::BUSY ? int8_t(disabled) : -1; + rf_rx_retry_at = 0; + snprintf(reply, capacity, "OK"); + } + } + return true; +} + +void MyMesh::servicePendingLocalRadioSettings() { + _radio_profiles.servicePendingTuning(); + servicePendingTxPower(); + if (rf_rx_disable_pending >= 0 && _radio_available + && !(_radio && _radio->isCarrierWaveActive()) + && (!rf_rx_retry_at || millisHasNowPassed(rf_rx_retry_at))) { + const auto result = radio_driver.trySetRxPowerSavingRfRxDisabled(rf_rx_disable_pending != 0); + if (result == mesh::RadioParamApplyResult::APPLIED) rf_rx_disable_pending = -1; + rf_rx_retry_at = result == mesh::RadioParamApplyResult::FAILED ? futureMillis(1000) : 0; + if (result == mesh::RadioParamApplyResult::FAILED && !rf_rx_retry_at) rf_rx_retry_at = 1; + } + if (!saved_radio_apply_pending || !_radio_available + || (_radio && (_radio->isCarrierWaveActive() + || _radio->isMorseTxActive() || _radio->isMorseRxActive())) +#if COMPANION_FEATURE_TEMP_RADIO + || _temp_radio_applied || _temp_radio_set_at || _temp_radio_revert_at +#endif + || (radio_apply_retry_at && !millisHasNowPassed(radio_apply_retry_at))) return; + // Driver admission preserves unread packets. This also runs in the idle + // gap before Dispatcher starts the next packet of a saturated TX queue. + const auto gain = radio_driver.supportsRxBoostedGainMode() + ? radio_driver.trySetRxBoostedGainMode(_prefs.rx_boosted_gain) + : mesh::RadioParamApplyResult::APPLIED; + const auto tuple = !_primary_radio_blocked && gain == mesh::RadioParamApplyResult::APPLIED + ? tryApplyRadioParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr) : gain; + const auto power = tuple == mesh::RadioParamApplyResult::APPLIED + ? radio_driver.trySetTxPower(_prefs.tx_power_dbm) : tuple; + uint32_t rx_us = _prefs.rx_ps_rx_us, sleep_us = _prefs.rx_ps_sleep_us; + if (_primary_radio_blocked && _prefs.rx_ps_level && _radio && _radio->profiles()) { + const auto& active = _radio->profiles()->primary; + recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, active.sf, active.bw, + _prefs.rx_ps_preamble, &rx_us, &sleep_us); + } + const auto result = power == mesh::RadioParamApplyResult::APPLIED + && radio_driver.supportsRxPowerSaving() + ? radio_driver.trySetRxPowerSaving(_prefs.rx_powersaving_enabled != 0, + rx_us, sleep_us) : power; if (result == mesh::RadioParamApplyResult::APPLIED) { - float freq = command_radio_freq; - float bw = command_radio_bw; - uint8_t sf = command_radio_sf; - uint8_t cr = command_radio_cr; - uint8_t repeat = command_radio_repeat; - BaseSerialInterface* route = command_radio_reply_route; - command_radio_apply_pending = false; - command_radio_apply_deadline = 0; - command_radio_reply_route = NULL; - finishRadioParamApply(freq, bw, sf, cr, repeat, route); + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; } else if (result == mesh::RadioParamApplyResult::FAILED) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG, command_radio_reply_route); - cancelPendingRadioParamApply(); - } else if (expired) { - writeErrFrame(ERR_CODE_BAD_STATE, command_radio_reply_route); - cancelPendingRadioParamApply(); + radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); + if (!radio_apply_retry_at) radio_apply_retry_at = 1; } } @@ -3630,11 +3676,10 @@ static bool isFullCompanionBandwidth(float bw) { bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr, uint32_t timeout_mins, char* reply, size_t reply_size) { - if (command_radio_apply_pending) { - snprintf(reply, reply_size, "ERR radio change pending"); + if (_primary_radio_blocked) { + mesh::cli::formatError(reply, reply_size, "radio acknowledgement pending"); return false; } - #if defined(OTA_SHARED_COMPANION_QUEUE) uint16_t drained = 0; if (!mesh::ota::ota_acquire_context(reply, reply_size, true, &drained)) return false; @@ -3662,6 +3707,10 @@ bool MyMesh::scheduleTempRadio(float freq, float bw, uint8_t sf, uint8_t cr, } void MyMesh::scheduleNormalRadio(char* reply, size_t reply_size) { + if (_primary_radio_blocked) { + mesh::cli::formatError(reply, reply_size, "radio acknowledgement pending"); + return; + } _temp_radio_set_at = 0; _temp_radio_revert_at = futureMillis(1500); // keep the reply on the active tuple _temp_radio_retry_at = 0; @@ -4062,7 +4111,15 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, snprintf(reply, reply_size, "%s", result); return true; } - if (mesh::companion::handleRadioDiagnosticsCommand( + static const char rf_rx_key[] = "set radio.rxps.rfrx_disabled"; + if (!strncmp(command, rf_rx_key, sizeof(rf_rx_key) - 1) + && (!command[sizeof(rf_rx_key) - 1] || command[sizeof(rf_rx_key) - 1] == ' ' + || command[sizeof(rf_rx_key) - 1] == '\t')) { + const char* value = command + sizeof(rf_rx_key) - 1; + while (*value == ' ' || *value == '\t') ++value; + return applyRxPowerSavingRfRxDisabled(value, reply, reply_size); + } + if (mesh::companion::handleRadioDiagnosticsCommand( radio_driver, _radio_available, command, reply, reply_size, [this]() { radio_driver.resetStats(); this->resetStats(); @@ -4404,7 +4461,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, } else if (!radio_driver.supportsRxBoostedGainMode()) { mesh::cli::formatError(reply, reply_size, "unsupported"); } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { - mesh::cli::formatError(reply, reply_size, "radio busy or save failed"); + mesh::cli::formatError(reply, reply_size, "RX gain failed"); } else { snprintf(reply, reply_size, "OK - radio.rxgain %s", value); } @@ -4885,10 +4942,11 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, memset(saved_password, 0, sizeof(saved_password)); if (_webconfig) { _webconfig->reloadStandaloneWiFi(); - if (has_ssid && _webconfig->isRunning()) { - _webconfig->requestStop(); - } + _webconfig->requestRestart(); } +#ifdef WITH_MQTT_BRIDGE + if (_mqtt_enabled && _mqtt_bridge) _mqtt_bridge->requestRestart(); +#endif if (has_ssid) { scheduleCompanionWiFiCredentialReload(); snprintf(reply, reply_size, @@ -4932,10 +4990,10 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (!WebConfigServer::saveEnabled(enabled)) { mesh::cli::formatError(reply, reply_size, "failed to save webui setting"); } else if (!enabled) { - if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); + if (_webconfig) _webconfig->requestStop(); snprintf(reply, reply_size, "OK - webui off"); } else if (_webconfig - && (_webconfig->isRunning() || _webconfig->isStopping())) { + && _webconfig->isRunning()) { snprintf(reply, reply_size, "OK - webui on (already active)"); } else { char start_reply[160] = {0}; @@ -4966,7 +5024,8 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, return true; } if (strcmp(command, "stop webconfig") == 0) { - if (!_webconfig || !_webconfig->isRunning()) { + if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping() + && !_webconfig->isStartPending())) { snprintf(reply, reply_size, "Err: webconfig not running"); } else { _webconfig->requestStop(); @@ -5120,7 +5179,6 @@ void MyMesh::serviceTempRadio() { _temp_radio_retry_at = 0; _temp_radio_failures = 0; _temp_radio_applied = false; - saved_radio_apply_pending = false; MESH_DEBUG_PRINTLN("Full companion restored normal radio"); } else { _temp_radio_retry_at = futureMillis( @@ -5181,12 +5239,19 @@ static void copyMqttString(char* dest, size_t dest_size, const char* src) { void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) { if (!_mqtt_enabled) { stopMQTT(); + if (_mqtt_bridge) _mqtt_bridge->loop(); return; } #ifdef WITH_WEBCONFIG if (_wc_batch_active) return; #endif - if (!_mqtt_started) { + // OTA keeps its radio/heap ownership; retain desired MQTT for a later loop. + if (board.isOTAUpdateRunning()) { + if (_mqtt_bridge && _mqtt_bridge->isStopping()) _mqtt_bridge->loop(); + return; + } + if (_mqtt_bridge && !_mqtt_bridge->isRunning() + && !_mqtt_bridge->isStopping()) { if (strcmp(_mqtt_prefs.wifi_ssid, wifi_ssid ? wifi_ssid : "") != 0) { copyMqttString(_mqtt_prefs.wifi_ssid, sizeof(_mqtt_prefs.wifi_ssid), wifi_ssid); } @@ -5194,18 +5259,23 @@ void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) { copyMqttString(_mqtt_prefs.wifi_password, sizeof(_mqtt_prefs.wifi_password), wifi_password); } } + if (_mqtt_bridge) _mqtt_bridge->loop(); + _mqtt_started = _mqtt_bridge && _mqtt_bridge->isRunning() + && !_mqtt_bridge->isStopping(); // Start the recovery scheduler even when the router is down at boot. The // Companion still owns association; waiting for GOT_IP here would leave its // failed station outside MQTT/NTP's shared power budget indefinitely. - if (_mqtt_configured && !_mqtt_started && _mqtt_bridge + if (_mqtt_configured && wifi_ssid && wifi_ssid[0] + && !_mqtt_started && _mqtt_bridge + && !_mqtt_bridge->isStartPending() #ifdef WITH_WEBCONFIG && !isWebConfigSetupActive() #endif ) { - _mqtt_started = true; // begin is one-shot; avoid retrying partial allocations _mqtt_bridge->begin(); - if (_mqtt_bridge->isRunning()) { + _mqtt_started = _mqtt_bridge->isRunning() && !_mqtt_bridge->isStopping(); + if (_mqtt_started) { mesh::usbLoggingPort().println("MQTT companion: bridge started"); } else { mesh::usbLoggingPort().println( @@ -5215,7 +5285,7 @@ void MyMesh::serviceMQTT(const char* wifi_ssid, const char* wifi_password) { } void MyMesh::stopMQTT() { - if (_mqtt_started && _mqtt_bridge) _mqtt_bridge->end(); + if (_mqtt_bridge) _mqtt_bridge->requestStop(); _mqtt_started = false; } #endif @@ -5358,6 +5428,10 @@ void MyMesh::getNodeSnapshot(WebConfigServer::NodeSnapshot& s) { } bool MyMesh::startWebConfig(bool force_ap, char* reply) { + if (board.isOTAUpdateRunning()) { + strcpy(reply, "Err: OTA server is running - 'stop ota' first"); + return false; + } #if defined(ESP32_PLATFORM) && defined(WIFI_SSID) if (!_management_password_ready) { strcpy(reply, "Err: management password unavailable; set password on USB"); @@ -5375,35 +5449,34 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) { strcpy(reply, "Err: WebUI unavailable (not enough memory)"); return false; } - if (_webconfig->isRunning() || _webconfig->isStopping()) { + if (_webconfig->isRunning()) { if (force_ap && !_webconfig->isStopping() && _webconfig->mode() == WebConfigServer::MODE_LAN) { #ifdef WITH_MQTT_BRIDGE - if (_mqtt_started && _mqtt_bridge) { + if (_mqtt_bridge) { _mqtt_bridge->end(); _mqtt_started = false; } #endif - return _webconfig->startSetupMode(reply); + return _webconfig->requestStart(true, reply); } - strcpy(reply, _webconfig->isStopping() ? "Err: WebUI still stopping" - : "Err: WebUI already running"); + strcpy(reply, "Err: WebUI already running"); return false; } if (force_ap) { #ifdef WITH_MQTT_BRIDGE - if (_mqtt_started && _mqtt_bridge) { + if (_mqtt_bridge) { _mqtt_bridge->end(); _mqtt_started = false; } #endif - return _webconfig->startSetupMode(reply); + return _webconfig->requestStart(true, reply); } - return _webconfig->startAutoMode(reply); + return _webconfig->requestStart(false, reply); } void MyMesh::stopWebConfig() { - if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); + if (_webconfig) _webconfig->requestStop(); } void MyMesh::serviceWebConfig() { @@ -5411,7 +5484,13 @@ void MyMesh::serviceWebConfig() { } bool MyMesh::isWebConfigActiveOrStopping() const { - return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping()); + return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping() + || _webconfig->isStartPending()); +} + +bool MyMesh::isWebConfigWiFiTransitionActive() const { + return _webconfig && (_webconfig->isStopping() + || _webconfig->mode() == WebConfigServer::MODE_SETUP); } bool MyMesh::isWebConfigSetupActive() const { @@ -5447,7 +5526,7 @@ void MyMesh::onConfigBatchEnd() { #ifdef WITH_MQTT_BRIDGE if (_wc_mqtt_dirty) { CompanionMqttSetupPortal::saveStoredConfig(_mqtt_prefs); - if (_mqtt_started && _mqtt_bridge) _mqtt_bridge->end(); + if (_mqtt_bridge) _mqtt_bridge->end(); _mqtt_started = false; // loadStoredConfig only replaces its destination after validation. Pass // the live preferences directly: another 2.8 KB copy here nests with the @@ -5645,35 +5724,16 @@ void MyMesh::execCommand(char* cmd, char* reply) { return; } if (strcmp(key, "radio") == 0) { - float freq, bw; - uint8_t sf, cr; - if (!mesh::cli::parseRadioTupleStrict(value, freq, bw, sf, cr) - || !isfinite(freq) || !isfinite(bw) || freq < 150.0f || freq > 2500.0f - || bw < 7.0f || bw > 500.0f || sf < 5 || sf > 12 || cr < 5 || cr > 8) { + float freq, bw; uint8_t sf, cr; + if (!mesh::cli::parseRadioTupleStrict(value, freq, bw, sf, cr)) { mesh::cli::formatError(reply, 160, "radio must be freq,bw,sf,cr"); } else { - const float previous_freq = _prefs.freq, previous_bw = _prefs.bw; - const uint8_t previous_sf = _prefs.sf, previous_cr = _prefs.cr; - const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; - const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; - _prefs.freq = freq; - _prefs.bw = bw; - _prefs.sf = sf; - _prefs.cr = cr; - recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, - _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, - &_prefs.rx_ps_sleep_us); - if (savePrefs()) { - strcpy(reply, "OK - reboot required"); - } else { - _prefs.freq = previous_freq; - _prefs.bw = previous_bw; - _prefs.sf = previous_sf; - _prefs.cr = previous_cr; - _prefs.rx_ps_rx_us = previous_rx_us; - _prefs.rx_ps_sleep_us = previous_sleep_us; - mesh::cli::formatError(reply, 160, "radio could not be saved"); - } + const auto result = applyAndSaveRadioParams(freq, bw, sf, cr, + _prefs.client_repeat, _radio_profiles.primaryPreamble()); + strcpy(reply, result == RadioSettingResult::Saved ? "OK" + : result == RadioSettingResult::SaveFailed ? "Error: radio not saved" + : result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed" + : "Error: radio rejected"); } return; } @@ -5711,11 +5771,11 @@ void MyMesh::execCommand(char* cmd, char* reply) { } else { if (strcmp(key, "radio.rxgain") == 0) { if (!radio_driver.supportsRxBoostedGainMode()) { - mesh::cli::formatError(reply, 160, "RX boosted gain unsupported"); + mesh::cli::formatError(reply, 160, "RX gain unsupported"); return; } if (!applyAndSaveRxBoostedGain(enabled)) { - mesh::cli::formatError(reply, 160, "radio busy; retry"); + mesh::cli::formatError(reply, 160, "RX gain failed"); return; } } else { @@ -6050,8 +6110,7 @@ void MyMesh::abandonPendingRequest() { bool MyMesh::hasFiniteDelayedReplyForRoute(BaseSerialInterface* route) const { if (route == NULL) return false; - if (_delayed_replies.hasReplyForRoute(route, _ms->getMillis()) - || (command_radio_apply_pending && command_radio_reply_route == route)) { + if (_delayed_replies.hasReplyForRoute(route, _ms->getMillis())) { return true; } for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) { @@ -6116,7 +6175,6 @@ void MyMesh::cancelSerialOperationsForRoute(BaseSerialInterface* route) { memset(private_key_backup_sender, 0, sizeof(private_key_backup_sender)); } if (_delayed_replies.request.route == route) clearPendingReqs(); - if (command_radio_reply_route == route) cancelPendingRadioParamApply(); if (_delayed_replies.trace.route == route) clearBinaryTraceReply(); if (sign_data_reply_route == route) cancelSigningSession(); for (int i = 0; i < EXPECTED_ACK_TABLE_SIZE; ++i) { @@ -6759,71 +6817,21 @@ void MyMesh::handleCmdFrame(size_t len) { } else if (cmd_frame[0] == CMD_SYNC_NEXT_MESSAGE) { syncNextOfflineMessage(); } else if (cmd_frame[0] == CMD_SET_RADIO_PARAMS) { - if (len < 11) { + if (len < 11) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); return; } + uint32_t freq, bw; + memcpy(&freq, &cmd_frame[1], 4); + memcpy(&bw, &cmd_frame[5], 4); + const uint8_t sf = cmd_frame[9], cr = cmd_frame[10]; + const uint8_t repeat = len > 11 ? cmd_frame[11] : 0; + if (freq < 150000 || freq > 2500000 || bw < 7000 || bw > 500000 + || sf < 5 || sf > 12 || cr < 5 || cr > 8) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } -#if COMPANION_FEATURE_TEMP_RADIO - if (isTempRadioActive() || _temp_radio_set_at != 0 - || _temp_radio_revert_at != 0) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } -#endif - int i = 1; - uint32_t freq; - memcpy(&freq, &cmd_frame[i], 4); - i += 4; - uint32_t bw; - memcpy(&bw, &cmd_frame[i], 4); - i += 4; - uint8_t sf = cmd_frame[i++]; - uint8_t cr = cmd_frame[i++]; - uint8_t repeat = 0; // default - false - if (len > i) { - repeat = cmd_frame[i++]; // FIRMWARE_VER_CODE 9+ - } - - if (repeat && !isValidClientRepeatFreq(freq)) { - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - } else if (freq >= 150000 && freq <= 2500000 && sf >= 5 && sf <= 12 && cr >= 5 && cr <= 8 && bw >= 7000 && - bw <= 500000) { - float new_freq = (float)freq / 1000.0; - float new_bw = (float)bw / 1000.0; - if (command_radio_apply_pending) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - - mesh::RadioParamApplyResult result = tryApplyRadioParams(new_freq, new_bw, sf, cr); - if (result == mesh::RadioParamApplyResult::BUSY) { - // Capture this requester instead of relying on the mutable last-RX - // route. This remains correct if a contact stream later unlocks or a - // different transport supplies another command before completion. - command_radio_freq = new_freq; - command_radio_bw = new_bw; - command_radio_sf = sf; - command_radio_cr = cr; - command_radio_repeat = repeat; - deferRadioCommand(); - MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy"); - return; - } - if (result == mesh::RadioParamApplyResult::FAILED) { - // Persisted settings remain authoritative after a rejected change or - // a hardware apply that had to be rolled back. - saved_radio_apply_pending = true; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - writeErrFrame(ERR_CODE_ILLEGAL_ARG); - return; - } - - finishRadioParamApply(new_freq, new_bw, sf, cr, repeat); } else { - MESH_DEBUG_PRINTLN("Error: CMD_SET_RADIO_PARAMS: f=%d, bw=%d, sf=%d, cr=%d", freq, bw, (uint32_t)sf, - (uint32_t)cr); - writeErrFrame(ERR_CODE_ILLEGAL_ARG); + const auto result = applyAndSaveRadioParams(freq / 1000.0f, bw / 1000.0f, + sf, cr, repeat, _radio_profiles.primaryPreamble()); + if (result == RadioSettingResult::Saved) writeOKFrame(); + else writeErrFrame(result == RadioSettingResult::RadioRejected + ? ERR_CODE_ILLEGAL_ARG : ERR_CODE_FILE_IO_ERROR); } } else if (cmd_frame[0] == CMD_SET_RADIO_TX_POWER) { if (len < 2) { @@ -8481,7 +8489,7 @@ bool MyMesh::saveAdvertLocation(double latitude, double longitude) { MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) { if (!radio_driver.isTxPowerValid(power)) return RadioSettingResult::RadioRejected; const int8_t previous = _prefs.tx_power_dbm; - const auto result = _radio_available && !command_radio_apply_pending + const auto result = _radio_available ? radio_driver.trySetTxPower(power) : mesh::RadioParamApplyResult::BUSY; if (result == mesh::RadioParamApplyResult::FAILED) { // A rejected SPI transaction may have partly changed the hardware. Keep @@ -8507,7 +8515,7 @@ MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) { } void MyMesh::servicePendingTxPower() { - if (!tx_power_apply_pending || !_radio_available || command_radio_apply_pending + if (!tx_power_apply_pending || !_radio_available || (_radio && _radio->isCarrierWaveActive())) return; const uint32_t now = _ms->getMillis(); if (tx_power_apply_retry_at @@ -8561,24 +8569,22 @@ bool MyMesh::applyAndSaveFemTxGain(bool enabled) { } bool MyMesh::applyAndSaveRxBoostedGain(bool enabled) { - if (!radio_driver.supportsRxBoostedGainMode() - || (_radio_available && !radio_driver.setRxBoostedGainMode(enabled))) { + if (!radio_driver.supportsRxBoostedGainMode()) return false; + const auto result = _radio_available + ? radio_driver.trySetRxBoostedGainMode(enabled) : mesh::RadioParamApplyResult::BUSY; + if (result == mesh::RadioParamApplyResult::FAILED) { + queueSavedRadioApply(); return false; } - const uint8_t previous_pref = _prefs.rx_boosted_gain; _prefs.rx_boosted_gain = enabled ? 1 : 0; if (!savePrefs()) { _prefs.rx_boosted_gain = previous_pref; - if (_radio_available && !radio_driver.setRxBoostedGainMode(previous_pref != 0)) { - // A receive can make the rollback busy after hardware accepted the - // candidate. Retry the durable gain along with the saved radio settings. - saved_radio_apply_pending = true; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - } + if (result == mesh::RadioParamApplyResult::APPLIED + && !radio_driver.setRxBoostedGainMode(previous_pref != 0)) queueSavedRadioApply(); return false; } + if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply(); return true; } @@ -9140,7 +9146,7 @@ bool MyMesh::handleRxPowerSavingGetCommand(const char* command, char* reply, bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { if (!radio_driver.supportsRxPowerSaving()) { - mesh::cli::formatError(reply, 160, "RX power saving unsupported"); + mesh::cli::formatError(reply, 160, "RXPS unsupported"); return false; } @@ -9154,7 +9160,7 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { mesh::cli::RxPowerSavingArguments parsed = {}; if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) { - mesh::cli::formatError(reply, 160, "use off, level 1-10, or RX/SLEEP microseconds"); + mesh::cli::formatError(reply, 160, "use off, level 1-10, or RX_US SLEEP_US"); return false; } @@ -9175,12 +9181,12 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { if (parsed.level < 1 || parsed.level > 10) { strcpy(reply, parsed.preamble == 0 ? "Error: level must be 1-10" - : "Error: level must be 1-10; preamble must be 16 or 32"); + : "Error: level 1-10; preamble 16 or 32"); return false; } if (parsed.preamble != 0 && parsed.preamble != 16 && parsed.preamble != 32) { - mesh::cli::formatError(reply, 160, "level must be 1-10; preamble must be 16 or 32"); + mesh::cli::formatError(reply, 160, "level 1-10; preamble 16 or 32"); return false; } enabled = 1; @@ -9192,7 +9198,7 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { || parsed.rx_us > RX_POWERSAVING_MAX_PERIOD_US || parsed.sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US || parsed.sleep_us > RX_POWERSAVING_MAX_PERIOD_US) { - snprintf(reply, 160, "Error: RX/SLEEP must be %lu-%lu us", + snprintf(reply, 160, "Error: RX/SLEEP %lu-%lu us", (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return false; @@ -9210,22 +9216,25 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { || (preamble != 0 && preamble != 16 && preamble != 32) || !recalcRxPowerSavingFromLevel(level, _prefs.sf, _prefs.bw, preamble, &rx_us, &sleep_us)) { - mesh::cli::formatError(reply, 160, "level must be 1-10; preamble must be auto, 16, or 32"); + mesh::cli::formatError(reply, 160, "level 1-10; preamble auto, 16 or 32"); return false; } } if ((manual_requested || enabled) && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { - snprintf(reply, 160, "Error: RX/SLEEP must be %lu-%lu us", + snprintf(reply, 160, "Error: RX/SLEEP %lu-%lu us", (unsigned long)RX_POWERSAVING_MIN_MANUAL_PERIOD_US, (unsigned long)RX_POWERSAVING_MAX_PERIOD_US); return false; } - if (_radio_available - && !radio_driver.setRxPowerSaving(enabled != 0, rx_us, sleep_us)) { - mesh::cli::formatError(reply, 160, "radio busy; retry"); + const auto result = _radio_available && !_primary_radio_blocked + ? radio_driver.trySetRxPowerSaving(enabled != 0, rx_us, sleep_us) + : mesh::RadioParamApplyResult::BUSY; + if (result == mesh::RadioParamApplyResult::FAILED) { + queueSavedRadioApply(); + mesh::cli::formatError(reply, 160, "RXPS rejected"); return false; } @@ -9244,15 +9253,13 @@ bool MyMesh::applyAndSaveRxPowerSaving(const char* value, char* reply) { _prefs.rx_ps_sleep_us = previous_sleep; _prefs.rx_ps_level = previous_level; _prefs.rx_ps_preamble = previous_preamble; - if (_radio_available - && !radio_driver.setRxPowerSaving(previous_enabled != 0, previous_rx, previous_sleep)) { - saved_radio_apply_pending = true; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - } - mesh::cli::formatError(reply, 160, "RX power saving could not be saved"); + if (result == mesh::RadioParamApplyResult::APPLIED + && !radio_driver.setRxPowerSaving(previous_enabled != 0, previous_rx, previous_sleep)) + queueSavedRadioApply(); + mesh::cli::formatError(reply, 160, "RXPS not saved"); return false; } + if (result == mesh::RadioParamApplyResult::BUSY) queueSavedRadioApply(); snprintf(reply, 160, "OK - %s,%lu,%lu", enabled ? "on" : "off", (unsigned long)rx_us, (unsigned long)sleep_us); @@ -10370,7 +10377,7 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, #endif } else if (strcmp(command, "get radio.rxgain") == 0) { if (!radio_driver.supportsRxBoostedGainMode()) { - terminalOutput().print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); + terminalOutput().print(" ERROR: RX gain unsupported\r\n"); } else { terminalOutput().printf(" radio.rxgain %s\r\n", _prefs.rx_boosted_gain ? "on" : "off"); @@ -10415,11 +10422,11 @@ void MyMesh::handleTerminalCommand(char* command, bool reader_prompt, const char* value = config + 12; while (*value == ' ' || *value == '\t') value++; if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { - terminalOutput().print(" ERROR: use set radio.rxgain \r\n"); + terminalOutput().print(" ERROR: use on|off\r\n"); } else if (!radio_driver.supportsRxBoostedGainMode()) { - terminalOutput().print(" ERROR: RX boosted gain is unsupported on this radio\r\n"); + terminalOutput().print(" ERROR: RX gain unsupported\r\n"); } else if (!applyAndSaveRxBoostedGain(strcmp(value, "on") == 0)) { - terminalOutput().print(" ERROR: radio busy; retry\r\n"); + terminalOutput().print(" ERROR: RX gain failed\r\n"); } else { terminalOutput().printf(" OK - radio.rxgain %s\r\n", value); } @@ -10901,9 +10908,6 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (_radio_profiles.handle(command, reply, reply_capacity, sender_timestamp != 0)) return true; if (!strncmp(command, "set tempradio ", 14)) command += 4; if (!strcmp(command, "get tempradio")) command += 4; - char profile_command[140]; - const uint16_t previous_primary_preamble = _radio_profiles.primaryPreamble(); - uint16_t requested_primary_preamble = previous_primary_preamble; if (!strncmp(command, "set radio ", 10)) { char legacy[120]; uint16_t preamble = 0; @@ -10915,9 +10919,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (!_radio_profiles.acceptsPrimary(f, bw, sf, cr, preamble)) { mesh::cli::formatError(reply, reply_capacity, "radio params unsupported"); return true; } - requested_primary_preamble = preamble; - snprintf(profile_command, sizeof(profile_command), "set radio %s", legacy); - command = profile_command; + const auto result = applyAndSaveRadioParams(f, bw, sf, cr, _prefs.client_repeat, + preamble, sender_timestamp != 0); + snprintf(reply, reply_capacity, "%s", result == RadioSettingResult::Saved ? "OK" + : result == RadioSettingResult::SaveFailed ? "Error: radio not saved" + : result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed" + : "Error: radio rejected"); + if (result == RadioSettingResult::Saved) + _radio_profiles.appendPrimaryChirpWarning(reply, reply_capacity, sf, bw, preamble); + return true; } if (sender_timestamp == 0 && handleDirectCommand(command, reply, reply_capacity)) return true; if (handleNotificationCommand(command, reply, reply_capacity)) return true; @@ -10948,14 +10958,12 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, || frequency < 150.0f || frequency > 2500.0f) { mesh::cli::formatError(reply, reply_capacity, "frequency must be 150-2500 MHz"); } else { - const float previous = _prefs.freq; - _prefs.freq = frequency; - if (savePrefs()) { - snprintf(reply, reply_capacity, "OK - reboot to apply"); - } else { - _prefs.freq = previous; - mesh::cli::formatError(reply, reply_capacity, "frequency could not be saved"); - } + const auto result = applyAndSaveRadioParams(frequency, _prefs.bw, _prefs.sf, _prefs.cr, + _prefs.client_repeat, _radio_profiles.primaryPreamble(), sender_timestamp != 0); + snprintf(reply, reply_capacity, "%s", result == RadioSettingResult::Saved ? "OK" + : result == RadioSettingResult::SaveFailed ? "Error: radio not saved" + : result == RadioSettingResult::SaveUnconfirmed ? "Error: radio save unconfirmed" + : "Error: radio rejected"); } return true; } @@ -10996,10 +11004,6 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, } if (isCompanionRadioPrefsCommand(command)) { - const float previous_freq = _prefs.freq; - const float previous_bw = _prefs.bw; - const uint8_t previous_sf = _prefs.sf; - const uint8_t previous_cr = _prefs.cr; const float previous_af = _prefs.airtime_factor; const float previous_rxdelay = _prefs.rx_delay_base; const float previous_txdelay = _prefs.tx_delay_factor; @@ -11008,30 +11012,13 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, const uint8_t previous_agc = _prefs.agc_reset_interval; const uint8_t previous_hash_mode = _prefs.path_hash_mode; const uint8_t previous_multi_acks = _prefs.multi_acks; - const uint32_t previous_rx_us = _prefs.rx_ps_rx_us; - const uint32_t previous_sleep_us = _prefs.rx_ps_sleep_us; if (!_prefs.getRadioPrefs()->handleCommand( - command, sender_timestamp, reply)) { + command, sender_timestamp, reply, false)) { return false; } if (_prefs.getRadioPrefs()->isDirty() && strncmp(reply, "OK", 2) == 0) { - if (strncmp(command, "set radio ", 10) == 0) { - recalcRxPowerSavingFromLevel( - _prefs.rx_ps_level, _prefs.sf, _prefs.bw, - _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, - &_prefs.rx_ps_sleep_us); - } - // Both validators must accept before either persistent image changes. - // In particular, the shared prefs parser has a tighter BW tolerance. - const bool primary_radio_command = strncmp(command, "set radio ", 10) == 0; - const bool preamble_saved = !primary_radio_command - || _radio_profiles.savePrimaryPreamble(requested_primary_preamble); - if (!preamble_saved || !savePrefs()) { - _prefs.freq = previous_freq; - _prefs.bw = previous_bw; - _prefs.sf = previous_sf; - _prefs.cr = previous_cr; + if (!savePrefs()) { _prefs.airtime_factor = previous_af; _prefs.rx_delay_base = previous_rxdelay; _prefs.tx_delay_factor = previous_txdelay; @@ -11040,24 +11027,10 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, _prefs.agc_reset_interval = previous_agc; _prefs.path_hash_mode = previous_hash_mode; _prefs.multi_acks = previous_multi_acks; - _prefs.rx_ps_rx_us = previous_rx_us; - _prefs.rx_ps_sleep_us = previous_sleep_us; _prefs.clearDirty(); - if (!preamble_saved) { - mesh::cli::formatError(reply, reply_capacity, "preamble could not be saved"); - } else if (primary_radio_command - && !_radio_profiles.savePrimaryPreamble(previous_primary_preamble)) { - mesh::cli::formatError(reply, reply_capacity, "radio save failed; preamble rollback failed"); - } else { - mesh::cli::formatError(reply, reply_capacity, "setting changed but save failed"); - } + mesh::cli::formatError(reply, reply_capacity, "setting changed but save failed"); } } - // Keep get radio as the upstream four-field tuple. Apps parse this CSV - // directly; preamble and timing diagnostics have separate commands. - 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; } @@ -11595,30 +11568,7 @@ void MyMesh::loop() { #endif serviceBinaryTraceReply(); if (!mesh::morseTimingActive(_radio)) serviceSigningSession(); - if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound() - && _radio_available -#if COMPANION_FEATURE_TEMP_RADIO - && !_temp_radio_applied && _temp_radio_set_at == 0 - && _temp_radio_revert_at == 0 -#endif - && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) { - // A power-saving wake can enter begin() with a complete packet already - // waiting. Preserve that packet, then apply the persisted radio settings - // once the receive/response path is idle. - if ((!radio_driver.supportsRxBoostedGainMode() - || radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) - && applySavedRadioParams() - && radio_driver.setTxPower(_prefs.tx_power_dbm) - && (!radio_driver.supportsRxPowerSaving() - || radio_driver.setRxPowerSaving(_prefs.rx_powersaving_enabled != 0, - _prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us))) { - saved_radio_apply_pending = false; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - } else { - radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); - } - } + if (has_next_ack_expiry && (next_ack_expiry == _ms->getMillis() || millisHasNowPassed(next_ack_expiry))) { expireExpectedAcks(); @@ -11639,8 +11589,7 @@ void MyMesh::loop() { checkCLIRescueCmd(); #endif } - servicePendingRadioParamApply(); - servicePendingTxPower(); + servicePendingLocalRadioSettings(); #if MESH_ENABLE_ONE_KEY_DM if (held_dm_count != 0 || verified_pending_count != 0) releaseHeldOneKeyDMs(); #endif @@ -11733,7 +11682,8 @@ bool MyMesh::canRecoverUsbLogging() const { && !_store->hasPendingAdvertWrites() #endif && !hasOutbound() && !isAnyTempRadioActive() && !hasPendingOtaApply() - && !command_radio_apply_pending && !saved_radio_apply_pending && !tx_power_apply_pending + && !saved_radio_apply_pending && !tx_power_apply_pending + && rf_rx_disable_pending < 0 && !_radio_profiles.hasPendingTuning() && _scheduled_reboot_at == 0 && !hasPendingReqs() && !_delayed_replies.hasBinaryTrace() && sign_data == NULL #if COMPANION_FEATURE_TEXT_TERMINAL @@ -11778,7 +11728,8 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { if (_iter_started && _serial != NULL && _serial->isReplyRouteAvailable(_iter_reply_route)) return mask(Reason::SerialIO); if (hasPendingOtaApply()) return mask(Reason::Ota); - if (command_radio_apply_pending || (tx_power_apply_pending && _radio_available) + if (_radio_profiles.hasPendingTuning() || (rf_rx_disable_pending >= 0 && _radio_available) + || (tx_power_apply_pending && _radio_available) || _scheduled_reboot_at != 0) return mask(Reason::Scheduled); if (hasPendingReqs() || _delayed_replies.hasBinaryTrace()) return mask(Reason::Mesh); #if COMPANION_FEATURE_TEXT_TERMINAL @@ -11794,7 +11745,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { && !_temp_radio_applied && _temp_radio_set_at == 0 && _temp_radio_revert_at == 0 #endif - && (!radio_apply_retry_at || millisHasNowPassed(radio_apply_retry_at))) return mask(Reason::Scheduled); + ) return mask(Reason::Scheduled); if (contact_write_needs_polling || _store->contactRecoveryPending()) return mask(Reason::Mesh); #if defined(ESP32_PLATFORM) if (_store->hasPendingAdvertWrites()) return mask(Reason::Mesh); diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 53c901f7c..c7e0774a4 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -30,6 +30,7 @@ #include "DataStore.h" #include #include +#include #include #if MESH_ENABLE_ETHERNET_TUNNEL #include @@ -221,6 +222,7 @@ public: void begin(bool has_display, bool radio_available = true, mesh::ui::StartupScreen* startup_screen = nullptr); void activateRadio(); + void servicePendingLocalRadioSettings(); bool isRadioReady() const { return _radio_available; } void startInterface(BaseSerialInterface &serial); void cancelSerialResponseStream(BaseSerialInterface* route = NULL); @@ -273,6 +275,7 @@ public: void stopWebConfig(); void serviceWebConfig(); bool isWebConfigActiveOrStopping() const; + bool isWebConfigWiFiTransitionActive() const; bool isWebConfigSetupActive() const; bool isWebConfigWiFiRecoveryActive() const; @@ -678,7 +681,7 @@ private: bool applyAndSaveFemRxGain(bool enabled); bool applyAndSaveFemTxGain(bool enabled); bool applyAndSaveRxBoostedGain(bool enabled); - enum class RadioSettingResult { Saved, RadioRejected, SaveFailed }; + enum class RadioSettingResult { Saved, RadioRejected, SaveFailed, SaveUnconfirmed }; RadioSettingResult applyAndSaveTxPower(int8_t power); void servicePendingTxPower(); bool handleCadCommand(const char* command, char* reply, size_t reply_size); @@ -750,12 +753,11 @@ private: mesh::RadioParamApplyResult tryApplyRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, bool temporary = false, uint16_t preamble = 0); bool applySavedRadioParams(); void configureRadioFromPrefs(); - void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, - uint8_t repeat, - BaseSerialInterface* route = nullptr); - void deferRadioCommand(); - void cancelPendingRadioParamApply(); - void servicePendingRadioParamApply(); + void queueSavedRadioApply(); + void finishPrimaryRadioReply(bool delivered); + bool applyRxPowerSavingRfRxDisabled(const char* value, char* reply, size_t capacity); + RadioSettingResult applyAndSaveRadioParams(float freq, float bw, uint8_t sf, + uint8_t cr, uint8_t repeat, uint16_t preamble, bool remote = false); void servicePendingSerialReply(); void servicePendingSerialReply(uint32_t now); bool beginPendingRequest(mesh::CompanionDelayedReplies::Kind kind, @@ -807,6 +809,8 @@ private: #endif mesh::MorseReplyHandoff _morse_reply; bool _morse_reply_admission = false; + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_blocked = false, _primary_radio_reply_admission = false; #if MESH_ENABLE_MESHTEXT_CHAT mesh::mt::Region _mt_region = mesh::mt::Region::Unset; uint32_t _mt_slot = 0, _mt_node = 0, _mt_packet_id = 0; @@ -913,20 +917,14 @@ private: uint8_t _morse_terminal_pending_chars = 0; bool _morse_terminal_was_receiving = false; #endif + int8_t rf_rx_disable_pending = -1; + uint32_t rf_rx_retry_at = 0; bool saved_radio_apply_pending; bool _radio_available; bool tx_power_apply_pending = false; uint32_t tx_power_apply_retry_at = 0; unsigned long radio_apply_retry_at; uint8_t radio_apply_failures; - bool command_radio_apply_pending; - float command_radio_freq; - float command_radio_bw; - uint8_t command_radio_sf; - uint8_t command_radio_cr; - uint8_t command_radio_repeat; - unsigned long command_radio_apply_deadline; - BaseSerialInterface* command_radio_reply_route; // Deferred so USB/TCP terminals can transmit the acknowledgement before // the transport disappears. Also used by USB interface changes. unsigned long _scheduled_reboot_at; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 3ebca00bd..23709144c 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2625,7 +2625,7 @@ void halt() { } if (companionWiFiBackgroundSuspended()) return; #ifdef WITH_WEBCONFIG - if (the_mesh.isWebConfigActiveOrStopping()) return; + if (the_mesh.isWebConfigWiFiTransitionActive()) return; #endif // Retain the pending reload until the operator's OTA/setup AP closes. if (!mesh::wifi::stationMutationAllowed()) return; diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 5659d7372..a41dc76ee 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -4387,6 +4387,9 @@ void MyMesh::onSendComplete(mesh::Packet* packet) { if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles()); #endif if (temp_radio_reply_barrier.complete(packet) && !temp_radio_reply_barrier.waiting()) finishRadioReply(true); + _cli.servicePendingRxSettings(); + _cli.radioProfiles().servicePendingTuning(); + processScheduledRadioSettings(true); if (packet == pending_battery_alert_packet) { pending_battery_alert_packet = NULL; battery_alert_sent = true; @@ -5129,9 +5132,9 @@ void MyMesh::serviceFleetScheduleReply() { } #endif -void MyMesh::processScheduledRadioSettings() { +void MyMesh::processScheduledRadioSettings(bool tx_gap) { #if MESH_ENABLE_FLEET_CONTROL - serviceFleetScheduleReply(); + if (!tx_gap) serviceFleetScheduleReply(); #endif const uint64_t current_uptime_millis = uptime_millis + (uint32_t)(millis() - last_millis); @@ -5175,7 +5178,8 @@ void MyMesh::processScheduledRadioSettings() { // changes retain their existing bounded retry behavior. if (scheduled_radio_retry_at && !millisHasNowPassed(scheduled_radio_retry_at) - && !hard_temp_end_due) return; + && !hard_temp_end_due + && !(tx_gap && scheduled_radio_retry_failures == 0)) return; const bool schedule_check_due = next_scheduled_radio_time != 0 && (next_scheduled_radio_check_at == 0 @@ -5193,7 +5197,7 @@ void MyMesh::processScheduledRadioSettings() { // Never touch modulation registers while a packet is still on air. Back off // this check too; a long packet should not make the scheduler poll every loop. - if (hasOutbound()) { + if (!tx_gap && hasOutbound()) { scheduled_radio_retry_at = futureMillis(RADIO_APPLY_RETRY_INTERVAL_MILLIS); return; } @@ -5908,6 +5912,14 @@ bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return ok; } +bool MyMesh::supportsRxPowerSaving() const { + return radio_driver.supportsRxPowerSaving(); +} + +mesh::RadioParamApplyResult MyMesh::trySetRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + return radio_driver.trySetRxPowerSaving(enable, rx_us, sleep_us); +} + bool MyMesh::supportsRxPowerSavingRfRxDisable() const { return radio_driver.supportsRxPowerSavingRfRxDisable(); } @@ -5924,6 +5936,10 @@ bool MyMesh::isRxPowerSavingRfRxDisabled() const { return radio_driver.isRxPowerSavingRfRxDisabled(); } +mesh::RadioParamApplyResult MyMesh::trySetRxPowerSavingRfRxDisabled(bool disabled) { + return radio_driver.trySetRxPowerSavingRfRxDisabled(disabled); +} + void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { *soft = radio_driver.getRxPsWatchdogSoftCount(); *hard = radio_driver.getRxPsWatchdogHardCount(); @@ -5933,10 +5949,22 @@ bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } +bool MyMesh::supportsRxBoostedGain() const { + return radio_driver.supportsRxBoostedGainMode(); +} + +mesh::RadioParamApplyResult MyMesh::trySetRxBoostedGain(bool enable) { + return radio_driver.trySetRxBoostedGainMode(enable); +} + #if defined(USE_LR2021) bool MyMesh::configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { return radio_driver.configSideDetectors(sideDetSFs, num, bw); } + +mesh::RadioParamApplyResult MyMesh::tryConfigSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { + return radio_driver.tryConfigSideDetectors(sideDetSFs, num, bw); +} #endif void MyMesh::formatNeighborsReply(char *reply) { @@ -11652,12 +11680,11 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup strcpy(reply, "Err: OTA server is running - 'stop ota' first"); return true; } - if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { + if (_webconfig && _webconfig->isRunning()) { if (!automatic_setup && !_webconfig->isStopping()) { _webconfig->requestNetworkAccess(); } - strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" - : "Err: webconfig already running"); + strcpy(reply, "Err: webconfig already running"); return true; } if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { @@ -11710,9 +11737,9 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup #endif bool started; if (force_ap) { - started = _webconfig->startSetupMode(reply); + started = _webconfig->requestStart(true, reply); } else { - started = _webconfig->startAutoMode(reply); + started = _webconfig->requestStart(false, reply); } if (!started) { // A failed portal must not consume a live operator-started transport. @@ -11734,7 +11761,8 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup } bool MyMesh::stopWebConfig(char* reply) { - if (!_webconfig || !_webconfig->isRunning()) { + if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping() + && !_webconfig->isStartPending())) { strcpy(reply, "Err: webconfig not running"); return true; } @@ -11749,7 +11777,7 @@ bool MyMesh::setWebUIEnabled(bool enabled, char* reply) { return true; } if (enabled) { - if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { + if (_webconfig && _webconfig->isRunning()) { strcpy(reply, "OK - webui on (already active)"); } else { startWebConfig(false, reply); @@ -11760,7 +11788,7 @@ bool MyMesh::setWebUIEnabled(bool enabled, char* reply) { } } } else { - if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); + if (_webconfig) _webconfig->requestStop(); strcpy(reply, "OK - webui off"); } return true; @@ -11801,24 +11829,30 @@ bool MyMesh::getWiFiCLI(char* reply) const { bool MyMesh::setWiFiSSID(const char* value, char* reply) { if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160, canonicalWiFiLegacyPrefs())) { - const bool was_running = _webconfig && _webconfig->isRunning(); - if (was_running) _webconfig->requestStop(); - if (_webconfig) _webconfig->reloadStandaloneWiFi(); - if (was_running) { - strcpy(reply, "OK - WiFi SSID saved; WebConfig stopping, start again to apply"); + if (_webconfig) { + _webconfig->reloadStandaloneWiFi(); + _webconfig->requestRestart(); } +#ifdef WITH_MQTT_BRIDGE + if (_prefs.bridge_enabled && mqtt_bridge + && !_cli.getBoard()->isOTAUpdateRunning()) mqtt_bridge->requestRestart(); +#endif + strcpy(reply, "OK - WiFi SSID saved; reconnect scheduled"); } return true; } bool MyMesh::setWiFiPassword(const char* value, char* reply) { if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160, canonicalWiFiLegacyPrefs())) { - const bool was_running = _webconfig && _webconfig->isRunning(); - if (was_running) _webconfig->requestStop(); - if (_webconfig) _webconfig->reloadStandaloneWiFi(); - if (was_running) { - strcpy(reply, "OK - WiFi password saved; WebConfig stopping, start again to apply"); + if (_webconfig) { + _webconfig->reloadStandaloneWiFi(); + _webconfig->requestRestart(); } +#ifdef WITH_MQTT_BRIDGE + if (_prefs.bridge_enabled && mqtt_bridge + && !_cli.getBoard()->isOTAUpdateRunning()) mqtt_bridge->requestRestart(); +#endif + strcpy(reply, "OK - WiFi password saved; reconnect scheduled"); } return true; } @@ -13858,7 +13892,7 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { #endif #if defined(WITH_MQTT_BRIDGE) // MQTT owns TLS on Core 0; the radio loop only reaps an acknowledged stop. - if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->loop(); + if (mqtt_bridge) mqtt_bridge->loop(); #endif #if defined(WITH_ESPNOW_BRIDGE) // ESP-NOW remains cooperative, including in the @@ -13960,7 +13994,8 @@ void __attribute__((noinline)) MyMesh::servicePostMeshLoop() { if (_webconfig) { _webconfig->updateWiFiOwnership(webConfigOwnsWiFi()); _webconfig->tick(millis()); - if (!_webconfig->isRunning() && !_webconfig->isStopping()) { + if (!_webconfig->isRunning() && !_webconfig->isStopping() + && !_webconfig->isStartPending()) { delete _webconfig; _webconfig = nullptr; } @@ -14813,7 +14848,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { if (_local_cli_output.busy()) return mask(Reason::SerialIO); #endif if (deferred_cli_command.pending || pending_self_advert || _cli.hasActiveUserGpioTimer() - || _cli.hasPendingTxPower()) return mask(Reason::Scheduled); + || _cli.hasPendingRadioSettings()) return mask(Reason::Scheduled); #if defined(WITH_BRIDGE) const AbstractBridge* active_bridge = activeBridge(); if (active_bridge && active_bridge->isRunning()) { diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 2c19efb2b..74d804706 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -930,7 +930,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool applySavedRadioParams(); void queueSavedRadioApply(); void refreshScheduledRadioState(); - void processScheduledRadioSettings(); + void processScheduledRadioSettings(bool tx_gap = false); bool isMillisTimerDue(unsigned long timestamp) const; bool floodChannelDataHopApplies(const mesh::Packet* packet) const; bool loadFloodPacketFilters(); @@ -1287,8 +1287,11 @@ public: bool isTxPowerApplied(int8_t power_dbm) const override; #endif bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + bool supportsRxPowerSaving() const override; + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; bool supportsRxPowerSavingRfRxDisable() const override; bool setRxPowerSavingRfRxDisabled(bool disabled) override; + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool disabled) override; bool isRxPowerSavingRfRxDisabled() const override; void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; void formatNeighborsReply(char *reply) override; @@ -1360,7 +1363,7 @@ public: #endif bool canRecoverUsbLogging() const { return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive() - && !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingTxPower() + && !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingRadioSettings() && !temp_radio_handoff_pending && _ota_update_at == 0; } #if MESH_ENABLE_FLOOD_RULE_ENGINE @@ -1612,11 +1615,14 @@ public: #ifdef WITH_MQTT_BRIDGE bool setMqttBridgeState(bool enable) override { if (!enable) { - if (mqtt_bridge && mqtt_bridge->isRunning()) mqtt_bridge->end(); + if (mqtt_bridge) mqtt_bridge->end(); _alerter.setBridge(nullptr); return !mqtt_bridge || !mqtt_bridge->isRunning(); } - if (mqtt_bridge && mqtt_bridge->isStopping()) return false; + if (mqtt_bridge && mqtt_bridge->isStopping()) { + mqtt_bridge->begin(); + return mqtt_bridge->isStartPending(); + } // Give the WiFi station to MQTT while it associates. ESP-NOW is restarted // after the AP channel is known, which avoids a raw ESP-NOW-only session // pinning the station to a different channel. @@ -1652,7 +1658,7 @@ public: _alerter.setBridge(mqtt_bridge); } if (_prefs.espnow_bridge_enabled) startSharedEspNowBridgeIfReady(); - return mqtt_bridge->isRunning(); + return mqtt_bridge->isRunning() || mqtt_bridge->isStartPending(); } #endif @@ -1706,7 +1712,11 @@ public: bool setBridgeState(bool enable) override { #ifdef WITH_MQTT_BRIDGE - if (enable && mqtt_bridge && mqtt_bridge->isStopping()) return false; + if (!enable && mqtt_bridge && mqtt_bridge->isStartPending()) mqtt_bridge->end(); + if (enable && mqtt_bridge && mqtt_bridge->isStopping()) { + mqtt_bridge->begin(); + return mqtt_bridge->isStartPending(); + } #endif #if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) if (!enable) { @@ -1785,7 +1795,11 @@ public: #else configureBridgeFilter(active_bridge); active_bridge->begin(); - const bool started = active_bridge->isRunning(); + const bool started = active_bridge->isRunning() +#ifdef WITH_MQTT_BRIDGE + || mqtt_bridge->isStartPending() +#endif + ; #endif #ifdef WITH_MQTT_BRIDGE _alerter.setBridge(mqtt_bridge); @@ -1967,6 +1981,12 @@ public: #endif } +#ifdef WITH_MQTT_BRIDGE + bool isMqttBridgeStartPending() override { + return mqtt_bridge && mqtt_bridge->isStartPending(); + } +#endif + bool syncMqttNtp() override { #ifdef WITH_MQTT_BRIDGE if (!mqtt_bridge || !mqtt_bridge->isRunning()) return false; @@ -2020,7 +2040,8 @@ public: bool setWiFiPowerSave(const char* value, char* reply) override; bool setWiFiCLI(const char* value, char* reply) override; bool isWebConfigActive() const override { - return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping()); + return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping() + || _webconfig->isStartPending()); } void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override; void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); } @@ -2071,10 +2092,13 @@ public: mesh::powerdiag::Mask pendingWorkReason() const; bool setRxBoostedGain(bool enable) override; + bool supportsRxBoostedGain() const override; + mesh::RadioParamApplyResult trySetRxBoostedGain(bool enable) override; void recalibrateNoiseFloor() override { _radio->recalibrateNoiseFloor(); } #if defined(USE_LR2021) virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) override; + mesh::RadioParamApplyResult tryConfigSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) override; #endif }; diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 4d357ef0a..c95486d63 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,5 +1,8 @@ #include "MyMesh.h" #include +#if defined(USE_LR2021) +#include +#endif #include #if defined(HELTEC_V4_R8_TFT) && defined(HELTEC_TFT_RUNTIME_ROTATION) \ && HELTEC_TFT_RUNTIME_ROTATION == 1 @@ -2427,6 +2430,73 @@ void MyMesh::onGroupPacketRecv(mesh::Packet* packet) { _clock_sync.observeGroupPacket(packet); } +void MyMesh::servicePendingPrimaryRadioSettings(bool tx_gap) { + 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; + if (revert_radio_due && !temp_radio_applied) { + // The temporary window ended before it could be applied. Drop both timers + // so a previously busy radio cannot switch to the expired channel later. + set_radio_at = revert_radio_at = 0; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); + } else if (revert_radio_due && !hasOutbound() && radio_apply_ready) { + if (applySavedRadioParams()) { + set_radio_at = revert_radio_at = 0; + temp_radio_applied = false; + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + MESH_DEBUG_PRINTLN("Radio params restored"); + } else { + radio_apply_failed = true; + } + } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound() + && radio_apply_ready) { + uint32_t rx_us = _prefs.rx_ps_rx_us; + uint32_t sleep_us = _prefs.rx_ps_sleep_us; + bool timing_ok = true; + if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { + uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble + : rxPowerSavingPreambleForParams(pending_sf, pending_bw); + timing_ok = CommonCLI::calculateRxPowerSavingLevel( + _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); + } + uint32_t timings[2] = {rx_us, sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (timing_ok && _cli.radioProfiles().applyPrimary( + pending_freq, pending_bw, pending_sf, pending_cr, true, pending_preamble, applied_timings) == mesh::RadioParamApplyResult::APPLIED) { + set_radio_at = 0; + active_cr = pending_cr; + temp_radio_applied = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + MESH_DEBUG_PRINTLN("Temp radio params"); + } else { + // A failed setParams() may have applied only a prefix of the tuple. + // Ensure expiry restores the complete saved configuration. + saved_radio_apply_pending = true; + radio_apply_failed = true; + } + } + + if (saved_radio_apply_pending && !temp_radio_applied && (tx_gap || !hasOutbound()) + && radio_apply_ready && !radio_apply_failed) { + if (applySavedRadioParams()) { + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + } else { + radio_apply_failed = true; + } + } + if (radio_apply_failed) { + radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); + } +} + void MyMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); _cli.servicePendingTxPower(); @@ -2434,6 +2504,9 @@ void MyMesh::onSendComplete(mesh::Packet* packet) { #if MESH_ENABLE_FLEET_CONTROL if (_cli.fleetChannel()) _cli.fleetChannel()->complete(packet, _cli.radioProfiles()); #endif + _cli.servicePendingRxSettings(); + _cli.radioProfiles().servicePendingTuning(); + servicePendingPrimaryRadioSettings(true); } void MyMesh::onSendFail(mesh::Packet* packet) { @@ -2872,6 +2945,19 @@ void MyMesh::begin(FILESYSTEM *fs) { } bool MyMesh::applySavedRadioParams() { +#if defined(USE_LR2021) + uint8_t extra_sf_count = 0; + const auto* profiles = radio_driver.profiles(); + const bool side_detectors_allowed = !profiles || !profiles->enabled(); + if (!mesh::lr2021::storedSideDetectorCount(_prefs.extra_sf, extra_sf_count) + || !mesh::lr2021::validateSideDetectorSFs( + _prefs.extra_sf, extra_sf_count, _prefs.sf, _prefs.bw)) { + if (side_detectors_allowed && !radio_driver.configSideDetectors(nullptr, 0, _prefs.bw)) return false; + memset(_prefs.extra_sf, 0, sizeof(_prefs.extra_sf)); + extra_sf_count = 0; + savePrefs(); + } +#endif // Each setter can defer while the radio is busy. Keep recovery pending // until the complete saved configuration has been accepted. if (radio_driver.supportsRxBoostedGainMode() @@ -2885,6 +2971,10 @@ bool MyMesh::applySavedRadioParams() { return false; } active_cr = _prefs.cr; +#if defined(USE_LR2021) + if (side_detectors_allowed + && !radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false; +#endif return radio_driver.setTxPower(_prefs.tx_power_dbm); } @@ -3209,6 +3299,26 @@ bool MyMesh::isTxPowerApplied(int8_t power_dbm) const { bool MyMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); } + +bool MyMesh::supportsRxPowerSaving() const { + return radio_driver.supportsRxPowerSaving(); +} + +mesh::RadioParamApplyResult MyMesh::trySetRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + return radio_driver.trySetRxPowerSaving(enable, rx_us, sleep_us); +} + +bool MyMesh::supportsRxPowerSavingRfRxDisable() const { + return radio_driver.supportsRxPowerSavingRfRxDisable(); +} + +mesh::RadioParamApplyResult MyMesh::trySetRxPowerSavingRfRxDisabled(bool disabled) { + return radio_driver.trySetRxPowerSavingRfRxDisabled(disabled); +} + +bool MyMesh::isRxPowerSavingRfRxDisabled() const { + return radio_driver.isRxPowerSavingRfRxDisabled(); +} void MyMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { *soft = radio_driver.getRxPsWatchdogSoftCount(); *hard = radio_driver.getRxPsWatchdogHardCount(); @@ -3218,6 +3328,20 @@ bool MyMesh::setRxBoostedGain(bool enable) { return radio_driver.setRxBoostedGainMode(enable); } +bool MyMesh::supportsRxBoostedGain() const { + return radio_driver.supportsRxBoostedGainMode(); +} + +mesh::RadioParamApplyResult MyMesh::trySetRxBoostedGain(bool enable) { + return radio_driver.trySetRxBoostedGainMode(enable); +} + +#if defined(USE_LR2021) +mesh::RadioParamApplyResult MyMesh::tryConfigSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { + return radio_driver.tryConfigSideDetectors(sideDetSFs, num, bw); +} +#endif + bool MyMesh::saveIdentity(const mesh::LocalIdentity &new_id) { #if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM) IdentityStore store(*_fs, ""); @@ -3351,12 +3475,11 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup strcpy(reply, "Err: OTA server is running - 'stop ota' first"); return true; } - if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { + if (_webconfig && _webconfig->isRunning()) { if (!automatic_setup && !_webconfig->isStopping()) { _webconfig->requestNetworkAccess(); } - strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly" - : "Err: webconfig already running"); + strcpy(reply, "Err: webconfig already running"); return true; } if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { @@ -3406,9 +3529,9 @@ bool MyMesh::startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup bool started; if (force_ap) { - started = _webconfig->startSetupMode(reply); + started = _webconfig->requestStart(true, reply); } else { - started = _webconfig->startAutoMode(reply); + started = _webconfig->requestStart(false, reply); } if (!started) { _unconfigured_setup_espnow_suspended = setup_was_suspended; @@ -3433,7 +3556,7 @@ bool MyMesh::setWebUIEnabled(bool enabled, char* reply) { return true; } if (enabled) { - if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) { + if (_webconfig && _webconfig->isRunning()) { strcpy(reply, "OK - webui on (already active)"); } else { startWebConfig(false, reply); @@ -3444,7 +3567,7 @@ bool MyMesh::setWebUIEnabled(bool enabled, char* reply) { } } } else { - if (_webconfig && _webconfig->isRunning()) _webconfig->requestStop(); + if (_webconfig) _webconfig->requestStop(); strcpy(reply, "OK - webui off"); } return true; @@ -3485,24 +3608,30 @@ bool MyMesh::getWiFiCLI(char* reply) const { bool MyMesh::setWiFiSSID(const char* value, char* reply) { if (WebConfigServer::setStandaloneWiFiSSID(value, reply, 160, canonicalWiFiLegacyPrefs())) { - const bool was_running = _webconfig && _webconfig->isRunning(); - if (was_running) _webconfig->requestStop(); - if (_webconfig) _webconfig->reloadStandaloneWiFi(); - if (was_running) { - strcpy(reply, "OK - WiFi SSID saved; WebConfig stopping, start again to apply"); + if (_webconfig) { + _webconfig->reloadStandaloneWiFi(); + _webconfig->requestRestart(); } +#ifdef WITH_MQTT_BRIDGE + if (_prefs.bridge_enabled && bridge + && !_cli.getBoard()->isOTAUpdateRunning()) bridge->requestRestart(); +#endif + strcpy(reply, "OK - WiFi SSID saved; reconnect scheduled"); } return true; } bool MyMesh::setWiFiPassword(const char* value, char* reply) { if (WebConfigServer::setStandaloneWiFiPassword(value, reply, 160, canonicalWiFiLegacyPrefs())) { - const bool was_running = _webconfig && _webconfig->isRunning(); - if (was_running) _webconfig->requestStop(); - if (_webconfig) _webconfig->reloadStandaloneWiFi(); - if (was_running) { - strcpy(reply, "OK - WiFi password saved; WebConfig stopping, start again to apply"); + if (_webconfig) { + _webconfig->reloadStandaloneWiFi(); + _webconfig->requestRestart(); } +#ifdef WITH_MQTT_BRIDGE + if (_prefs.bridge_enabled && bridge + && !_cli.getBoard()->isOTAUpdateRunning()) bridge->requestRestart(); +#endif + strcpy(reply, "OK - WiFi password saved; reconnect scheduled"); } return true; } @@ -3521,7 +3650,8 @@ bool MyMesh::setWiFiCLI(const char* value, char* reply) { } bool MyMesh::stopWebConfig(char* reply) { - if (!_webconfig || !_webconfig->isRunning()) { + if (!_webconfig || (!_webconfig->isRunning() && !_webconfig->isStopping() + && !_webconfig->isStartPending())) { strcpy(reply, "Err: webconfig not running"); return true; } @@ -4127,7 +4257,7 @@ void MyMesh::loop() { #endif #ifdef WITH_MQTT_BRIDGE // TLS runs on Core 0; the radio loop reaps only an acknowledged CLI stop. - if (bridge && bridge->isRunning()) bridge->loop(); + if (bridge) bridge->loop(); #endif #ifdef WITH_ESPNOW_BRIDGE if (espnow_bridge.isRunning()) espnow_bridge.loop(); @@ -4150,70 +4280,7 @@ void MyMesh::loop() { updateAdvertTimer(); // schedule next local advert } - 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; - if (revert_radio_due && !temp_radio_applied) { - // The temporary window ended before it could be applied. Drop both timers - // so a previously busy radio cannot switch to the expired channel later. - set_radio_at = revert_radio_at = 0; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); - } else if (revert_radio_due && !hasOutbound() && radio_apply_ready) { - if (applySavedRadioParams()) { - set_radio_at = revert_radio_at = 0; - temp_radio_applied = false; - saved_radio_apply_pending = false; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - MESH_DEBUG_PRINTLN("Radio params restored"); - } else { - radio_apply_failed = true; - } - } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound() - && radio_apply_ready) { - uint32_t rx_us = _prefs.rx_ps_rx_us; - uint32_t sleep_us = _prefs.rx_ps_sleep_us; - bool timing_ok = true; - if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { - uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble - : rxPowerSavingPreambleForParams(pending_sf, pending_bw); - timing_ok = CommonCLI::calculateRxPowerSavingLevel( - _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); - } - uint32_t timings[2] = {rx_us, sleep_us}; - const uint32_t* applied_timings = _prefs.rx_powersaving_enabled - && radio_driver.supportsRxPowerSaving() ? timings : NULL; - if (timing_ok && _cli.radioProfiles().applyPrimary( - pending_freq, pending_bw, pending_sf, pending_cr, true, pending_preamble, applied_timings) == mesh::RadioParamApplyResult::APPLIED) { - set_radio_at = 0; - active_cr = pending_cr; - temp_radio_applied = true; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - MESH_DEBUG_PRINTLN("Temp radio params"); - } else { - // A failed setParams() may have applied only a prefix of the tuple. - // Ensure expiry restores the complete saved configuration. - saved_radio_apply_pending = true; - radio_apply_failed = true; - } - } - - if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound() - && radio_apply_ready && !radio_apply_failed) { - if (applySavedRadioParams()) { - saved_radio_apply_pending = false; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - } else { - radio_apply_failed = true; - } - } - if (radio_apply_failed) { - radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); - } + servicePendingPrimaryRadioSettings(); #ifdef WITH_WEBCONFIG if (_webconfig) { @@ -4222,7 +4289,8 @@ void MyMesh::loop() { || (!bridge->isRunning() && !bridge->isStopping())); #endif _webconfig->tick(millis()); - if (!_webconfig->isRunning() && !_webconfig->isStopping()) { + if (!_webconfig->isRunning() && !_webconfig->isStopping() + && !_webconfig->isStartPending()) { delete _webconfig; _webconfig = nullptr; } @@ -4510,7 +4578,7 @@ mesh::powerdiag::Mask MyMesh::pendingWorkReason() const { using mesh::powerdiag::Reason; if (!canIdleDualRadio()) return mask(Reason::DualRadio); if (hasPendingOtaApply()) return mask(Reason::Ota); - if (_cli.hasPendingTxPower()) return mask(Reason::Scheduled); + if (_cli.hasPendingRadioSettings()) return mask(Reason::Scheduled); #if defined(ESP32_PLATFORM) if (mesh::wifi::networkPower().foregroundActive()) return mask(Reason::Network); #endif diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 135fa1dc0..2dc774a39 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -410,6 +410,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks, bool addPost(ClientInfo* client, const char* postData); bool applySavedRadioParams(); + void servicePendingPrimaryRadioSettings(bool tx_gap = false); bool storePost(const mesh::Identity& author, const char* postData); bool snapshotRoomHistory(); void loadRoomHistory(); @@ -625,9 +626,19 @@ public: bool isTxPowerApplied(int8_t power_dbm) const override; #endif bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + bool supportsRxPowerSaving() const override; + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + bool supportsRxPowerSavingRfRxDisable() const override; + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool disabled) override; + bool isRxPowerSavingRfRxDisabled() const override; void recalibrateNoiseFloor() override { _radio->recalibrateNoiseFloor(); } void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; bool setRxBoostedGain(bool enable) override; + bool supportsRxBoostedGain() const override; + mesh::RadioParamApplyResult trySetRxBoostedGain(bool enable) override; +#if defined(USE_LR2021) + mesh::RadioParamApplyResult tryConfigSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) override; +#endif void formatNeighborsReply(char *reply) override; void removeNeighbor(const uint8_t* pubkey, int key_len) override; @@ -704,7 +715,7 @@ public: #endif bool canRecoverUsbLogging() const { return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive() - && !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingTxPower() + && !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingRadioSettings() && set_radio_at == 0 && _ota_update_at == 0; } #if MESH_ENABLE_ROOM_FLOOD_RULE_ENGINE @@ -767,11 +778,14 @@ public: bool setMqttBridgeState(bool enable) override { if (!enable) { - if (bridge && bridge->isRunning()) bridge->end(); + if (bridge) bridge->end(); _alerter.setBridge(nullptr); return !bridge || !bridge->isRunning(); } - if (bridge && bridge->isStopping()) return false; + if (bridge && bridge->isStopping()) { + bridge->begin(); + return bridge->isStartPending(); + } // Give the WiFi station to MQTT while it associates. ESP-NOW is restarted // after the AP channel is known, which avoids a raw ESP-NOW-only session // pinning the station to a different channel. @@ -814,7 +828,7 @@ public: _alerter.setBridge(bridge); } if (_prefs.espnow_bridge_enabled) startSharedEspNowBridgeIfReady(); - return bridge->isRunning(); + return bridge->isRunning() || bridge->isStartPending(); } bool setEspNowBridgeState(bool enable) override { @@ -872,7 +886,11 @@ public: bool setBridgeState(bool enable) override { #ifdef WITH_MQTT_BRIDGE - if (enable && bridge && bridge->isStopping()) return false; + if (!enable && bridge && bridge->isStartPending()) bridge->end(); + if (enable && bridge && bridge->isStopping()) { + bridge->begin(); + return bridge->isStartPending(); + } #endif #if defined(WITH_MQTT_BRIDGE) && defined(WITH_ESPNOW_BRIDGE) if (!enable) return setEspNowBridgeState(false) && setMqttBridgeState(false); @@ -936,7 +954,8 @@ public: _alerter.setBridge(nullptr); #endif } - return enable ? bridge->isRunning() : !bridge->isRunning(); + return enable ? (bridge->isRunning() || bridge->isStartPending()) + : !bridge->isRunning(); #endif } @@ -970,7 +989,7 @@ public: #endif configureBridgeFilter(bridge); bridge->begin(); - return bridge->isRunning(); + return bridge->isRunning() || bridge->isStartPending(); #endif } @@ -1031,6 +1050,10 @@ public: return bridge && bridge->isStopping(); } + bool isMqttBridgeStartPending() override { + return bridge && bridge->isStartPending(); + } + bool syncMqttNtp() override { if (!bridge || !bridge->isRunning()) return false; // Queue the sync onto the MQTT task (Core 0) without blocking: this runs on @@ -1076,7 +1099,8 @@ public: bool setWiFiPowerSave(const char* value, char* reply) override; bool setWiFiCLI(const char* value, char* reply) override; bool isWebConfigActive() const override { - return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping()); + return _webconfig && (_webconfig->isRunning() || _webconfig->isStopping() + || _webconfig->isStartPending()); } void getNodeSnapshot(WebConfigServer::NodeSnapshot& snapshot) override; void execCommand(char* cmd, char* reply) override { handleCommand(0, cmd, reply); } diff --git a/examples/simple_sensor/SensorMesh.cpp b/examples/simple_sensor/SensorMesh.cpp index 60beae7ad..08826f509 100644 --- a/examples/simple_sensor/SensorMesh.cpp +++ b/examples/simple_sensor/SensorMesh.cpp @@ -1,5 +1,8 @@ #include "SensorMesh.h" #include +#if defined(USE_LR2021) +#include +#endif #include #include #include @@ -1112,11 +1115,80 @@ bool SensorMesh::handleIncomingMsg(ClientInfo& from, uint32_t timestamp, uint8_t return false; } +void SensorMesh::servicePendingPrimaryRadioSettings(bool tx_gap) { + 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; + if (revert_radio_due && !temp_radio_applied) { + // Never apply a temporary channel after its window has already ended. + set_radio_at = revert_radio_at = 0; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); + } else if (revert_radio_due && !hasOutbound() && radio_apply_ready) { + if (applySavedRadioParams()) { + set_radio_at = revert_radio_at = 0; + temp_radio_applied = false; + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + MESH_DEBUG_PRINTLN("Radio params restored"); + } else { + radio_apply_failed = true; + } + } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound() + && radio_apply_ready) { + uint32_t rx_us = _prefs.rx_ps_rx_us; + uint32_t sleep_us = _prefs.rx_ps_sleep_us; + bool timing_ok = true; + if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { + uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble + : rxPowerSavingPreambleForParams(pending_sf, pending_bw); + timing_ok = CommonCLI::calculateRxPowerSavingLevel( + _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); + } + uint32_t timings[2] = {rx_us, sleep_us}; + const uint32_t* applied_timings = _prefs.rx_powersaving_enabled + && radio_driver.supportsRxPowerSaving() ? timings : NULL; + if (timing_ok && _cli.radioProfiles().applyPrimary( + pending_freq, pending_bw, pending_sf, pending_cr, true, pending_preamble, applied_timings) == mesh::RadioParamApplyResult::APPLIED) { + set_radio_at = 0; + active_cr = pending_cr; + temp_radio_applied = true; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + MESH_DEBUG_PRINTLN("Temp radio params"); + } else { + // A failed setParams() may have applied only a prefix of the tuple. + // Ensure expiry restores the complete saved configuration. + saved_radio_apply_pending = true; + radio_apply_failed = true; + } + } + + if (saved_radio_apply_pending && !temp_radio_applied && (tx_gap || !hasOutbound()) + && radio_apply_ready && !radio_apply_failed) { + if (applySavedRadioParams()) { + saved_radio_apply_pending = false; + radio_apply_retry_at = 0; + radio_apply_failures = 0; + } else { + radio_apply_failed = true; + } + } + if (radio_apply_failed) { + radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); + } +} + #if !MESH_ENABLE_SENSOR_TRACKER void SensorMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); _cli.servicePendingTxPower(); _cli.morseReply().complete(packet); + _cli.servicePendingRxSettings(); + _cli.radioProfiles().servicePendingTuning(); + servicePendingPrimaryRadioSettings(true); } void SensorMesh::onSendFail(mesh::Packet* packet) { @@ -1339,8 +1411,23 @@ void SensorMesh::begin(FILESYSTEM* fs) { } bool SensorMesh::applySavedRadioParams() { +#if defined(USE_LR2021) + uint8_t extra_sf_count = 0; + const auto* profiles = radio_driver.profiles(); + const bool side_detectors_allowed = !profiles || !profiles->enabled(); + if (!mesh::lr2021::storedSideDetectorCount(_prefs.extra_sf, extra_sf_count) + || !mesh::lr2021::validateSideDetectorSFs( + _prefs.extra_sf, extra_sf_count, _prefs.sf, _prefs.bw)) { + if (side_detectors_allowed && !radio_driver.configSideDetectors(nullptr, 0, _prefs.bw)) return false; + memset(_prefs.extra_sf, 0, sizeof(_prefs.extra_sf)); + extra_sf_count = 0; + savePrefs(); + } +#endif // A busy radio can accept the tuple before deferring TX power. Recovery // is complete only after both saved settings have been accepted. + if (radio_driver.supportsRxBoostedGainMode() + && !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false; uint32_t timings[2] = {_prefs.rx_ps_rx_us, _prefs.rx_ps_sleep_us}; const uint32_t* applied_timings = _prefs.rx_powersaving_enabled && radio_driver.supportsRxPowerSaving() ? timings : NULL; @@ -1350,6 +1437,10 @@ bool SensorMesh::applySavedRadioParams() { return false; } active_cr = _prefs.cr; +#if defined(USE_LR2021) + if (side_detectors_allowed + && !radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false; +#endif return radio_driver.setTxPower(_prefs.tx_power_dbm); } @@ -1479,6 +1570,40 @@ bool SensorMesh::isTxPowerApplied(int8_t power_dbm) const { bool SensorMesh::setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return radio_driver.setRxPowerSaving(enable, rx_us, sleep_us); } + +bool SensorMesh::supportsRxPowerSaving() const { + return radio_driver.supportsRxPowerSaving(); +} + +mesh::RadioParamApplyResult SensorMesh::trySetRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { + return radio_driver.trySetRxPowerSaving(enable, rx_us, sleep_us); +} + +bool SensorMesh::supportsRxPowerSavingRfRxDisable() const { + return radio_driver.supportsRxPowerSavingRfRxDisable(); +} + +mesh::RadioParamApplyResult SensorMesh::trySetRxPowerSavingRfRxDisabled(bool disabled) { + return radio_driver.trySetRxPowerSavingRfRxDisabled(disabled); +} + +bool SensorMesh::isRxPowerSavingRfRxDisabled() const { + return radio_driver.isRxPowerSavingRfRxDisabled(); +} + +bool SensorMesh::supportsRxBoostedGain() const { + return radio_driver.supportsRxBoostedGainMode(); +} + +mesh::RadioParamApplyResult SensorMesh::trySetRxBoostedGain(bool enable) { + return radio_driver.trySetRxBoostedGainMode(enable); +} + +#if defined(USE_LR2021) +mesh::RadioParamApplyResult SensorMesh::tryConfigSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { + return radio_driver.tryConfigSideDetectors(sideDetSFs, num, bw); +} +#endif void SensorMesh::getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { *soft = radio_driver.getRxPsWatchdogSoftCount(); *hard = radio_driver.getRxPsWatchdogHardCount(); @@ -1551,69 +1676,7 @@ void SensorMesh::loop() { updateAdvertTimer(); // schedule next local advert } - 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; - if (revert_radio_due && !temp_radio_applied) { - // Never apply a temporary channel after its window has already ended. - set_radio_at = revert_radio_at = 0; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - MESH_DEBUG_PRINTLN("Temp radio params expired before apply"); - } else if (revert_radio_due && !hasOutbound() && radio_apply_ready) { - if (applySavedRadioParams()) { - set_radio_at = revert_radio_at = 0; - temp_radio_applied = false; - saved_radio_apply_pending = false; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - MESH_DEBUG_PRINTLN("Radio params restored"); - } else { - radio_apply_failed = true; - } - } else if (set_radio_at && millisHasNowPassed(set_radio_at) && !hasOutbound() - && radio_apply_ready) { - uint32_t rx_us = _prefs.rx_ps_rx_us; - uint32_t sleep_us = _prefs.rx_ps_sleep_us; - bool timing_ok = true; - if (_prefs.rx_powersaving_enabled && _prefs.rx_ps_level != 0) { - uint32_t preamble = _prefs.rx_ps_preamble ? _prefs.rx_ps_preamble - : rxPowerSavingPreambleForParams(pending_sf, pending_bw); - timing_ok = CommonCLI::calculateRxPowerSavingLevel( - _prefs.rx_ps_level, pending_sf, pending_bw, preamble, &rx_us, &sleep_us); - } - uint32_t timings[2] = {rx_us, sleep_us}; - const uint32_t* applied_timings = _prefs.rx_powersaving_enabled - && radio_driver.supportsRxPowerSaving() ? timings : NULL; - if (timing_ok && _cli.radioProfiles().applyPrimary( - pending_freq, pending_bw, pending_sf, pending_cr, true, pending_preamble, applied_timings) == mesh::RadioParamApplyResult::APPLIED) { - set_radio_at = 0; - active_cr = pending_cr; - temp_radio_applied = true; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - MESH_DEBUG_PRINTLN("Temp radio params"); - } else { - // A failed setParams() may have applied only a prefix of the tuple. - // Ensure expiry restores the complete saved configuration. - saved_radio_apply_pending = true; - radio_apply_failed = true; - } - } - - if (saved_radio_apply_pending && !temp_radio_applied && !hasOutbound() - && radio_apply_ready && !radio_apply_failed) { - if (applySavedRadioParams()) { - saved_radio_apply_pending = false; - radio_apply_retry_at = 0; - radio_apply_failures = 0; - } else { - radio_apply_failed = true; - } - } - if (radio_apply_failed) { - radio_apply_retry_at = futureMillis(nextRadioApplyRetryDelay(radio_apply_failures)); - } + servicePendingPrimaryRadioSettings(); uint32_t curr = getRTCClock()->getCurrentTime(); if (curr >= last_read_time + SENSOR_READ_INTERVAL_SECS) { @@ -1751,7 +1814,7 @@ mesh::powerdiag::Mask SensorMesh::pendingWorkReason() const { #endif if (!canIdleDualRadio()) return mask(Reason::DualRadio); if (hasPendingOtaApply()) return mask(Reason::Ota); - if (_cli.hasActiveUserGpioTimer() || _cli.hasPendingTxPower()) return mask(Reason::Scheduled); + if (_cli.hasActiveUserGpioTimer() || _cli.hasPendingRadioSettings()) return mask(Reason::Scheduled); if (radio_driver.isWatchdogObserving()) return mask(Reason::RadioWatchdog); uint32_t noise_floor_delay_ms; if (getNextNoiseFloorWakeDelay(noise_floor_delay_ms) && noise_floor_delay_ms == 0) diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index cde147d49..16f512aa2 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -73,7 +73,7 @@ public: void loop(); bool canRecoverUsbLogging() const { return dirty_contacts_expiry == 0 && !hasOutbound() && !isAnyTempRadioActive() - && !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingTxPower() + && !hasPendingOtaApply() && !saved_radio_apply_pending && !_cli.hasPendingRadioSettings() && set_radio_at == 0; } void handleCommand(uint32_t sender_timestamp, char* command, char* reply, @@ -120,6 +120,16 @@ public: bool isTxPowerApplied(int8_t power_dbm) const override; #endif bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + bool supportsRxPowerSaving() const override; + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) override; + bool supportsRxPowerSavingRfRxDisable() const override; + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool disabled) override; + bool isRxPowerSavingRfRxDisabled() const override; + bool supportsRxBoostedGain() const override; + mesh::RadioParamApplyResult trySetRxBoostedGain(bool enable) override; +#if defined(USE_LR2021) + mesh::RadioParamApplyResult tryConfigSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) override; +#endif void recalibrateNoiseFloor() override { _radio->recalibrateNoiseFloor(); } void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) override; void formatNeighborsReply(char *reply) override { @@ -303,6 +313,7 @@ private: uint8_t radio_apply_failures; bool applySavedRadioParams(); + void servicePendingPrimaryRadioSettings(bool tx_gap = false); bool isMillisTimerDue(unsigned long timestamp) const; uint32_t limitSleepToMillisTimer(unsigned long timestamp, uint32_t sleep_ms) const; diff --git a/examples/simple_sensor/Tracker.cpp b/examples/simple_sensor/Tracker.cpp index 1917c3a6c..54890c900 100644 --- a/examples/simple_sensor/Tracker.cpp +++ b/examples/simple_sensor/Tracker.cpp @@ -83,7 +83,7 @@ bool SensorMesh::trackerNeedsRadio() const { || board.isRadioTestActive() || mesh::isUsbLoggingWatchdogArmed() || isAnyTempRadioActive() || hasPendingOtaApply() || isDualRadioActive() || set_radio_at || revert_radio_at || saved_radio_apply_pending - || _cli.hasActiveUserGpioTimer() || _cli.hasPendingTxPower() + || _cli.hasActiveUserGpioTimer() || _cli.hasPendingRadioSettings() || hasOutbound() || _mgr->getOutboundTotal() != 0) return true; uint32_t delay; @@ -272,6 +272,9 @@ void SensorMesh::onSendComplete(mesh::Packet* packet) { mesh::Mesh::onSendComplete(packet); _cli.servicePendingTxPower(); _cli.morseReply().complete(packet); + _cli.servicePendingRxSettings(); + _cli.radioProfiles().servicePendingTuning(); + servicePendingPrimaryRadioSettings(true); if (!tracker_pending || !packet || packet->getPayloadType() != PAYLOAD_TYPE_REQ) return; uint8_t hash[MAX_HASH_SIZE]; packet->calculatePacketHash(hash); diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 4295a20ea..c2fce59c5 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -174,6 +174,7 @@ void CommonCLI::loop() { #endif if (!mesh::morseTimingActive(_callbacks->getProfileRadio())) { servicePendingTxPower(); + servicePendingRxSettings(); _radio_profiles.loop(); loopManagement(); #if MESH_ENABLE_TX_BEACONS @@ -247,6 +248,77 @@ void CommonCLI::applyAndSaveTxPower(int8_t power, char* reply) { mesh::cli::formatError(reply, 160, "TX save unconfirmed; reboot may differ"); } +mesh::RadioParamApplyResult CommonCLI::applyRxSetting(uint8_t setting) { + using Result = mesh::RadioParamApplyResult; + const mesh::Radio* radio = _callbacks->getProfileRadio(); + if (mesh::morseTimingActive(radio) || (radio && radio->isCarrierWaveActive())) return Result::BUSY; + switch (setting) { + case RxGain: + return _callbacks->trySetRxBoostedGain(_prefs->rx_boosted_gain != 0); + case RxPowerSaving: + return _callbacks->trySetRxPowerSaving(_prefs->rx_powersaving_enabled != 0, + _prefs->rx_ps_rx_us, _prefs->rx_ps_sleep_us); +#if defined(USE_LR2021) + case RxSideDetectors: { + uint8_t count = 0; + if (!mesh::lr2021::storedSideDetectorCount(_prefs->extra_sf, count)) return Result::FAILED; + return _callbacks->tryConfigSideDetectors(_prefs->extra_sf, count, _prefs->bw); + } +#endif + case RxRfRx: + return _callbacks->trySetRxPowerSavingRfRxDisabled( + (_rx_settings_pending & RxRfRxDisabled) != 0); + default: return Result::FAILED; + } +} + +void CommonCLI::servicePendingRxSettings() { + if (!(_rx_settings_pending & RxPendingMask) || (_rx_settings_retry_at + && static_cast(millis() - _rx_settings_retry_at) < 0)) return; + bool failed = false; + for (uint8_t setting = RxGain; setting <= RxRfRx; setting <<= 1) { + if (!(_rx_settings_pending & setting)) continue; + const auto result = applyRxSetting(setting); + if (result == mesh::RadioParamApplyResult::APPLIED) _rx_settings_pending &= ~setting; + failed |= result == mesh::RadioParamApplyResult::FAILED; + } + _rx_settings_retry_at = failed ? millis() + 1000 : 0; + if (failed && !_rx_settings_retry_at) _rx_settings_retry_at = 1; +} + +bool CommonCLI::saveRxSetting(uint8_t setting, mesh::RadioParamApplyResult result, char* reply) { + using Result = mesh::RadioParamApplyResult; + if (result == Result::FAILED) { + mesh::cli::formatError(reply, 160, "RX setting rejected"); + return false; + } + if (!trySavePrefs()) { + mesh::cli::formatError(reply, 160, "RX save unconfirmed; reboot may differ"); + return false; + } + _rx_settings_pending &= ~setting; + if (result == Result::BUSY) _rx_settings_pending |= setting; + _rx_settings_retry_at = 0; + strcpy(reply, result == Result::BUSY ? "OK - saved; pending" : "OK"); + return true; +} + +void CommonCLI::restoreRxSetting(uint8_t setting, mesh::RadioParamApplyResult result, + uint8_t previous_pending, uint32_t previous_retry_at) { + _rx_settings_pending = previous_pending; + _rx_settings_retry_at = previous_retry_at; + if (result == mesh::RadioParamApplyResult::BUSY) return; + // Hardware errors can be partial writes. Repair only from the old accepted + // target, including when the rejected request never reached persistence. + const auto restored = applyRxSetting(setting); + _rx_settings_pending &= ~setting; + if (restored != mesh::RadioParamApplyResult::APPLIED) _rx_settings_pending |= setting; + _rx_settings_retry_at = restored == mesh::RadioParamApplyResult::FAILED ? millis() + 1000 + : restored == mesh::RadioParamApplyResult::BUSY ? 0 : previous_retry_at; + if (restored == mesh::RadioParamApplyResult::FAILED && !_rx_settings_retry_at) + _rx_settings_retry_at = 1; +} + // Old fork firmware persisted the (since removed) NodePrefs MQTT fields to /com_prefs // as a zero-filled gap between owner_info (which ends at offset 290) and a trailing // observer block (rx_boosted_gain, flood_max_*, snmp/watchdog/alert settings). @@ -3577,7 +3649,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re #if defined(WITH_MQTT_BRIDGE) && defined(LIGHTWEIGHT_WIFI_OTA) else { // Keep WiFi up, but release MQTT/TLS heap while the browser uploader runs. - const bool mqtt_paused = _callbacks->isMqttBridgeRunning(); + const bool mqtt_paused = _callbacks->isMqttBridgeRunning() + || _callbacks->isMqttBridgeStartPending(); _wifi_ota_resume_mqtt = _wifi_ota_resume_mqtt || mqtt_paused; if (mqtt_paused) _callbacks->setMqttBridgeState(false); } @@ -4296,10 +4369,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep mesh::cli::formatError(reply, 160, "usage set logging.output off|usb|wifi|both"); return; } - if (wifi_enabled && _callbacks->isMqttBridgeStopping()) { - mesh::cli::formatError(reply, 160, "MQTT is stopping; retry when mqtt.stopping is off"); - return; - } const uint8_t previous_usb = _prefs->usb_logging_enabled; const uint8_t previous_bridge = _prefs->bridge_enabled; _prefs->usb_logging_enabled = usb_enabled ? 1 : 0; @@ -4372,10 +4441,6 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep mesh::cli::formatError(reply, 160, "use set mqtt.enabled on|off"); } else { const bool enable = strcmp(value, "on") == 0; - if (enable && _callbacks->isMqttBridgeStopping()) { - mesh::cli::formatError(reply, 160, "MQTT is stopping; retry when mqtt.stopping is off"); - return; - } const uint8_t previous = _prefs->bridge_enabled; _prefs->bridge_enabled = enable ? 1 : 0; // A cooperative stop cannot be cancelled safely once signalled. Save @@ -4712,13 +4777,23 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep strcpy(reply, "Error, must be on or off"); } } else if (memcmp(config, "radio.rxgain ", 13) == 0) { - bool enabled = memcmp(&config[13], "on", 2) == 0; - if (_callbacks->setRxBoostedGain(enabled)) { - _prefs->rx_boosted_gain = enabled; - savePrefs(); - strcpy(reply, "OK"); - } else { + const char* value = &config[13]; + if (strcmp(value, "on") != 0 && strcmp(value, "off") != 0) { + mesh::cli::formatError(reply, 160, "state must be on or off"); + return; + } + if (!_callbacks->supportsRxBoostedGain()) { mesh::cli::formatError(reply, 160, "unsupported"); + return; + } + const uint8_t previous = _prefs->rx_boosted_gain; + const uint8_t previous_pending = _rx_settings_pending; + const uint32_t previous_retry_at = _rx_settings_retry_at; + _prefs->rx_boosted_gain = strcmp(value, "on") == 0; + const auto result = applyRxSetting(RxGain); + if (!saveRxSetting(RxGain, result, reply)) { + _prefs->rx_boosted_gain = previous; + restoreRxSetting(RxGain, result, previous_pending, previous_retry_at); } } else if (memcmp(config, "radio.fem.rxgain ", 17) == 0) { const auto* radio = _callbacks->getProfileRadio(); @@ -4790,10 +4865,30 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } - if (!_callbacks->setRxPowerSavingRfRxDisabled(disabled)) { + if (!_callbacks->supportsRxPowerSavingRfRxDisable()) { mesh::cli::formatError(reply, 160, "unsupported"); + return; + } + // With no outstanding target, snapshot the actual runtime setting. Its + // accepted desired value shares the high bit of the pending mask. + if (!(_rx_settings_pending & RxRfRx)) { + _rx_settings_pending &= ~RxRfRxDisabled; + if (_callbacks->isRxPowerSavingRfRxDisabled()) _rx_settings_pending |= RxRfRxDisabled; + } + const uint8_t previous_pending = _rx_settings_pending; + const uint32_t previous_retry_at = _rx_settings_retry_at; + _rx_settings_pending &= ~RxRfRxDisabled; + if (disabled) _rx_settings_pending |= RxRfRxDisabled; + const auto result = applyRxSetting(RxRfRx); + if (result == mesh::RadioParamApplyResult::FAILED) { + restoreRxSetting(RxRfRx, result, previous_pending, previous_retry_at); + mesh::cli::formatError(reply, 160, "RX setting rejected"); } else { - sprintf(reply, "OK - radio.rxps.rfrx_disabled %s", disabled ? "on" : "off"); + _rx_settings_pending &= ~RxRfRx; + if (result == mesh::RadioParamApplyResult::BUSY) _rx_settings_pending |= RxRfRx; + _rx_settings_retry_at = 0; + sprintf(reply, "OK - radio.rxps.rfrx_disabled %s%s", disabled ? "on" : "off", + result == mesh::RadioParamApplyResult::BUSY ? "; pending" : ""); } } else if (memcmp(config, "radio.rxps ", 11) == 0) { // RX PowerSaving const char* value = &config[11]; @@ -4806,9 +4901,7 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep bool preamble_overridden = false; bool manual_timings_requested = false; - ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); - rx_us = _prefs->rx_ps_rx_us; - sleep_us = _prefs->rx_ps_sleep_us; + ensureRxPowerSavingDefaults(&rx_us, &sleep_us); mesh::cli::RxPowerSavingArguments parsed = {}; if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, parsed)) { @@ -4816,14 +4909,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } using mesh::cli::RxPowerSavingArgumentMode; -#ifdef WRAPPER_CLASS - if (!WRAPPER_CLASS::SupportsRxPowerSaving) { - if (parsed.mode != RxPowerSavingArgumentMode::Off) { - strcpy(reply, "ERROR: RX powersaving unsupported"); - return; - } + if (!_callbacks->supportsRxPowerSaving() && parsed.mode != RxPowerSavingArgumentMode::Off) { + strcpy(reply, "ERROR: RX powersaving unsupported"); + return; } -#endif if (parsed.mode == RxPowerSavingArgumentMode::Off) { enable = 0; } else if (parsed.mode == RxPowerSavingArgumentMode::Manual) { @@ -4879,11 +4968,11 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep return; } - if (!_callbacks->setRxPowerSaving(enable, rx_us, sleep_us)) { - strcpy(reply, "ERROR: RX powersaving unsupported"); - return; - } - + const RxPowerSavingConfig previous(_prefs->rx_powersaving_enabled, + _prefs->rx_ps_rx_us, _prefs->rx_ps_sleep_us, + _prefs->rx_ps_level, _prefs->rx_ps_preamble); + const uint8_t previous_pending = _rx_settings_pending; + const uint32_t previous_retry_at = _rx_settings_retry_at; _prefs->rx_powersaving_enabled = enable; _prefs->rx_ps_rx_us = rx_us; _prefs->rx_ps_sleep_us = sleep_us; @@ -4897,7 +4986,16 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep _prefs->rx_ps_level = 0; _prefs->rx_ps_preamble = 0; } - savePrefs(); + const auto result = applyRxSetting(RxPowerSaving); + if (!saveRxSetting(RxPowerSaving, result, reply)) { + _prefs->rx_powersaving_enabled = previous.enabled; + _prefs->rx_ps_rx_us = previous.rx_us; + _prefs->rx_ps_sleep_us = previous.sleep_us; + _prefs->rx_ps_level = previous.level; + _prefs->rx_ps_preamble = previous.preamble; + restoreRxSetting(RxPowerSaving, result, previous_pending, previous_retry_at); + return; + } if (level_requested) { sprintf(reply, "OK - level %lu,%s,%lu,%lu,preamble=%lu", (unsigned long)level, @@ -4911,6 +5009,10 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep sprintf(reply, "OK - %s,%lu,%lu", enable ? "on" : "off", (unsigned long)rx_us, (unsigned long)sleep_us); } + if (result == mesh::RadioParamApplyResult::BUSY) { + const size_t used = strlen(reply); + if (used < 160) snprintf(reply + used, 160 - used, "; saved; pending"); + } } else if (strcmp(config, "powersaving on") == 0 || strcmp(config, "powersaving off") == 0) { const bool enabled = strcmp(&config[12], "on") == 0; @@ -5679,21 +5781,34 @@ void CommonCLI::handleSetCmd(uint32_t sender_timestamp, char* command, char* rep } } else if (strcmp(config, "extra.sf") == 0 || memcmp(config, "extra.sf ", 9) == 0) { #if defined(USE_LR2021) + const auto* radio = _callbacks->getProfileRadio(); + const auto* profiles = radio ? radio->profiles() : nullptr; + if (profiles && profiles->enabled()) { + mesh::cli::formatError(reply, 160, "extra SFs unavailable with dual radio"); + return; + } uint8_t sideDetSFs[mesh::lr2021::STORED_SIDE_DETECTOR_BYTES] = {}; uint8_t num = 0; const char* value = config[8] == '\0' ? "" : &config[9]; if (strcmp(value, "none") == 0 || strcmp(value, "off") == 0) value = ""; if (!mesh::lr2021::parseSideDetectorSFList(value, sideDetSFs, num) || !mesh::lr2021::validateSideDetectorSFs( - sideDetSFs, num, _prefs->sf, _prefs->bw)) { + sideDetSFs, num, _prefs->sf, _prefs->bw) + || (profiles && !mesh::lr2021::validateSideDetectorSFs( + sideDetSFs, num, profiles->primary.sf, profiles->primary.bw))) { strcpy(reply, "Invalid extra SF config"); } else { - if (_callbacks->configSideDetectors(sideDetSFs, num, _prefs->bw)) { - memcpy(_prefs->extra_sf, sideDetSFs, sizeof(_prefs->extra_sf)); - savePrefs(); + uint8_t previous[sizeof(_prefs->extra_sf)]; + memcpy(previous, _prefs->extra_sf, sizeof(previous)); + const uint8_t previous_pending = _rx_settings_pending; + const uint32_t previous_retry_at = _rx_settings_retry_at; + memcpy(_prefs->extra_sf, sideDetSFs, sizeof(_prefs->extra_sf)); + const auto result = applyRxSetting(RxSideDetectors); + if (!saveRxSetting(RxSideDetectors, result, reply)) { + memcpy(_prefs->extra_sf, previous, sizeof(previous)); + restoreRxSetting(RxSideDetectors, result, previous_pending, previous_retry_at); + } else if (result == mesh::RadioParamApplyResult::APPLIED) { strcpy(reply, num == 0 ? "OK - extra SFs cleared" : "OK - extra SFs set"); - } else { - strcpy(reply, "Invalid extra SF config"); } } #else diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 06179d00d..cf856a828 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -668,6 +668,9 @@ public: // Ownership barriers continue to use the synchronous state setter. virtual bool requestMqttBridgeStop() { return setMqttBridgeState(false); } virtual bool isMqttBridgeStopping() { return false; } +#ifdef WITH_MQTT_BRIDGE + virtual bool isMqttBridgeStartPending() { return false; } +#endif virtual bool setEspNowBridgeState(bool enable) { return setBridgeState(enable); } virtual bool setRs232BridgeState(bool enable) { return setBridgeState(enable); } virtual bool restartMqttBridge() { return restartBridge(); } @@ -829,11 +832,21 @@ public: virtual bool setRxBoostedGain(bool enable) { return false; // CommonCLI reports unsupported if not overridden by wrapper }; + virtual bool supportsRxBoostedGain() const { return false; } + virtual mesh::RadioParamApplyResult trySetRxBoostedGain(bool enable) { + return setRxBoostedGain(enable) ? mesh::RadioParamApplyResult::APPLIED + : mesh::RadioParamApplyResult::FAILED; + } #if defined(USE_LR2021) virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { return false; // Override in wrapper } + virtual mesh::RadioParamApplyResult tryConfigSideDetectors( + const uint8_t sideDetSFs[], uint8_t num, float bw) { + return configSideDetectors(sideDetSFs, num, bw) ? mesh::RadioParamApplyResult::APPLIED + : mesh::RadioParamApplyResult::FAILED; + } #endif virtual void recalibrateNoiseFloor() { @@ -861,12 +874,22 @@ public: virtual bool setRxPowerSaving(bool enable, uint32_t rx_us, uint32_t sleep_us) { return !enable; }; + virtual bool supportsRxPowerSaving() const { return false; } + virtual mesh::RadioParamApplyResult trySetRxPowerSaving( + bool enable, uint32_t rx_us, uint32_t sleep_us) { + return setRxPowerSaving(enable, rx_us, sleep_us) ? mesh::RadioParamApplyResult::APPLIED + : mesh::RadioParamApplyResult::FAILED; + } virtual bool supportsRxPowerSavingRfRxDisable() const { return false; } virtual bool setRxPowerSavingRfRxDisabled(bool disabled) { (void)disabled; return false; } + virtual mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool disabled) { + return setRxPowerSavingRfRxDisabled(disabled) ? mesh::RadioParamApplyResult::APPLIED + : mesh::RadioParamApplyResult::FAILED; + } virtual bool isRxPowerSavingRfRxDisabled() const { return false; } virtual void getRxPsWatchdogCounts(uint32_t* soft, uint32_t* hard) { @@ -950,6 +973,12 @@ class CommonCLI { bool _common_save_succeeded = false; bool _tx_power_pending = false; uint32_t _tx_power_retry_at = 0; + enum RxSetting : uint8_t { + RxGain = 1, RxPowerSaving = 2, RxSideDetectors = 4, RxRfRx = 8, + RxPendingMask = 15, RxRfRxDisabled = 128 + }; + uint8_t _rx_settings_pending = 0; + uint32_t _rx_settings_retry_at = 0; uint32_t _prefs_save_failures = 0; mesh::RadioProfileCLI _radio_profiles; #if MESH_ENABLE_TX_BEACONS @@ -971,6 +1000,10 @@ class CommonCLI { bool trySavePrefs( PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common); void applyAndSaveTxPower(int8_t power, char* reply); + mesh::RadioParamApplyResult applyRxSetting(uint8_t setting); + bool saveRxSetting(uint8_t setting, mesh::RadioParamApplyResult result, char* reply); + void restoreRxSetting(uint8_t setting, mesh::RadioParamApplyResult result, + uint8_t previous_pending, uint32_t previous_retry_at); bool applyAndSaveAdcMultiplier(float multiplier, char* reply, size_t capacity); bool saveObserverPrefs(); void loadPrefsInt(FILESYSTEM* _fs, const char* filename); @@ -1063,6 +1096,11 @@ public: bool hasActiveUserGpioTimer() const; bool hasPendingTxPower() const { return _tx_power_pending; } void servicePendingTxPower(); + bool hasPendingRadioSettings() const { + return _tx_power_pending || (_rx_settings_pending & RxPendingMask) != 0 + || _radio_profiles.hasPendingTuning(); + } + void servicePendingRxSettings(); void handleCommand(uint32_t sender_timestamp, char* command, char* reply); mesh::MainBoard* getBoard() { return _board; } uint8_t buildAdvertData(uint8_t node_type, uint8_t* app_data); diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index b871335de..42481b3f1 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -202,11 +202,16 @@ void CommonRadioPrefs::formatRxPowerSavingStatus(char* reply, size_t reply_size) RXPowerSavingCLI::get(config, status, sf, bw, reply, reply_size, wire_preamble); } +bool CommonRadioPrefs::isValidBandwidth(float bandwidth) { + return isValidLoRaBandwidth(bandwidth); +} + #if defined(NRF52_PLATFORM) // Keep shared radio parsing out of the Companion's larger command dispatcher. __attribute__((noinline)) #endif -bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { +bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply, + bool allow_primary_set) { (void)sender_timestamp; if (command == nullptr || reply == nullptr) return false; @@ -222,7 +227,7 @@ bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timest sprintf(reply, "> %s,%s,%d,%d", freq, bw, (uint32_t)getSpreadFactor(), (uint32_t)getCodingRate()); return true; } - if (startsWith(command, "set radio ")) { + if (allow_primary_set && startsWith(command, "set radio ")) { const char* args = command + strlen("set radio "); const size_t args_len = boundedLength(args, RADIO_ARGS_CAPACITY); if (args_len >= RADIO_ARGS_CAPACITY) { diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 221444243..87094c643 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -66,7 +66,9 @@ public: virtual RxPowerSavingConfig readRxPowerSavingConfig() const { return {}; } void formatRxPowerSavingStatus(char* reply, size_t reply_size) const; - bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply); + static bool isValidBandwidth(float bandwidth); + bool handleCommand(const char* command, uint32_t sender_timestamp, char* reply, + bool allow_primary_set = true); bool setByKey(const char* key, const char* value) override; // for dynamic key/value access bool getByKey(const char* key, char* value, size_t max_len) override; diff --git a/src/helpers/CompanionRadioDiagnostics.h b/src/helpers/CompanionRadioDiagnostics.h index 87afac8fd..abf391020 100644 --- a/src/helpers/CompanionRadioDiagnostics.h +++ b/src/helpers/CompanionRadioDiagnostics.h @@ -9,7 +9,7 @@ namespace mesh { namespace companion { // The radio owns RXEN admission and receiver restoration. This command adapter // does not stop RX, change preferences, or bypass an unread packet/other owner. -template +template bool handleRadioDiagnosticsCommand(Driver& driver, bool radio_available, const char* command, char* reply, size_t capacity, ClearStats clear_stats) { @@ -39,6 +39,7 @@ bool handleRadioDiagnosticsCommand(Driver& driver, bool radio_available, } return true; } + if (!AllowRfRxSet) return false; static const char set_key[] = "set radio.rxps.rfrx_disabled"; if (strncmp(command, set_key, sizeof(set_key) - 1) != 0 || (command[sizeof(set_key) - 1] != 0 diff --git a/src/helpers/RadioProfileCLI.cpp b/src/helpers/RadioProfileCLI.cpp index e4722ca48..cc87a7580 100644 --- a/src/helpers/RadioProfileCLI.cpp +++ b/src/helpers/RadioProfileCLI.cpp @@ -369,7 +369,7 @@ __attribute__((noinline)) bool RadioProfileCLI::handleFastSettings(const char* k if (hasReplyMutation() || fast_hold_) { cli::formatError(reply, capacity, "radio settings held; retry after recovery"); return true; } - RadioSwitchTuning next = current; + RadioSwitchTuning next = fast_pending_ ? fast_saved_ : current; if (spi) { // Single-digit selections remain strict; 9 labels the 8888888 Hz clock. if (!args[1] && strchr("02489", args[0])) next.spi_mhz = args[0] - '0'; @@ -389,8 +389,11 @@ __attribute__((noinline)) bool RadioProfileCLI::handleFastSettings(const char* k } const auto result = radio_->trySetSwitchTuning(next, commitFastSettings, this); if (result == RadioParamApplyResult::APPLIED) snprintf(reply, capacity, "OK - %s=%s (saved)", key, args); - else cli::formatError(reply, capacity, result == RadioParamApplyResult::BUSY - ? "radio busy; retry" : "radio tuning could not be applied/saved"); + else if (result == RadioParamApplyResult::BUSY && saveFastSettings(next)) { + fast_pending_ = true; + fast_retry_ms_ = 0; + snprintf(reply, capacity, "OK - saved; pending"); + } else cli::formatError(reply, capacity, "radio tuning could not be applied/saved"); return true; } #endif @@ -474,13 +477,24 @@ void RadioProfileCLI::begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool in bool RadioProfileCLI::savePrimaryPreamble(uint16_t symbols) { if (symbols && (symbols < 8 || symbols > RadioProfiles::MaxPreamble)) return false; if (!radio_ || !radio_->profiles()) return symbols == 0; - if (symbols == primary_preamble_) return true; + if (symbols == primary_preamble_ && !primary_preamble_dirty_) return true; if (hasReplyMutation()) return false; if (!save(saved_, symbols, cross_)) return false; primary_preamble_ = symbols; + primary_preamble_dirty_ = false; + primary_preamble_retry_ms_ = 0; return true; } +bool RadioProfileCLI::restorePrimaryPreamble(uint16_t symbols) { + if (symbols && (symbols < 8 || symbols > RadioProfiles::MaxPreamble)) return false; + if (savePrimaryPreamble(symbols)) return true; + primary_preamble_ = symbols; + primary_preamble_dirty_ = true; + primary_preamble_retry_ms_ = 0; + return false; +} + bool RadioProfileCLI::acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const { const auto* profiles = radio_ ? radio_->profiles() : nullptr; if (!profiles) return preamble == 0; @@ -761,16 +775,29 @@ bool RadioProfileCLI::applyReplyMutation() { return true; } -void RadioProfileCLI::loop() { - const uint32_t now = millis(); +void RadioProfileCLI::servicePendingTuning() { + if (primary_preamble_dirty_ && (!primary_preamble_retry_ms_ + || (int32_t)(millis() - primary_preamble_retry_ms_) >= 0) + && !savePrimaryPreamble(primary_preamble_)) { + primary_preamble_retry_ms_ = millis() + 1000; + if (!primary_preamble_retry_ms_) primary_preamble_retry_ms_ = 1; + } #if MESH_RADIO_SWITCH_TUNING - if (fast_pending_ && radio_ && (int32_t)(now - fast_retry_ms_) >= 0) { + const uint32_t now = millis(); + if (fast_pending_ && radio_ && (!fast_retry_ms_ || (int32_t)(now - fast_retry_ms_) >= 0)) { // A saved setting may fail RX verification as well as be temporarily busy. // Avoid repeatedly applying and rolling it back at the main-loop rate. - fast_pending_ = radio_->trySetSwitchTuning(fast_saved_) != RadioParamApplyResult::APPLIED; - fast_retry_ms_ = now + 1000; + const auto result = radio_->trySetSwitchTuning(fast_saved_); + fast_pending_ = result != RadioParamApplyResult::APPLIED; + fast_retry_ms_ = result == RadioParamApplyResult::FAILED ? now + 1000 : 0; + if (result == RadioParamApplyResult::FAILED && !fast_retry_ms_) fast_retry_ms_ = 1; } #endif +} + +void RadioProfileCLI::loop() { + const uint32_t now = millis(); + servicePendingTuning(); if (!radio_ || !radio_->profiles()) return; const uint32_t elapsed = now - last_ms_; last_ms_ = now; diff --git a/src/helpers/RadioProfileCLI.h b/src/helpers/RadioProfileCLI.h index a94063d17..1bdc2aec0 100644 --- a/src/helpers/RadioProfileCLI.h +++ b/src/helpers/RadioProfileCLI.h @@ -26,6 +26,8 @@ class RadioProfileCLI { RadioProfileConfig temporary_; RadioProfileConfig pending_temporary_; uint16_t primary_preamble_ = 0; + bool primary_preamble_dirty_ = false; + uint32_t primary_preamble_retry_ms_ = 0; RadioCrossMode cross_ = RadioCrossMode::Off; uint8_t reply_setting_ = 0; // reserved image byte: 0 = role default bool infrastructure_replies_ = false; @@ -118,6 +120,14 @@ class RadioProfileCLI { static void configureBootTuning(FILESYSTEM*, Radio&) {} #endif void begin(FILESYSTEM* fs, Radio* radio, RTCClock* rtc, bool infrastructure_replies = false); + void servicePendingTuning(); + bool hasPendingTuning() const { +#if MESH_RADIO_SWITCH_TUNING + return fast_pending_ || primary_preamble_dirty_; +#else + return primary_preamble_dirty_; +#endif + } void loop(); bool handle(const char* command, char* reply, size_t capacity = 160, bool remote_origin = false); // Remote infrastructure commands must keep both RX-origin generations alive @@ -146,6 +156,9 @@ class RadioProfileCLI { // its committed value changes saved intent only, never the running radio. void adoptPrimaryPreamble(uint16_t symbols) { primary_preamble_ = symbols; } bool savePrimaryPreamble(uint16_t symbols); + // A failed compensating write still restores the old runtime target. Keep + // that target dirty until storage confirms it, even for an identical save. + bool restorePrimaryPreamble(uint16_t symbols); bool acceptsPrimary(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t preamble) const; RadioParamApplyResult applyPrimary(float freq, float bw, uint8_t sf, uint8_t cr, bool temporary, uint16_t preamble, const uint32_t* timings = nullptr) { diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 37265fe0b..7c8429c2f 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -188,6 +188,12 @@ static bool mqttStationWiFiMutationAllowed() { void MQTTBridge::beginWiFiStation() { if (!mqttStationWiFiMutationAllowed()) return; + if (_wifi_credentials_changed) { + WiFi.disconnect(); + _wifi_credentials_changed = false; + _last_wifi_reconnect_attempt = millis(); + _wifi_reconnect_backoff_attempt = 0; + } mesh::wifi::beginStation(_wifi_ssid, _wifi_password); } @@ -209,6 +215,10 @@ bool MQTTBridge::prepareWiFiCredentials() { } else if (state != mesh::wifi::CredentialState::Ready) { return _wifi_configured = false; } + // Only begin(), after the old worker's stop barrier, replaces this snapshot. + // Keep a changed tuple armed across allocation failures until Core 0 joins it. + if (_wifi_ssid[0] && (strcmp(_wifi_ssid, credentials.ssid) + || strcmp(_wifi_password, credentials.password))) _wifi_credentials_changed = true; memcpy(_wifi_ssid, credentials.ssid, sizeof(_wifi_ssid)); memcpy(_wifi_password, credentials.password, sizeof(_wifi_password)); _wifi_power_save = credentials.power_save; @@ -1043,8 +1053,12 @@ void MQTTBridge::releaseRuntimeBuffers() { // --------------------------------------------------------------------------- void MQTTBridge::begin() { #if defined(ESP32) - if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return; + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { + _start_requested = false; + return; + } #endif + _start_requested = true; MQTT_DEBUG_PRINTLN("Initializing MQTT Bridge..."); // Idempotent start: a second begin() would re-run allocation and re-create @@ -1053,6 +1067,7 @@ void MQTTBridge::begin() { MQTT_DEBUG_PRINTLN("MQTT Bridge running or stopping - begin() ignored"); return; } + _start_retry_at = millis() + 5000; cancelNtpRefresh(); _ntp_refresh_retry_at = 0; @@ -1131,6 +1146,7 @@ void MQTTBridge::begin() { #endif if (!wifi_configured) { MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured"); + _start_requested = false; return; } @@ -1341,6 +1357,8 @@ void MQTTBridge::begin() { // requestStop() - signal only; worker retains ownership until acknowledgment // --------------------------------------------------------------------------- void MQTTBridge::requestStop() { + _start_requested = false; + _start_retry_at = 0; // A repeated request must not clear an acknowledgment or restart its budget. if (!_initialized || _lifecycle.isStopInProgress()) return; @@ -1382,6 +1400,8 @@ void MQTTBridge::requestStop() { // end() - synchronous barrier for OTA and other ownership transfers // --------------------------------------------------------------------------- void MQTTBridge::end() { + _start_requested = false; + _start_retry_at = 0; MQTT_DEBUG_PRINTLN("Stopping MQTT Bridge..."); if (!_initialized) return; requestStop(); // Joins an existing request without re-delivering its signal. @@ -1403,6 +1423,12 @@ void MQTTBridge::end() { finishStopped(); } +void MQTTBridge::requestRestart() { + requestStop(); + _start_requested = true; + _start_retry_at = millis() + 1000; +} + void MQTTBridge::finishStopped() { if (!_initialized || _lifecycle.isStopInProgress()) return; // Timezone is inline class storage (_timezone_storage) - nothing to delete. @@ -1601,7 +1627,7 @@ bool MQTTBridge::initializeWiFiInTask() { // the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers). // When already connected, the deferred slot setup still fires in mqttTaskLoop() // because _ntp_synced persists across end() (only _slots_setup_done is reset). - if (WiFi.status() != WL_CONNECTED) { + if (WiFi.status() != WL_CONNECTED || (_manage_wifi && _wifi_credentials_changed)) { if (_manage_wifi) { beginWiFiStation(); } @@ -3592,6 +3618,7 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { // An AP can start after this worker initialized. Defer channel enforcement, // reconnect/disconnect and radio power changes until that owner releases it. if (!mqttStationWiFiMutationAllowed()) return false; + if (_manage_wifi && _wifi_credentials_changed) beginWiFiStation(); #endif wl_status_t current_wifi_status = WiFi.status(); #ifdef ESP_PLATFORM @@ -3781,8 +3808,6 @@ bool MQTTBridge::isSlotReady(int index, char* reason_buf, size_t reason_size) co // loop() - Core 1 stop reaping on ESP32; cooperative processing elsewhere // --------------------------------------------------------------------------- void MQTTBridge::loop() { - if (!_initialized) return; - #ifdef ESP_PLATFORM // Poll only: a slow TLS teardown must not stall radio/UART forwarding or // force-delete the worker. Only end(), the synchronous OTA barrier, owns @@ -3791,9 +3816,16 @@ void MQTTBridge::loop() { && _stop_acked.load(std::memory_order_acquire)) { _lifecycle.onTaskStopped(); finishStopped(); + return; // Let the owner refresh committed credentials before a restart. } + if (!_initialized && _start_requested + && int32_t(millis() - _start_retry_at) >= 0) begin(); return; #else + if (!_initialized) { + if (_start_requested && int32_t(millis() - _start_retry_at) >= 0) begin(); + return; + } unsigned long now = millis(); const bool wifi_just_connected = handleWiFiConnection(now); if (wifi_just_connected diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 2145d6d64..589e2135c 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -606,6 +606,8 @@ private: }; LifecycleOps _lifecycle_ops; MQTTLifecycle::Coordinator _lifecycle; + bool _start_requested = false; + uint32_t _start_retry_at = 0; // Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt_prefs. MQTTPrefs* _obs = nullptr; @@ -617,6 +619,7 @@ private: char _wifi_password[65] = {}; uint8_t _wifi_power_save = 0; bool _wifi_configured = false; + bool _wifi_credentials_changed = false; bool prepareWiFiCredentials(); void beginWiFiStation(); #endif @@ -633,6 +636,10 @@ public: // Core 1: request cooperative teardown without waiting or freeing resources. // Call loop() to reap the acknowledgment; end() remains the OTA stop barrier. void requestStop(); + void requestRestart(); + bool isStartPending() const { + return _start_requested && (!_initialized || isStopping()); + } bool isStopping() const { return _lifecycle.isStopInProgress(); } bool isRunning() const override { return _initialized; } // Configured and still scheduling recovery, but transports are quiesced. diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index 850f7817c..1d356c8dd 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -59,6 +59,11 @@ public: */ virtual bool setRxBoostedGainMode(bool) { return false; } virtual bool supportsRxBoostedGainMode() const { return false; } + mesh::RadioParamApplyResult trySetRxBoostedGainMode(bool) { return mesh::RadioParamApplyResult::FAILED; } + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enabled, uint32_t, uint32_t) { + return enabled ? mesh::RadioParamApplyResult::FAILED : mesh::RadioParamApplyResult::APPLIED; + } + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool) { return mesh::RadioParamApplyResult::FAILED; } virtual bool getRxBoostedGainMode() const { return false; } // ESP-NOW has no LoRa RX power-saving watchdog or noise-floor calibration. diff --git a/src/helpers/esp32/SerialBLEInterface.cpp b/src/helpers/esp32/SerialBLEInterface.cpp index 01e5814dc..aef631a6f 100644 --- a/src/helpers/esp32/SerialBLEInterface.cpp +++ b/src/helpers/esp32/SerialBLEInterface.cpp @@ -1584,8 +1584,13 @@ size_t SerialBLEInterface::checkRecvFrame(uint8_t dest[]) { ADVERT_RESTART_DELAY)) { if (pServer->getConnectedCount() == 0) { BLE_DEBUG_PRINTLN("SerialBLEInterface -> re-starting advertising"); +#if defined(MESH_USE_NIMBLE_ARDUINO) + _adv_restart_pending = !pServer->getAdvertising()->start(); + if (_adv_restart_pending) _adv_restart_started = now; +#else pServer->getAdvertising()->start(); // re-Start advertising _adv_restart_pending = false; +#endif } else { // A disconnect can take longer than the normal restart delay. Keep the // restart armed instead of losing it while the controller still reports diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index 37bbe15bb..b357ab4c3 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -851,6 +851,7 @@ static bool scanSetupNetworks() { } bool WebConfigServer::startSetupMode(char reply[]) { + _start_retryable = false; if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); return false; @@ -944,6 +945,7 @@ bool WebConfigServer::startSetupMode(char reply[]) { if (!promote_lan) stopOwnedWiFiRadio(); refreshNetworkPowerHold(millis()); strcpy(reply, "Err: failed to start AP"); + _start_retryable = true; return false; } delay(100); // let the AP netif settle before reading its IP @@ -1028,6 +1030,7 @@ bool WebConfigServer::beginSavedStation(uint32_t now) { } bool WebConfigServer::startAutoMode(char reply[]) { + _start_retryable = false; if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); return false; @@ -1042,6 +1045,7 @@ bool WebConfigServer::startAutoMode(char reply[]) { const bool manages_station = _owns_wifi || !_runtime_wifi_ownership_managed; if (manages_station && !beginSavedStation(millis())) { strcpy(reply, "Err: failed to reset WiFi station protocol"); + _start_retryable = true; return false; } _mode = MODE_CONNECTING; @@ -1053,6 +1057,56 @@ bool WebConfigServer::startAutoMode(char reply[]) { return true; } +bool WebConfigServer::requestStart(bool force_ap, char reply[]) { + _start_retryable = false; + if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) { + _start_mode = MODE_OFF; + strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); + return false; + } + if ((force_ap || !_wifi_ssid[0]) + && !mesh::wifi::accessPointCompatibleWithLongRange()) { + _start_mode = MODE_OFF; + strcpy(reply, "Error: stop ESP-NOW before starting a setup AP"); + return false; + } + const Mode requested = force_ap ? MODE_SETUP : MODE_CONNECTING; + if (!_stopping) { + if (force_ap ? startSetupMode(reply) : startAutoMode(reply)) { + _start_mode = MODE_OFF; + _start_retry_at = 0; + _start_retry_delay = 5000; + return true; + } + if (!_start_retryable) { + _start_mode = MODE_OFF; + return false; + } + } + _start_mode = requested; + _start_retry_at = WebConfigBatch::scheduleAt(millis(), _start_retry_delay); + if (_start_retry_delay < 60000) _start_retry_delay *= 2; + if (_start_retry_delay > 60000) _start_retry_delay = 60000; + strcpy(reply, "OK - WebConfig start pending; retrying automatically"); + return true; +} + +void WebConfigServer::requestRestart() { + if (!isRunning() && !isStartPending()) return; + requestStop(); + _start_mode = MODE_CONNECTING; + _start_retry_delay = 5000; + _start_retry_at = WebConfigBatch::scheduleAt(millis(), 1000); +} + +void WebConfigServer::serviceStartRetry(uint32_t now) { + if (!isStartPending() || _stopping + || !WebConfigBatch::deadlineReached(now, _start_retry_at)) return; + char reply[160]; + requestStart(_start_mode == MODE_SETUP, reply); + mesh::usbDebugPort().println(reply); +} + bool WebConfigServer::prepareStationOwner() { if (_stopping) return false; updateWiFiOwnership(false); @@ -1121,6 +1175,9 @@ void WebConfigServer::createServer() { } void WebConfigServer::requestStop() { + _start_mode = MODE_OFF; + _start_retry_at = 0; + _start_retry_delay = 5000; if (_mode == MODE_OFF && !_stopping) return; detachRoutes(); if (_server) _server->end(); @@ -1133,6 +1190,8 @@ void WebConfigServer::requestStop() { } bool WebConfigServer::stopForOTA(char reply[]) { + _start_mode = MODE_OFF; + _start_retry_at = 0; if (!isRunning() && !isStopping()) return true; // Keep a STA interface alive while removing our setup AP. Switching the // last interface off and immediately creating the OTA AP races ESP32 netif @@ -1292,6 +1351,7 @@ void WebConfigServer::tick(uint32_t now) { } return; } + serviceStartRetry(now); if (_mode == MODE_OFF) return; if (!_board_cmds_probed) probeBoardCommands(); @@ -1345,6 +1405,10 @@ void WebConfigServer::tick(uint32_t now) { char ignored[160]; if (startSetupMode(ignored)) { _retry_saved_wifi_in_setup = retry_saved_wifi; + } else if (_start_retryable) { + // Keep automatic recovery alive if the AP netif was temporarily busy. + _mode = MODE_CONNECTING; + _connect_deadline = WebConfigBatch::scheduleAt(millis(), 30000); } mesh::usbDebugPort().println(ignored); } diff --git a/src/helpers/esp32/WebConfigServer.h b/src/helpers/esp32/WebConfigServer.h index 5a25a82d2..eb7b31c0b 100644 --- a/src/helpers/esp32/WebConfigServer.h +++ b/src/helpers/esp32/WebConfigServer.h @@ -265,6 +265,10 @@ public: bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal bool startLanMode(char reply[]); // bind to existing STA connection bool startAutoMode(char reply[]); // use saved WiFi, or setup AP when absent + // Accept a start through asynchronous teardown or a transient SDK failure. + bool requestStart(bool force_ap, char reply[]); + void requestRestart(); // coalesce saved credential changes + bool isStartPending() const { return _start_mode != MODE_OFF; } bool reloadStandaloneWiFi(); void requestStop(); // stop listening and detach this session // Release HTTP/DNS for the updater without delayed teardown taking its @@ -332,6 +336,10 @@ private: SemaphoreHandle_t _mux; Mode _mode = MODE_OFF; bool _stopping = false; + Mode _start_mode = MODE_OFF; + uint32_t _start_retry_at = 0; + uint32_t _start_retry_delay = 5000; + bool _start_retryable = false; bool _keep_wifi_on_stop = false; bool _was_setup_ap = false; bool _initial_setup = false; @@ -421,6 +429,7 @@ private: void noteNetworkActivity(); void refreshNetworkPowerHold(uint32_t now); bool beginSavedStation(uint32_t now); + void serviceStartRetry(uint32_t now); void registerRoutes(); typedef void (WebConfigServer::*RequestHandler)(AsyncWebServerRequest*); static void dispatchRequest(AsyncWebServerRequest* req, RequestHandler handler); diff --git a/src/helpers/radiolib/CustomLR2021Wrapper.h b/src/helpers/radiolib/CustomLR2021Wrapper.h index 112cebe3f..a9308d79c 100644 --- a/src/helpers/radiolib/CustomLR2021Wrapper.h +++ b/src/helpers/radiolib/CustomLR2021Wrapper.h @@ -131,19 +131,24 @@ public: } bool configSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override { - if (_profiles.enabled()) return false; + return tryConfigSideDetectors(sideDetSFs, num, bw) == mesh::RadioParamApplyResult::APPLIED; + } + + mesh::RadioParamApplyResult tryConfigSideDetectors(const uint8_t* sideDetSFs, uint8_t num, float bw) override { + using Result = mesh::RadioParamApplyResult; + if (_profiles.enabled()) return Result::FAILED; const uint8_t primary_sf = getSpreadingFactor(); float active_bw = _params_valid ? _cur_bw : ((CustomLR2021 *)_radio)->getBandwidthKhz(); if (active_bw <= 0.0f) active_bw = bw; if (!mesh::lr2021::validateSideDetectorSFs(sideDetSFs, num, primary_sf, active_bw)) { - return false; + return Result::FAILED; } LR2021LoRaSideDetector_t tmp[mesh::lr2021::MAX_SIDE_DETECTORS] = {}; buildSideDetectorConfig(sideDetSFs, num, active_bw, tmp); uint8_t resume_rx = beginReconfigure(); - if (resume_rx > 1) return false; + if (resume_rx > 1) return resume_rx == 2 ? Result::BUSY : Result::FAILED; int16_t status = ((CustomLR2021 *)_radio)->setSideDetector(num > 0 ? tmp : nullptr, num); MESH_DEBUG_PRINTLN("setSideDetector() returned %d", status); @@ -165,7 +170,7 @@ public: } endReconfigure(resume_rx); - return status == RADIOLIB_ERR_NONE; + return status == RADIOLIB_ERR_NONE ? Result::APPLIED : Result::FAILED; } protected: diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index f6727fe8f..26f808190 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -220,16 +220,22 @@ public: } bool setRxPowerSavingRfRxDisabled(bool disabled) override { + return trySetRxPowerSavingRfRxDisabled(disabled) == mesh::RadioParamApplyResult::APPLIED; + } + + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool disabled) override { + using Result = mesh::RadioParamApplyResult; + if (!supportsRxPowerSavingRfRxDisable()) return Result::FAILED; #if MESH_ENABLE_TX_BEACONS - if (isBeaconRadioOwned()) return false; + if (isBeaconRadioOwned()) return Result::BUSY; #endif - if (isCarrierWaveActive()) return false; - if (!supportsRxPowerSavingRfRxDisable()) return false; + if (isCarrierWaveActive()) return Result::BUSY; + if (isRxPowerSavingRfRxDisabled() == disabled) return Result::APPLIED; // Do not stop duty-cycle RX or change its policy before admission. A BUSY // sleep window, TX, or unread RX completion still owns the current mode. const uint8_t resume_rx = beginReconfigure(); - if (resume_rx > 1) return false; + if (resume_rx > 1) return resume_rx == 2 ? Result::BUSY : Result::FAILED; const bool previous = isRxPowerSavingRfRxDisabled(); ((CustomSX1262 *)_radio)->setRxPowerSavingRfRxDisabled(disabled); @@ -238,9 +244,9 @@ public: // startRecv already retained its bounded retry/backoff. That retry must // use the old RXEN policy; never report a failed RX restoration as OK. ((CustomSX1262 *)_radio)->setRxPowerSavingRfRxDisabled(previous); - return false; + return Result::FAILED; } - return true; + return Result::APPLIED; } bool isRxPowerSavingRfRxDisabled() const override { diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 68221c7a8..5637e8f7e 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -554,6 +554,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { if (_beacon_owned) cacheBeaconRestoreParams(); #endif _dbm_applied = false; + _rx_boosted_gain_applied = false; if (!radioDeepInit()) return false; _rx_ps_armed = false; @@ -575,7 +576,8 @@ bool RadioLibWrapper::restoreAfterDeepInit() { restored = _dbm_applied && restored; } if (_rx_boosted_gain_valid) { - restored = applyRxBoostedGainMode(_cur_rx_boosted_gain) && restored; + _rx_boosted_gain_applied = applyRxBoostedGainMode(_cur_rx_boosted_gain); + restored = _rx_boosted_gain_applied && restored; } recalibrateNoiseFloor(); if (restored) _profile_repair_required = false; @@ -1426,11 +1428,25 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetPrimaryParams(const mesh::Rad } bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) { - uint8_t resume_rx = beginReconfigure(); - if (resume_rx > 1) return false; + return trySetRxBoostedGainMode(enabled) == mesh::RadioParamApplyResult::APPLIED; +} - const bool gain_changed = !_rx_boosted_gain_valid || _cur_rx_boosted_gain != enabled; +mesh::RadioParamApplyResult RadioLibWrapper::trySetRxBoostedGainMode(bool enabled) { + using Result = mesh::RadioParamApplyResult; + if (!supportsRxBoostedGainMode()) return Result::FAILED; +#if MESH_ENABLE_TX_BEACONS + if (_beacon_owned) return Result::BUSY; +#endif + if (_cw_active) return Result::BUSY; + if (_rx_boosted_gain_valid && _rx_boosted_gain_applied + && _cur_rx_boosted_gain == enabled && !_profile_repair_required) return Result::APPLIED; + const uint8_t resume_rx = beginReconfigure(); + if (resume_rx > 1) return resume_rx == 2 ? Result::BUSY : Result::FAILED; + + const bool gain_changed = !_rx_boosted_gain_valid || !_rx_boosted_gain_applied + || _cur_rx_boosted_gain != enabled; bool success = applyRxBoostedGainMode(enabled); + _rx_boosted_gain_applied = success; if (success) { _cur_rx_boosted_gain = enabled; _rx_boosted_gain_valid = true; @@ -1438,7 +1454,7 @@ bool RadioLibWrapper::setRxBoostedGainMode(bool enabled) { } endReconfigure(resume_rx); - return success; + return success ? Result::APPLIED : Result::FAILED; } void RadioLibWrapper::idle() { @@ -1565,7 +1581,7 @@ bool RadioLibWrapper::resetAGCIfIdle(bool refresh_floor) { doResetAGC(); state = STATE_IDLE; // trigger a startReceive() const bool gain_restored = !_rx_boosted_gain_valid - || applyRxBoostedGainMode(_cur_rx_boosted_gain); + || (_rx_boosted_gain_applied = applyRxBoostedGainMode(_cur_rx_boosted_gain)); // Adaptive maintenance restores the same RF path. A valid, idle baseline // keeps its existing refresh deadline instead of spending another complete @@ -2097,7 +2113,8 @@ mesh::MorseRxResult RadioLibWrapper::startMorseRx(float frequency_mhz, state = STATE_IDLE; _board->setRadioTestActive(true); if (prepareMorseReceiver(frequency_mhz) != RADIOLIB_ERR_NONE - || (_rx_boosted_gain_valid && !applyRxBoostedGainMode(_cur_rx_boosted_gain)) + || (_rx_boosted_gain_valid + && !(_rx_boosted_gain_applied = applyRxBoostedGainMode(_cur_rx_boosted_gain))) || startMorseReceiver() != RADIOLIB_ERR_NONE) { if (allocated) delete receiver; #if defined(MESH_MORSE_HIL) && MESH_MORSE_HIL @@ -2640,26 +2657,31 @@ bool RadioLibWrapper::getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) c // RX PowerSaving bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + return trySetRxPowerSaving(enabled, rx_us, sleep_us) == mesh::RadioParamApplyResult::APPLIED; +} + +mesh::RadioParamApplyResult RadioLibWrapper::trySetRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + using Result = mesh::RadioParamApplyResult; + if (enabled && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us) + || !supportsRxPowerSaving())) return Result::FAILED; #if MESH_ENABLE_TX_BEACONS - if (_beacon_owned) return false; + if (_beacon_owned) return Result::BUSY; #endif - if (_cw_active) return false; - if (enabled && (!isValidRxPowerSavingPeriod(rx_us) || !isValidRxPowerSavingPeriod(sleep_us))) { - return false; - } - // A rejected request must not become the policy restored after radio2 ends. - if (enabled && !supportsRxPowerSaving()) return false; + if (_cw_active) return Result::BUSY; if (_profile_rxps_suspended) { _profile_saved_rxps = enabled; _rx_ps_rx_us = rx_us; _rx_ps_sleep_us = sleep_us; _rx_ps_arm_retry.reset(); _rx_ps_last_error = RADIOLIB_ERR_NONE; - return true; + return Result::APPLIED; } + if (!_profile_repair_required && _rx_ps_enabled == enabled + && _rx_ps_rx_us == rx_us && _rx_ps_sleep_us == sleep_us) return Result::APPLIED; + uint8_t resume_rx = beginReconfigure(); - if (resume_rx > 1) return false; + if (resume_rx > 1) return resume_rx == 2 ? Result::BUSY : Result::FAILED; _rx_ps_enabled = enabled; _rx_ps_rx_us = rx_us; @@ -2669,7 +2691,7 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl _rx_ps_continuous_fallback = enabled && rxPowerSavingUsesContinuousFallback(rx_us, sleep_us); endReconfigure(resume_rx); - return true; + return Result::APPLIED; } RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 2f30d10cf..f27808242 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -109,6 +109,7 @@ protected: bool _cur_rx_boosted_gain; bool _params_valid, _dbm_valid, _rx_boosted_gain_valid; bool _dbm_applied = false; + bool _rx_boosted_gain_applied = false; mesh::RadioProfiles _profiles; uint8_t _active_profile = 0; uint16_t _physical_preamble = 0; @@ -543,6 +544,7 @@ public: void onSendFinished() override; bool isInRecvMode() const override; bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us); bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; } RxPowerSavingStatus getRxPowerSavingStatus() const; virtual uint32_t rxPowerSavingTransitionUs() const { return rxPowerSavingDefaultTransitionUs(); } @@ -555,6 +557,9 @@ public: } virtual bool supportsRxPowerSavingRfRxDisable() const { return false; } virtual bool setRxPowerSavingRfRxDisabled(bool) { return false; } + virtual mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool) { + return mesh::RadioParamApplyResult::FAILED; + } virtual bool isRxPowerSavingRfRxDisabled() const { return false; } bool isChannelActive(); @@ -681,10 +686,14 @@ public: float packetScore(float snr, int packet_len) override { return packetScoreInt(snr, 10, packet_len); } // assume sf=10 bool setRxBoostedGainMode(bool enabled); + mesh::RadioParamApplyResult trySetRxBoostedGainMode(bool enabled); virtual bool supportsRxBoostedGainMode() const { return false; } virtual bool getRxBoostedGainMode() const { return false; } virtual bool configSideDetectors(const uint8_t sideDetSFs[], uint8_t num, float bw) { return false; } + virtual mesh::RadioParamApplyResult tryConfigSideDetectors(const uint8_t*, uint8_t, float) { + return mesh::RadioParamApplyResult::FAILED; + } }; /** diff --git a/src/helpers/sim/SimRadio.h b/src/helpers/sim/SimRadio.h index f939b7aff..d450ca41d 100644 --- a/src/helpers/sim/SimRadio.h +++ b/src/helpers/sim/SimRadio.h @@ -87,6 +87,11 @@ public: void resetStats() { n_recv = n_sent = n_recv_errors = 0; } bool setRxBoostedGainMode(bool /*enabled*/) { return false; } bool supportsRxBoostedGainMode() const { return false; } + mesh::RadioParamApplyResult trySetRxBoostedGainMode(bool) { return mesh::RadioParamApplyResult::FAILED; } + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enabled, uint32_t, uint32_t) { + return enabled ? mesh::RadioParamApplyResult::FAILED : mesh::RadioParamApplyResult::APPLIED; + } + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool) { return mesh::RadioParamApplyResult::FAILED; } bool getRxBoostedGainMode() const { return false; } uint32_t getRxPsWatchdogSoftCount() const { return 0; } uint32_t getRxPsWatchdogHardCount() const { return 0; } diff --git a/test/fixtures/advert_cache/test.cpp b/test/fixtures/advert_cache/test.cpp index 8d7867e5a..1d89221cc 100644 --- a/test/fixtures/advert_cache/test.cpp +++ b/test/fixtures/advert_cache/test.cpp @@ -73,7 +73,9 @@ public: uint8_t dirty_contacts_failures=0; bool(*save_filter)(const ContactInfo&)=nullptr; bool _radio_available=false, _iter_started=false; - bool command_radio_apply_pending=false, saved_radio_apply_pending=false; + bool saved_radio_apply_pending=false, tx_power_apply_pending=false; + int8_t rf_rx_disable_pending=-1; + struct {bool hasPendingTuning() const{return false;}} _radio_profiles; uint32_t _scheduled_reboot_at=0, radio_apply_retry_at=0; uint32_t emergency_client_repeat_send_at=0; void* sign_data=nullptr; diff --git a/test/fixtures/cli_settings/main.cpp b/test/fixtures/cli_settings/main.cpp index e7f94b47e..dfbf3e0fb 100644 --- a/test/fixtures/cli_settings/main.cpp +++ b/test/fixtures/cli_settings/main.cpp @@ -10,7 +10,11 @@ // This fixture isolates the actual CommonCLI setting branches and callbacks. namespace mesh { enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; -struct Radio { bool isCarrierWaveActive() const { return false; } }; +struct Radio { + bool isCarrierWaveActive() const { return false; } + const RadioProfiles* profiles() const { return nullptr; } +}; +inline bool morseTimingActive(const Radio*) { return false; } struct RadioProfileCLI { static bool parseSuffix(const char* input, unsigned fields, char* legacy, size_t size, uint16_t& preamble) { @@ -52,7 +56,7 @@ struct Prefs { uint8_t path_hash_mode=0, multi_acks=0, rx_ps_level=0, rx_ps_preamble=0; int8_t tx_power_dbm=10; uint32_t rx_ps_rx_us=1000, rx_ps_sleep_us=1000; - bool cad_enabled=false, rx_boosted_gain=false; + bool cad_enabled=false, rx_boosted_gain=false, rx_powersaving_enabled=false; uint8_t extra_sf[4]={}; uint8_t flood_max=16, flood_max_advert=16, flood_max_unscoped=16; }; @@ -70,7 +74,20 @@ struct Callbacks { bool isTxPowerApplied(int8_t value) const { return power == value; } const mesh::Radio* getProfileRadio() const { return nullptr; } bool setRxBoostedGain(bool value) { ++gain_calls; if (!accept) return false; gain=value; return true; } + bool supportsRxBoostedGain() const { return true; } + mesh::RadioParamApplyResult trySetRxBoostedGain(bool value) { + return setRxBoostedGain(value) ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; + } + mesh::RadioParamApplyResult trySetRxPowerSaving(bool, uint32_t, uint32_t) { + return mesh::RadioParamApplyResult::FAILED; + } + mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool) { + return mesh::RadioParamApplyResult::FAILED; + } bool configSideDetectors(const uint8_t*,uint8_t,float) { return accept; } + mesh::RadioParamApplyResult tryConfigSideDetectors(const uint8_t* sfs,uint8_t count,float bw) { + return configSideDetectors(sfs,count,bw) ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; + } }; struct Board { unsigned n_cad_busy=0; }; bool isValidLoRaBandwidth(float bw) { return bw==125 || bw==62.5f; } @@ -89,10 +106,17 @@ public: Board* _board=&board; bool _tx_power_pending=false; uint32_t _tx_power_retry_at=0; + enum RxSetting : uint8_t { RxGain=1, RxPowerSaving=2, RxSideDetectors=4, RxRfRx=8, + RxPendingMask=15, RxRfRxDisabled=128 }; + uint8_t _rx_settings_pending=0; + uint32_t _rx_settings_retry_at=0; void savePrefs() { callbacks.savePrefs(); } bool trySavePrefs() { savePrefs(); return true; } void applyAndSaveTxPower(int8_t power, char* reply); void servicePendingTxPower(); + mesh::RadioParamApplyResult applyRxSetting(uint8_t); + bool saveRxSetting(uint8_t, mesh::RadioParamApplyResult, char*); + void restoreRxSetting(uint8_t, mesh::RadioParamApplyResult, uint8_t, uint32_t); bool savePrimaryRadioParams(float freq, float bw, uint8_t sf, uint8_t cr, uint16_t) { prefs.freq=freq; prefs.bw=bw; prefs.sf=sf; prefs.cr=cr; prefs.tx_power_dbm=mesh::clampLoRaTxPower(prefs.tx_power_dbm,freq); @@ -119,6 +143,7 @@ public: }; @TX_METHODS@ +@RX_METHODS@ struct Case { const char *key, *value; double expected; }; int main() { diff --git a/test/fixtures/common_cli_tx_pending.cpp b/test/fixtures/common_cli_tx_pending.cpp index 5ebf55e6f..8bb97fe0b 100644 --- a/test/fixtures/common_cli_tx_pending.cpp +++ b/test/fixtures/common_cli_tx_pending.cpp @@ -82,7 +82,8 @@ public: uint32_t _tx_power_retry_at = 0; bool _common_save_result_known = false; bool _common_save_succeeded = false; - struct Profiles { void loop() {} } _radio_profiles; + struct Profiles { void loop() {} void servicePendingTuning() {} } _radio_profiles; + Profiles& radioProfiles() { return _radio_profiles; } struct MorseReply { void complete(const void*) {} } _morse_reply; MorseReply& morseReply() { return _morse_reply; } CommonCLI() { callbacks.owner = this; } @@ -91,6 +92,7 @@ public: void loop(); void applyAndSaveTxPower(int8_t, char*); void servicePendingTxPower(); + void servicePendingRxSettings() {} bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common); void command(const char* text, char* reply) { if (!strncmp(text, "set ", 4)) { diff --git a/test/fixtures/companion_radio_diagnostics/test.cpp b/test/fixtures/companion_radio_diagnostics/test.cpp index 520233908..a79addba5 100644 --- a/test/fixtures/companion_radio_diagnostics/test.cpp +++ b/test/fixtures/companion_radio_diagnostics/test.cpp @@ -7,6 +7,7 @@ #include #include #include +namespace mesh { enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; } constexpr int RADIOLIB_NC=-1; constexpr int RADIOLIB_ERR_NONE=0; @@ -55,6 +56,9 @@ public: bool isInRecvMode() const; virtual bool supportsRxPowerSavingRfRxDisable() const {return false;} virtual bool setRxPowerSavingRfRxDisabled(bool) {return false;} + virtual mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool) { + return mesh::RadioParamApplyResult::FAILED; + } virtual bool isRxPowerSavingRfRxDisabled() const {return false;} uint32_t getRxPsWatchdogSoftCount() const {return n_wd_soft;} uint32_t getRxPsWatchdogHardCount() const {return n_wd_hard;} @@ -162,6 +166,17 @@ static void radio2_suspended_rxps() { #endif } int main() { + { + Driver driver; + char reply[80] = "untouched"; + const auto before = driver.physical.flag_writes; + assert(!mesh::companion::handleRadioDiagnosticsCommand(driver, true, + "set radio.rxps.rfrx_disabled on", reply, sizeof(reply), []() {})); + assert(driver.physical.flag_writes == before && !strcmp(reply, "untouched")); + assert(mesh::companion::handleRadioDiagnosticsCommand(driver, true, + "get rxps.wd", reply, sizeof(reply), []() {})); + assert(strncmp(reply, "> soft=", 7) == 0); + } backend_guards();radio2_suspended_rxps();Harness h;char reply[160]; bounds(h,"get rxps.wd","> soft=11,hard=13"); #if defined(SX126X_RXEN) && SX126X_RXEN != -1 diff --git a/test/fixtures/companion_rxps_setters.cpp b/test/fixtures/companion_rxps_setters.cpp index ce62f9eac..970fd247b 100644 --- a/test/fixtures/companion_rxps_setters.cpp +++ b/test/fixtures/companion_rxps_setters.cpp @@ -10,6 +10,7 @@ #include #include +namespace mesh {enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};} struct Prefs { uint8_t rx_powersaving_enabled = 0, rx_ps_level = 8, rx_ps_preamble = 16, sf = 7; uint32_t rx_ps_rx_us = 3000, rx_ps_sleep_us = 4000; @@ -26,6 +27,9 @@ struct Driver { unsigned calls = 0; uint32_t rx = 3000, sleep = 4000; std::deque results; + mesh::RadioParamApplyResult trySetRxPowerSaving(bool enable,uint32_t rx,uint32_t sleep) { + return setRxPowerSaving(enable,rx,sleep)?mesh::RadioParamApplyResult::APPLIED:mesh::RadioParamApplyResult::FAILED; + } bool supportsRxPowerSaving() const { return supported; } bool setRxPowerSaving(bool enable, uint32_t receive, uint32_t rest) { ++calls; @@ -53,6 +57,7 @@ struct MyMesh { Serial serial; Serial* _serial = &serial; bool _radio_available = true, storage_accepts = true, saved_radio_apply_pending = false; + bool _primary_radio_blocked=false; unsigned saves = 0; uint32_t radio_apply_retry_at = 0; uint8_t radio_apply_failures = 0; @@ -60,6 +65,7 @@ struct MyMesh { char reply_buf[160] = {}; MyMesh() { radio_driver = Driver(); } bool savePrefs() { ++saves; if (!storage_accepts) return false; durable = _prefs; return true; } + void queueSavedRadioApply(); bool applyAndSaveRxPowerSaving(const char*, char*); bool handleLocalControlCommand(const char*, char*, size_t); bool handleCommand(const char*, uint32_t, char*); diff --git a/test/fixtures/morse_reply_companion.cpp b/test/fixtures/morse_reply_companion.cpp index 23e572d07..35c907da4 100644 --- a/test/fixtures/morse_reply_companion.cpp +++ b/test/fixtures/morse_reply_companion.cpp @@ -1,3 +1,4 @@ +#include namespace mesh { struct Packet { bool radio_reply=false; }; } struct ContactInfo { struct { uint8_t pub_key[32]={}; } id; @@ -10,6 +11,8 @@ static uint32_t millis() { return 100; } struct MyMesh { mesh::MorseReplyHandoff _morse_reply; bool _morse_reply_admission=false; + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_reply_admission=false; TestRadio radio; unsigned active=0, forwarded=0; uint32_t handled_timestamp=0; diff --git a/test/fixtures/morse_reply_loop.cpp b/test/fixtures/morse_reply_loop.cpp index eeba0d197..b27e70db5 100644 --- a/test/fixtures/morse_reply_loop.cpp +++ b/test/fixtures/morse_reply_loop.cpp @@ -28,6 +28,7 @@ struct CommonCLI { void loopBatteryForwarding(bool = false) {} void loopManagement() { ++management_loops; } void servicePendingTxPower() { ++tx_services; } + void servicePendingRxSettings() {} void loop(); }; @LOOP@ diff --git a/test/fixtures/mqtt_ap_ownership.cpp b/test/fixtures/mqtt_ap_ownership.cpp index 08a2456cb..d35c84327 100644 --- a/test/fixtures/mqtt_ap_ownership.cpp +++ b/test/fixtures/mqtt_ap_ownership.cpp @@ -75,6 +75,7 @@ struct MQTTBridge{ std::atomic_network_suspended{false}; bool _network_espnow_restore_pending=false; bool _wifi_event_registered=false,_manage_wifi=true,_ntp_sync_pending=false; + bool _wifi_credentials_changed=false; int _wifi_reconnect_backoff_attempt=0; std::atomic_ntp_synced{true},_stop_requested{false},_stop_acked{false}; struct{void noteGotIp(){}}_ntp_reconnect_latch; @@ -169,6 +170,23 @@ int main(int argc,char** argv){ race_ap=true;assert(!bridge.initializeWiFiInTask()); assert(sdk_mode==WIFI_AP_STA&&event_registers==0&&reconnect_writes==0&&station_begins==0); race_ap=false;sdk_mode=WIFI_STA;assert(bridge.initializeWiFiInTask());assert(event_registers==1); + }else if(scenario=="changed_credentials"){ + // A different saved tuple must replace an old, still-connected station. + bridge._wifi_credentials_changed=true; + assert(bridge.initializeWiFiInTask()); + assert(station_disconnects==1&&station_begins==1&&!bridge._wifi_credentials_changed); + WiFi.state=WL_CONNECTED; + assert(bridge.initializeWiFiInTask()); + assert(station_disconnects==1&&station_begins==1); + // A setup/OTA owner defers that reconnect without consuming the intent. + sdk_mode=WIFI_AP_STA;WiFi.state=WL_CONNECTED;bridge._wifi_credentials_changed=true; + assert(!bridge.initializeWiFiInTask()&&bridge._wifi_credentials_changed); + assert(!bridge.handleWiFiConnection(20000)&&bridge._wifi_credentials_changed); + assert(station_disconnects==1&&station_begins==1); + sdk_mode=WIFI_STA; + now_ms=20000; + bridge.handleWiFiConnection(20000); + assert(!bridge._wifi_credentials_changed&&station_disconnects==2&&station_begins==2); }else if(scenario=="recovery"||scenario=="companion_recovery"){ bridge._manage_wifi=scenario!="companion_recovery"; for(int mode:{WIFI_AP,WIFI_AP_STA}){ diff --git a/test/fixtures/mqtt_canonical_wifi.cpp b/test/fixtures/mqtt_canonical_wifi.cpp index de5315edd..c97ba4939 100644 --- a/test/fixtures/mqtt_canonical_wifi.cpp +++ b/test/fixtures/mqtt_canonical_wifi.cpp @@ -18,7 +18,9 @@ void beginStation(const char* ssid, const char* password) { }} @WEB_POLICY@ enum { WIFI_OFF, WIFI_STA, WIFI_PS_NONE, WIFI_PS_MAX_MODEM, WIFI_PS_MIN_MODEM }; -struct { int getMode() { return WIFI_STA; } } WiFi; +struct { unsigned disconnects = 0; int getMode() { return WIFI_STA; } + void disconnect() { ++disconnects; } } WiFi; +uint32_t millis() { return 1000; } using wifi_ps_type_t = int; using wifi_mode_t = int; using esp_err_t = int; @@ -79,6 +81,9 @@ struct MQTTBridge { MQTTPrefs* _obs; MQTTNodeInfo _node_info; bool _manage_wifi = true, _wifi_configured = false, _initialized = false; + bool _wifi_credentials_changed = false, _start_requested = false; + uint32_t _last_wifi_reconnect_attempt = 0; + uint8_t _wifi_reconnect_backoff_attempt = 0; char _wifi_ssid[32] = {}, _wifi_password[65] = {}; uint8_t _wifi_power_save = 0; std::atomic _stop_requested{false}; @@ -167,6 +172,15 @@ int main() { // Saved changes affect a new boot/restart, never a live worker snapshot. assert(WebConfigServer::saveStandaloneWiFi("changed", "new-password", 0)); assert_selected(portal, "portal", "password"); + // The same stopped bridge keeps the old verified tuple until begin snapshots + // the latest settings, and retains changed intent through failed allocations. + assert(portal.prepareWiFiCredentials()); + assert(portal._wifi_credentials_changed); + assert(portal.prepareWiFiCredentials() && portal._wifi_credentials_changed); + const auto old_disconnects = WiFi.disconnects; + portal.beginWiFiStation(); + assert(!portal._wifi_credentials_changed && WiFi.disconnects == old_disconnects + 1); + assert_selected(portal, "changed", "new-password"); MQTTBridge reboot(obs); reboot.begin(); assert(reboot._wifi_power_save == 0); assert_selected(reboot, "changed", "new-password"); std::string raw(64, 'A'); diff --git a/test/fixtures/nimble_companion/test_nimble_companion.cpp b/test/fixtures/nimble_companion/test_nimble_companion.cpp index 7a975486f..b80ebf8b5 100644 --- a/test/fixtures/nimble_companion/test_nimble_companion.cpp +++ b/test/fixtures/nimble_companion/test_nimble_companion.cpp @@ -101,6 +101,36 @@ static void authenticate(SerialBLEInterface& interface, NimBLEConnInfo& info) { interface.checkRecvFrame(discarded); } +static void test_advertising_restart_retries_and_disable_cancels() { + reset(); SerialBLEInterface interface; + assert(interface.begin("MeshCore-", "retry", 123456)); + interface.enable(); + NimBLEConnInfo info; authenticate(interface, info); + auto* server = NimBLEDevice::server.get(); + server->connected = false; + server->callbacks->onDisconnect(server, info, 0); + uint8_t frame[MAX_FRAME_SIZE]; interface.checkRecvFrame(frame); + failure = "advertise"; + const auto attempts = std::count(calls.begin(), calls.end(), "advertise"); + test_millis += 1000; interface.checkRecvFrame(frame); + assert(!server->advertising.active); + assert(std::count(calls.begin(), calls.end(), "advertise") == attempts + 1); + for (unsigned i = 0; i < 100; ++i) interface.checkRecvFrame(frame); + assert(std::count(calls.begin(), calls.end(), "advertise") == attempts + 1); + failure.clear(); test_millis += 999; interface.checkRecvFrame(frame); + assert(!server->advertising.active); + ++test_millis; interface.checkRecvFrame(frame); + assert(server->advertising.active); + assert(std::count(calls.begin(), calls.end(), "advertise") == attempts + 2); + authenticate(interface, info); + server->connected = false; server->callbacks->onDisconnect(server, info, 0); + interface.checkRecvFrame(frame); interface.disable(); + const auto before_off = std::count(calls.begin(), calls.end(), "advertise"); + test_millis += 10000; interface.checkRecvFrame(frame); + assert(std::count(calls.begin(), calls.end(), "advertise") == before_off); + NimBLEDevice::deinit(true); +} + static void test_authentication_and_bond_lifecycle() { for (unsigned missing = 0; missing < 3; ++missing) { reset(); SerialBLEInterface interface; @@ -493,6 +523,7 @@ int main() { test_identity_modes(); test_pairing_wake_for_random_and_saved_pins(); test_startup_failures(); + test_advertising_restart_retries_and_disable_cancels(); test_authentication_and_bond_lifecycle(); test_connection_parameters_only_on_current_authenticated_transition(); test_real_transport_frames(); diff --git a/test/fixtures/radio_profiles/switch_tuning_test.cpp b/test/fixtures/radio_profiles/switch_tuning_test.cpp index 82f479ade..9fc09a6ef 100644 --- a/test/fixtures/radio_profiles/switch_tuning_test.cpp +++ b/test/fixtures/radio_profiles/switch_tuning_test.cpp @@ -280,13 +280,10 @@ static void localOnlyAndBusyTransactions() { const auto original = f.radio.tuning; const auto saved = f.fs.files["/radio_fast"]; const unsigned persists = f.radio.persists; - for (const auto result : {mesh::RadioParamApplyResult::BUSY, - mesh::RadioParamApplyResult::FAILED}) { - f.radio.admission = result; - f.cmd("set radio.fast.fs on", false); - assert(f.radio.tuning == original && f.radio.persists == persists); - assert(f.fs.files["/radio_fast"] == saved); - } + f.radio.admission = mesh::RadioParamApplyResult::FAILED; + f.cmd("set radio.fast.fs on", false); + assert(f.radio.tuning == original && f.radio.persists == persists); + assert(f.fs.files["/radio_fast"] == saved); f.radio.admission = mesh::RadioParamApplyResult::APPLIED; assert(strstr(f.cmd("set radio.fast.fs on", false, true), "local")); f.cli.beginReplyCommand(); @@ -299,6 +296,77 @@ static void localOnlyAndBusyTransactions() { puts("Fast tuning: busy admission and local-only changes passed"); } +static void busyTargetsComposeAndApplyOnce() { + TuningFixture f; + const auto original = f.radio.tuning; + f.radio.admission = mesh::RadioParamApplyResult::BUSY; + assert(strstr(f.cmd("set radio.fast.warm off"), "pending")); + assert(f.cli.hasPendingTuning()); + assert(strstr(f.cmd("set radio.fast.spi 9"), "pending")); + const auto committed = f.fs.files["/radio_fast"]; + assert(f.radio.tuning == original); + // This pending feature must survive a rejected replacement save. + f.fs.fail_write = true; + f.cmd("set radio.fast.packet off", false); + f.fs.fail_write = false; + assert(f.fs.files["/radio_fast"] == committed); + const auto saved = f.fs.files; + for (unsigned i = 0; i < 40; ++i) f.advance(1000); + assert(f.cli.hasPendingTuning() && f.fs.files == saved); + // Completion hooks call this before the next queued packet starts. + f.radio.admission = mesh::RadioParamApplyResult::APPLIED; + f.cli.servicePendingTuning(); + assert(!f.cli.hasPendingTuning()); + assert(!(f.radio.tuning.flags & mesh::RadioSwitchTuning::Warm)); + assert(f.radio.tuning.flags & mesh::RadioSwitchTuning::Packet); + assert(f.radio.tuning.spi_mhz == 9 && f.fs.files == saved); + const auto attempts = f.radio.attempts; + f.cli.servicePendingTuning(); + assert(f.radio.attempts == attempts); + TuningRadio reboot; + mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock); + assert(reboot.tuning == f.radio.tuning); + puts("Fast tuning: BUSY targets compose, survive failed save and apply once without a retry command"); +} + +static void failedPreambleCompensationRepairsDurably() { + for (bool rollover : {false, true}) for (bool explicit_save : {false, true}) { + TuningFixture f; + assert(f.cli.savePrimaryPreamble(32)); + const auto old = f.fs.files["/radio_profiles"]; + assert(f.cli.savePrimaryPreamble(64)); + const auto candidate = f.fs.files["/radio_profiles"]; + assert(candidate != old); + const auto physical = f.radio.p.primary; + f.fs.fail_write = true; + assert(!f.cli.restorePrimaryPreamble(32)); + assert(f.cli.primaryPreamble() == 32 && f.cli.hasPendingTuning()); + assert(f.fs.files["/radio_profiles"] == candidate); + // RAM equality is insufficient evidence of a durable compensation. + assert(!f.cli.savePrimaryPreamble(32)); + assert(f.cli.hasPendingTuning()); + g_mock_millis = rollover ? UINT32_MAX - 500 : 100; + f.cli.servicePendingTuning(); + f.fs.fail_write = false; + if (explicit_save) { + assert(f.cli.savePrimaryPreamble(32)); + } else { + f.advance(999); + assert(f.cli.hasPendingTuning() && f.fs.files["/radio_profiles"] == candidate); + f.advance(1); + } + assert(!f.cli.hasPendingTuning() && f.fs.files["/radio_profiles"] == old); + assert(f.radio.p.primary == physical); // Persistence repair never retunes RX. + TuningRadio reboot; mesh::RadioProfileCLI restored; + restored.begin(&f.fs, &reboot, &f.clock); + assert(restored.primaryPreamble() == 32); + assert(!f.cli.restorePrimaryPreamble(7)); + assert(f.cli.primaryPreamble() == 32 && !f.cli.hasPendingTuning()); + } + puts("Preamble compensation: old runtime target, checked identical save, delayed durable repair and rollover passed"); +} + static void storageFailureKeepsCommittedPolicy() { for (unsigned failure = 0; failure < 6; ++failure) { TuningFixture f; @@ -435,11 +503,14 @@ static void bootRetriesAreBoundedAcrossRollover() { restored.loop(); assert(reboot.attempts == 2 && reboot.tuning != expected); restored.loop(); - assert(reboot.attempts == 2); + // BUSY is just an admission check: service can use the next TX gap. + // Real hardware failures retain the one-second retry backoff. + const unsigned before = admission == mesh::RadioParamApplyResult::BUSY ? 3 : 2; + assert(reboot.attempts == before); reboot.admission = mesh::RadioParamApplyResult::APPLIED; g_mock_millis += 1000; restored.loop(); - assert(reboot.attempts == 3 && reboot.tuning == expected); + assert(reboot.attempts == before + 1 && reboot.tuning == expected); assert(f.fs.files == saved && !reboot.persists); } } @@ -491,6 +562,8 @@ int main() { roundedClockRequiresCapabilityAndBootAdmission(); clockOnlyLegacyHalKeepsFeaturesUnavailable(); localOnlyAndBusyTransactions(); + busyTargetsComposeAndApplyOnce(); + failedPreambleCompensationRepairsDurably(); storageFailureKeepsCommittedPolicy(); bootRecoveryAndDeferredAdmission(); malformedAndUnreadableImagesStayProtected(); diff --git a/test/fixtures/radio_receive_retry.cpp b/test/fixtures/radio_receive_retry.cpp index 4319faaa2..1f33a0372 100644 --- a/test/fixtures/radio_receive_retry.cpp +++ b/test/fixtures/radio_receive_retry.cpp @@ -321,7 +321,7 @@ static void recoveryOwnershipAdmission() { for (bool between_checks : {false, true}) { RadioLibWrapper w; selectMode(w, mode); w._rx_ps_armed = mode == 1; - w._rx_boosted_gain_valid = w._cur_rx_boosted_gain = true; + w._rx_boosted_gain_valid = w._rx_boosted_gain_applied = w._cur_rx_boosted_gain = true; w._nf_refresh_requested = false; w._nf_last_calib = now_ms; assert(w._floor_estimator.add(-105, now_ms)); const uint8_t payload[] = {0x18, 0x27, 0x36, 0x45, 0x54}; diff --git a/test/fixtures/rs232_mqtt_runtime.cpp b/test/fixtures/rs232_mqtt_runtime.cpp index 47f6e3fd6..f9d41d156 100644 --- a/test/fixtures/rs232_mqtt_runtime.cpp +++ b/test/fixtures/rs232_mqtt_runtime.cpp @@ -132,6 +132,7 @@ class MQTTBridge : public AbstractBridge { bool running = false; bool stopping = false; bool network_suspended = false; + bool start_pending = false; public: inline static unsigned live = 0, fail_starts = 0; unsigned begins = 0, ends = 0, loops = 0, sent = 0, received = 0; @@ -143,13 +144,16 @@ public: void setBuildDate(const char*) {} void setStatsSources(void*, void*, void*, void*) {} void begin() override { + if (stopping) { start_pending = true; return; } assert(filters); ++begins; - if (fail_starts) { --fail_starts; return; } + if (fail_starts) { --fail_starts; start_pending = true; return; } running = true; + start_pending = false; network_suspended = false; } - void end() override { ++ends; running = stopping = network_suspended = false; } - void requestStop() { stopping = running; } + void end() override { ++ends; running = stopping = network_suspended = start_pending = false; } + void requestStop() { start_pending = false; stopping = running; } + bool isStartPending() const { return start_pending; } bool isStopping() const { return stopping; } bool isRunning() const override { return running; } bool isNetworkSuspended() const { return network_suspended; } @@ -159,7 +163,8 @@ public: } void loop() override { ++loops; - if (stopping) { ++ends; running = stopping = network_suspended = false; } + if (stopping) { ++ends; running = stopping = network_suspended = false; return; } + if (start_pending) begin(); } void sendPacket(mesh::Packet* p) override { if (allow(p)) ++sent; } void onPacketReceived(mesh::Packet* p) { if (allow(p)) ++received; } @@ -205,6 +210,7 @@ struct Callbacks { virtual bool setMqttBridgeState(bool) = 0; virtual bool requestMqttBridgeStop() = 0; virtual bool isMqttBridgeStopping() = 0; + virtual bool isMqttBridgeStartPending() = 0; virtual bool restartMqttBridge() = 0; }; class MyMesh : public Callbacks { @@ -418,10 +424,11 @@ int main() { expect_get(cli, "mqtt.stopping", "> on"); const unsigned stopping_saves = cli.saves; cli.set("mqtt.enabled on", reply); - assert(strstr(reply, "MQTT is stopping") && !mesh._prefs.bridge_enabled); - assert(cli.saves == stopping_saves && !mesh.setMqttBridgeState(true)); + assert(!strcmp(reply, "OK") && mesh._prefs.bridge_enabled); + assert(cli.saves == stopping_saves + 1 && mesh.isMqttBridgeStartPending()); cli.set("mqtt.enabled off", reply); // a repeated request is accepted assert(strcmp(reply, "OK") == 0 && mesh.isMqttBridgeStopping()); + assert(!mesh.isMqttBridgeStartPending()); assert(mesh.isRs232BridgeRunning() && mesh.bridgesPreventSleep()); const unsigned uart_tx_before = mesh.bridge->sent; mesh.routeRx(&packet); mesh.serviceBridges(); diff --git a/test/fixtures/sensor_tracker_radio_lease.cpp b/test/fixtures/sensor_tracker_radio_lease.cpp index f331c8b0d..4e572aec4 100644 --- a/test/fixtures/sensor_tracker_radio_lease.cpp +++ b/test/fixtures/sensor_tracker_radio_lease.cpp @@ -95,7 +95,7 @@ struct CLI { bool scheduled = false; unsigned attempts = 0; bool hasActiveUserGpioTimer() const { return false; } - bool hasPendingTxPower() const { return false; } + bool hasPendingRadioSettings() const { return false; } void loop(); }; class SensorMesh : public mesh::Mesh { diff --git a/test/fixtures/webconfig_ap_start.cpp b/test/fixtures/webconfig_ap_start.cpp index 7ffd78627..a943e73fb 100644 --- a/test/fixtures/webconfig_ap_start.cpp +++ b/test/fixtures/webconfig_ap_start.cpp @@ -327,7 +327,7 @@ void serializeJson(DynamicJsonDocument& doc, AsyncResponseStream& response) { struct WebConfigServer { enum Mode { MODE_OFF, MODE_LAN, MODE_SETUP }; Mode _mode = MODE_OFF; - bool _owns_wifi = true, _stopping = false; + bool _owns_wifi = true, _stopping = false, _start_retryable = false; bool _retry_saved_wifi_in_setup = true, _setup_reconnect_in_progress = true; bool _was_setup_ap = false, _initial_setup = false; bool _setup_provisioning_privilege = false; @@ -394,6 +394,7 @@ int main() { WebConfigServer portal; char reply[160] = {}; assert(!portal.startSetupMode(reply)); assert(strstr(reply, "stop ESP-NOW") && WiFi.live_mode == WIFI_STA); + assert(!portal._start_retryable); assert(WiFi.sta_only_calls == 0 && WiFi.ap_calls == 0 && WiFi.scans == 0); mesh::wifi::setLongRangeOwner(owner, false); } @@ -409,6 +410,7 @@ int main() { char reply[160] = {}; const uint32_t started = millis(); assert(!portal.startSetupMode(reply)); + assert(portal._start_retryable); assert(millis() - started < 4500); assert(portal._mode == WebConfigServer::MODE_OFF && !portal._dns && portal.servers == 0); const unsigned prepared = fault != Fault::ModeSetterFailure diff --git a/test/fixtures/webconfig_ota_handoff.cpp b/test/fixtures/webconfig_ota_handoff.cpp index 3ae25734e..28dbdea38 100644 --- a/test/fixtures/webconfig_ota_handoff.cpp +++ b/test/fixtures/webconfig_ota_handoff.cpp @@ -39,7 +39,8 @@ struct WebConfigServer { Callbacks callbacks; Callbacks* _cb = &callbacks; mesh::RoomWebMailbox* _room_mailbox = new mesh::RoomWebMailbox; - Mode _mode = MODE_LAN; + Mode _mode = MODE_LAN, _start_mode = MODE_OFF; + uint32_t _start_retry_at = 0, _start_retry_delay = 5000; bool _stopping = false, _keep_wifi_on_stop = false, _was_setup_ap = false; bool _owns_wifi = true, _stop_warned = false, _initial_setup = false; bool _setup_provisioning_privilege = false; @@ -139,4 +140,11 @@ int main() { assert(!failed.stopForOTA(reply)); assert(strstr(reply, "WebConfig stopped; WiFi AP handoff failed")); assert(!failed.server.listening); + // OTA cancels a queued AP retry even when no listener was created yet. + WebConfigServer pending; + pending._mode = WebConfigServer::MODE_OFF; + pending._start_mode = WebConfigServer::MODE_SETUP; + pending._start_retry_at = 5000; + assert(pending.stopForOTA(reply)); + assert(pending._start_mode == WebConfigServer::MODE_OFF && !pending._start_retry_at); } diff --git a/test/fixtures/webconfig_station_owner.cpp b/test/fixtures/webconfig_station_owner.cpp index 22c3b2a6c..e3b30121e 100644 --- a/test/fixtures/webconfig_station_owner.cpp +++ b/test/fixtures/webconfig_station_owner.cpp @@ -24,6 +24,7 @@ struct WebConfigServer { bool isStopping() const { return false; } void updateWiFiOwnership(bool owner) { owns = owner; } void requestNetworkAccess() {} + bool requestStart(bool ap, char* reply) { return ap ? startSetupMode(reply) : startAutoMode(reply); } bool startAutoMode(char* reply) { strcpy(reply, "starting"); return true; } bool startSetupMode(char* reply) { strcpy(reply, "setup"); return true; } }; diff --git a/test/fixtures/webconfig_station_recovery.cpp b/test/fixtures/webconfig_station_recovery.cpp index be87e9035..d32c60091 100644 --- a/test/fixtures/webconfig_station_recovery.cpp +++ b/test/fixtures/webconfig_station_recovery.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #define MESH_MANAGEMENT_PASSWORD_HOST_TEST #include @@ -102,7 +103,7 @@ public: char _wifi_ssid[32] = {}, _wifi_password[65] = {}; uint8_t _wifi_power_save = 0; Mode _mode = MODE_OFF; - bool _stopping = false, _retry_saved_wifi_in_setup = false; + bool _stopping = false, _retry_saved_wifi_in_setup = false, _start_retryable = false; bool _setup_reconnect_in_progress = false, _board_cmds_probed = false; bool _was_setup_ap = false, _initial_setup = false; bool foreground = false; diff --git a/test/test_beacon_radio.py b/test/test_beacon_radio.py index 3cfd09700..8f47e5db1 100644 --- a/test/test_beacon_radio.py +++ b/test/test_beacon_radio.py @@ -86,7 +86,8 @@ struct RadioLibWrapper { bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_hold_continuous = false; bool _rx_ps_continuous_fallback = false, _nf_calib_active = false; bool _nf_refresh_requested = false; - bool _rx_boosted_gain_valid = true, _cur_rx_boosted_gain = true; + bool _rx_boosted_gain_valid = true, _rx_boosted_gain_applied = true; + bool _cur_rx_boosted_gain = true; bool _noise_floor_valid = true, _noise_floor_secondary_valid = true; bool _wd_last_busy = false; uint8_t _startrx_fails = 0, _irq_probe_failures = 0; diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index f7b7094bd..27d2cc31a 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -80,6 +80,61 @@ OBSERVER_QUERY_EXCEPTIONS = { class CLISettingsContractTest(unittest.TestCase): + def test_shared_primary_parser_can_be_skipped_without_changing_other_commands(self): + harness = r''' +#include +#include +#include +#include "examples/companion_radio/NodePrefs.h" +int main() { + CompanionNodePrefs prefs; + prefs.freq = 915; prefs.bw = 125; prefs.sf = 7; prefs.cr = 5; + auto* radio = prefs.getRadioPrefs(); radio->clearDirty(); + char reply[160] = "unchanged"; + for (const char* text : {"set radio 916,125,8,6", "set radio invalid"}) { + assert(!radio->handleCommand(text, 0, reply, false)); + assert(!strcmp(reply, "unchanged") && !radio->isDirty()); + assert(prefs.freq == 915 && prefs.bw == 125 && prefs.sf == 7 && prefs.cr == 5); + } + assert(radio->handleCommand("get radio", 0, reply, false)); + float f = 0, bw = 0; unsigned sf = 0, cr = 0; + assert(sscanf(reply, "> %f,%f,%u,%u", &f, &bw, &sf, &cr) == 4); + assert(f == 915 && bw == 125 && sf == 7 && cr == 5); + assert(radio->handleCommand("set af 2", 0, reply, false)); + assert(!strcmp(reply, "OK") && prefs.airtime_factor == 2 && radio->isDirty()); + radio->clearDirty(); + // The three-argument/default path keeps the complete legacy parser. + assert(radio->handleCommand("set radio invalid", 0, reply)); + assert(strstr(reply, "invalid radio") && !radio->isDirty()); + assert(radio->handleCommand("set radio 916,125,8,6", 0, reply)); + assert(!strncmp(reply, "OK", 2) && radio->isDirty()); + assert(prefs.freq == 916 && prefs.bw == 125 && prefs.sf == 8 && prefs.cr == 6); +} +''' + with tempfile.TemporaryDirectory(prefix='common-radio-dispatch-') as directory: + work = Path(directory) + (work / 'test.cpp').write_text('#include \n' + harness) + (work / 'Utils.h').write_text('''#pragma once +#include +#include +namespace mesh { struct Utils { + static void printHex(Stream&,const uint8_t*,size_t){assert(false);} + static void fromHex(uint8_t*,size_t,const char*){assert(false);} +}; } +''') + sources = ['ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp'] + compiled = subprocess.run(['g++', '-std=c++17', '-DNRF52_PLATFORM=1', + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', + '-I', str(work), '-I', str(ROOT / 'test/fixtures/radio_profiles/mocks'), + '-I', str(ROOT / 'test/mocks'), '-I', str(ROOT / 'src'), '-I', str(ROOT), + str(work / 'test.cpp'), *(str(ROOT / 'src/helpers' / name) for name in sources), + '-o', str(work / 'test')], text=True, capture_output=True) + self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) + checked = subprocess.run([str(work / 'test')], text=True, capture_output=True) + self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) + def test_feature_guarded_query_coverage(self): compiler = shutil.which('g++') or shutil.which('clang++') self.assertIsNotNone(compiler, 'Host C++ compiler required') @@ -275,6 +330,11 @@ class CLISettingsContractTest(unittest.TestCase): extract_braced(source, signature).replace('CommonCLI::', 'CLI::') for signature in ('void CommonCLI::applyAndSaveTxPower(', 'void CommonCLI::servicePendingTxPower('))) + fixture = fixture.replace('@RX_METHODS@', '\n'.join( + extract_braced(source, signature).replace('CommonCLI::', 'CLI::') + for signature in ('mesh::RadioParamApplyResult CommonCLI::applyRxSetting(', + 'bool CommonCLI::saveRxSetting(', + 'void CommonCLI::restoreRxSetting('))) cpp.write_text(fixture, encoding='utf-8') sanitizers = (['-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] if sys.platform.startswith('linux') else []) diff --git a/test/test_common_cli_logging_transaction.py b/test/test_common_cli_logging_transaction.py index 757fdf539..53fe3b26b 100644 --- a/test/test_common_cli_logging_transaction.py +++ b/test/test_common_cli_logging_transaction.py @@ -22,10 +22,11 @@ namespace mesh { void setUsbLoggingEnabled(bool value) { live_usb = value; ++usb_calls; } } struct Callbacks { - bool live_wifi = false, apply_ok = true, stopping = false; + bool live_wifi = false, apply_ok = true, stopping = false, pending = false; unsigned calls = 0; bool setMqttBridgeState(bool value) { ++calls; + if (apply_ok && value && stopping) { pending = true; return true; } if (apply_ok) live_wifi = value; return apply_ok; } @@ -91,15 +92,17 @@ int main() { assert(cli.persisted.usb_logging_enabled && cli.persisted.bridge_enabled); assert(live_usb && !cli.callbacks.live_wifi); assert(strstr(reply, "saved, but MQTT runtime change failed")); - // A restart during cleanup must not save ON or change either live output. + // A restart during cleanup saves intent without interrupting the old worker. cli.callbacks.stopping = true; + cli.callbacks.apply_ok = true; cli.prefs = cli.persisted = {0, 0}; live_usb = false; const unsigned before_saves = cli.saves, before_calls = cli.callbacks.calls; cli.set("logging.output both", reply); - assert(strstr(reply, "MQTT is stopping")); - assert(cli.saves == before_saves && cli.callbacks.calls == before_calls); - assert(!cli.prefs.bridge_enabled && !cli.persisted.bridge_enabled && !live_usb); + assert(strstr(reply, "(saved)")); + assert(cli.saves == before_saves + 1 && cli.callbacks.calls == before_calls + 1); + assert(cli.prefs.bridge_enabled && cli.persisted.bridge_enabled && live_usb); + assert(cli.callbacks.pending && !cli.callbacks.live_wifi); #endif } ''' diff --git a/test/test_common_cli_tx_pending.py b/test/test_common_cli_tx_pending.py index 6ad5fea4d..999798285 100644 --- a/test/test_common_cli_tx_pending.py +++ b/test/test_common_cli_tx_pending.py @@ -113,6 +113,8 @@ struct TxRole : mesh::Mesh { uint8_t tracker_packet_hash[MAX_HASH_SIZE] = {}; uint32_t futureMillis(uint32_t delay) const { return millis() + delay; } void finishRadioReply(bool) {} + void processScheduledRadioSettings(bool) {} + void servicePendingPrimaryRadioSettings(bool) {} void onSendComplete(mesh::Packet*) override; }; @CALLBACK@ @@ -225,12 +227,12 @@ int main() { f'mesh::powerdiag::Mask {name}::pendingWorkReason() const') # Include pending intent during hardware-failure backoff too: # nRF52's event-driven sleep has no guaranteed timer wake. - self.assertIn('_cli.hasPendingTxPower()', pending) - self.assertLess(pending.index('_cli.hasPendingTxPower()'), + self.assertIn('_cli.hasPendingRadioSettings()', pending) + self.assertLess(pending.index('_cli.hasPendingRadioSettings()'), pending.index('radio_apply_backoff') if 'radio_apply_backoff' in pending else len(pending)) recovery = extract_braced(header, 'bool canRecoverUsbLogging() const') - self.assertIn('!_cli.hasPendingTxPower()', recovery) + self.assertIn('!_cli.hasPendingRadioSettings()', recovery) self.assertIn('_cli.loop();', extract_braced(source, f'void {name}::loop(')) diff --git a/test/test_companion_prefs_transactions.py b/test/test_companion_prefs_transactions.py index bfb8106aa..da3e2d278 100644 --- a/test/test_companion_prefs_transactions.py +++ b/test/test_companion_prefs_transactions.py @@ -13,6 +13,7 @@ SOURCE = ROOT / 'examples/companion_radio/MyMesh.cpp' HARNESS = r''' #include +#include #include #include #include @@ -24,6 +25,8 @@ HARNESS = r''' #include #include #include +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +using Apply=mesh::RadioParamApplyResult; using std::min; using std::isfinite; #define constrain(value,lo,hi) ((value)<(lo)?(lo):((value)>(hi)?(hi):(value))) @@ -74,6 +77,18 @@ struct Driver { uint32_t rx=3000,sleep=4000; unsigned calls=0,gain_calls=0; std::deque results,gain_results; + std::deque rx_accepted,gain_accepted; + Apply trySetRxPowerSaving(bool enable,uint32_t rx,uint32_t sleep) { + if(!rx_accepted.empty()) { const auto result=rx_accepted.front();rx_accepted.pop_front();if(result!=Apply::APPLIED){++calls;return result;} } + return setRxPowerSaving(enable,rx,sleep)?Apply::APPLIED:Apply::FAILED; + } + Apply trySetRxBoostedGainMode(bool enable) { + if(!gain_accepted.empty()) { const auto result=gain_accepted.front();gain_accepted.pop_front();if(result!=Apply::APPLIED){++gain_calls;return result;} } + return setRxBoostedGainMode(enable)?Apply::APPLIED:Apply::FAILED; + } + Apply trySetTxPower(int8_t) {return Apply::APPLIED;} + Apply trySetRxPowerSavingRfRxDisabled(bool) {return Apply::APPLIED;} + bool supportsRxBoostedGainMode() const { return gain_supported; } bool supportsRxPowerSaving() const { return supported; } bool setRxPowerSaving(bool enable,uint32_t r,uint32_t s) { @@ -92,7 +107,34 @@ struct Driver { } radio_driver; @PARSERS@ struct Clock { uint32_t getMillis() const { return 100; } } clock_source; +struct CommonRadioPrefs {static bool isValidBandwidth(float){return true;}}; struct MyMesh { + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_blocked=false,_primary_radio_reply_admission=false; + + int8_t rf_rx_disable_pending=-1;uint32_t rf_rx_retry_at=0; + struct { + bool acceptsPrimary(float,float,uint8_t,uint8_t,uint16_t) const {return true;} + uint16_t primaryPreamble() const {return 0;} + bool savePrimaryPreamble(uint16_t) {return true;} + bool restorePrimaryPreamble(uint16_t) {return true;} + void servicePendingTuning() {} + } _radio_profiles; + struct { + struct {struct {uint8_t sf=7;float bw=250;} primary;} p; + auto* profiles() {return &p;} + bool isCarrierWaveActive() const {return false;} + bool isMorseTxActive() const {return false;} + bool isMorseRxActive() const {return false;} + } radio; + decltype(radio)* _radio=&radio; + enum class RadioSettingResult {Saved,RadioRejected,SaveFailed,SaveUnconfirmed}; + RadioSettingResult applyAndSaveRadioParams(float,float,uint8_t,uint8_t,uint8_t,uint16_t,bool=false); + bool isValidClientRepeatFreq(uint32_t) const {return true;} + Apply tryApplyRadioParams(float,float,uint8_t,uint8_t) {return Apply::APPLIED;} + void queueSavedRadioApply(); + void servicePendingTxPower() {} + void servicePendingLocalRadioSettings(); Prefs _prefs,durable; double durable_lat=10,durable_lon=20; bool storage_accepts=true,_radio_available=true; @@ -140,7 +182,7 @@ struct MyMesh { bool millisHasNowPassed(uint32_t) const { return true; } uint32_t futureMillis(uint32_t delay) const { return delay+100; } bool applySavedRadioParams() { return true; } - void recover() { @RECOVERY@ } + void recover() { servicePendingLocalRadioSettings(); } void expectOriginal() const { assert(!memcmp(&_prefs,&durable,sizeof(Prefs))); assert(!strcmp(_prefs.node_name,"original")); @@ -292,6 +334,21 @@ int main() { assert(!m.applyAndSaveRxPowerSaving("10000 20000",reply));++checks; assert(m.saves==0);m.expectOriginal(); } + for(bool gain:{false,true}) { + MyMesh m;char reply[160]={}; + if(gain)radio_driver.gain_accepted={Apply::BUSY};else radio_driver.rx_accepted={Apply::BUSY}; + assert(gain?m.applyAndSaveRxBoostedGain(true):m.applyAndSaveRxPowerSaving("10000 20000",reply));++checks; + assert(m.saves==1 && m.saved_radio_apply_pending); + assert(gain?bool(m.durable.rx_boosted_gain):bool(m.durable.rx_powersaving_enabled)); + m.recover();assert(!m.saved_radio_apply_pending && m.saves==1); + assert(gain?radio_driver.boosted_gain:radio_driver.rxps); + // A failed BUSY replacement leaves the first desired target intact. + m.saved_radio_apply_pending=true;m.radio_apply_retry_at=500;m.storage_accepts=false; + if(gain)radio_driver.gain_accepted={Apply::BUSY};else radio_driver.rx_accepted={Apply::BUSY}; + assert(!(gain?m.applyAndSaveRxBoostedGain(false):m.applyAndSaveRxPowerSaving("off",reply)));++checks; + assert(m.saved_radio_apply_pending && m.radio_apply_retry_at==500 && m.saves==2); + assert(gain?bool(m._prefs.rx_boosted_gain):bool(m._prefs.rx_powersaving_enabled)); + } // Boosted gain uses the actual setter and the shared saved-radio recovery. // Cover both directions, rejected saves, busy restoration, and unsupported radios. for(bool initial:{false,true})for(bool save_ok:{false,true}) { @@ -329,7 +386,7 @@ int main() { { MyMesh m;radio_driver.gain_results={false}; assert(!m.applyAndSaveRxBoostedGain(true));++checks; - assert(m.saves==0 && !radio_driver.boosted_gain && !m.saved_radio_apply_pending); + assert(m.saves==0 && !radio_driver.boosted_gain && m.saved_radio_apply_pending); m.expectOriginal(); } { @@ -342,7 +399,7 @@ int main() { for(bool save_ok:{false,true}) { MyMesh m;m._radio_available=false;m.storage_accepts=save_ok; assert(m.applyAndSaveRxBoostedGain(true)==save_ok);++checks; - assert(m.saves==1 && radio_driver.gain_calls==0 && !m.saved_radio_apply_pending); + assert(m.saves==1 && radio_driver.gain_calls==0 && m.saved_radio_apply_pending==save_ok); assert(m._prefs.rx_boosted_gain==save_ok && m.durable.rx_boosted_gain==save_ok); } for(bool initial:{false,true})for(bool save_ok:{false,true}) { @@ -399,12 +456,14 @@ class CompanionPrefsTransactionTests(unittest.TestCase): 'bool MyMesh::applyAndSaveRxPowerSaving(', 'bool MyMesh::applyAndSaveRxBoostedGain(', 'bool MyMesh::applyAndSavePowerSaving(', + 'void MyMesh::queueSavedRadioApply(', + 'void MyMesh::servicePendingLocalRadioSettings(', + 'MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams(', )), '@FRAMES@': ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {cmd})') for cmd in frames), '@WEB@': ' else '.join(extract_braced(text,f'if ({expr})') for expr in web), '@TERMINAL@': ' else '.join(extract_braced(text,f'if ({expr})') for expr in terminal), '@GPS@': extract_braced(text,'if (gps_value)'), - '@RECOVERY@': extract_braced(text,'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'), } harness = HARNESS for marker,value in replacements.items(): diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 163c752ca..c431e77cd 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -16,6 +16,7 @@ ROOT = Path(__file__).resolve().parents[1] HARNESS = r''' #include +#include #include #include #include @@ -78,6 +79,21 @@ template bool handleMorseCommand(Args&&...) { return false; } } @COMMAND_FILTER@ struct MyMesh : CompanionFleetFixture { + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_blocked=false,_primary_radio_reply_admission=false; + + enum class RadioSettingResult {Saved,RadioRejected,SaveFailed,SaveUnconfirmed}; + bool _radio_available=true,saved_radio_apply_pending=false; + uint32_t radio_apply_retry_at=0; + uint8_t radio_apply_failures=0; + void queueSavedRadioApply(); + RadioSettingResult applyAndSaveRadioParams(float,float,uint8_t,uint8_t,uint8_t,uint16_t,bool=false); + bool isValidClientRepeatFreq(uint32_t) const {return true;} + mesh::RadioParamApplyResult tryApplyRadioParams(float f,float bw,uint8_t sf,uint8_t cr) { + radio.p.primary.freq=f;radio.p.primary.bw=bw;radio.p.primary.sf=sf;radio.p.primary.cr=cr; + return mesh::RadioParamApplyResult::APPLIED; + } + bool applySavedRadioParams() {return tryApplyRadioParams(_prefs.freq,_prefs.bw,_prefs.sf,_prefs.cr)==mesh::RadioParamApplyResult::APPLIED;} struct NoMorseReply {} _morse_reply; uint8_t self_id = 0; // Signing is unreachable in this radio-only fixture. CompanionNodePrefs _prefs; @@ -274,7 +290,8 @@ def production_primary_radio_harness(): replacements = { '@SAVE_PREFS@': extract_braced(header, 'bool savePrefs()'), '@COMMAND_FILTER@': extract_braced(companion, 'static bool isCompanionRadioPrefsCommand('), - '@DISPATCH@': dispatch, + '@DISPATCH@': extract_braced(companion, 'void MyMesh::queueSavedRadioApply(') + '\n' + + extract_braced(companion, 'MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams(') + '\n' + dispatch, '@COMPANION_SANITIZE@': boot_checks(companion, '_prefs.airtime_factor'), '@INFRA_SANITIZE@': boot_checks(infra, '_prefs->airtime_factor'), '@INFRA_SETTER@': extract_braced(infra, 'if (memcmp(config, "dutycycle ", 10) == 0)'), diff --git a/test/test_companion_radio_diagnostics.py b/test/test_companion_radio_diagnostics.py index 7c0488022..cf3733840 100644 --- a/test/test_companion_radio_diagnostics.py +++ b/test/test_companion_radio_diagnostics.py @@ -18,6 +18,7 @@ class CompanionRadioDiagnosticsTests(unittest.TestCase): methods='\n'.join(body(wrapper,signature) for signature in ( 'bool supportsRxPowerSavingRfRxDisable() const override', 'bool setRxPowerSavingRfRxDisabled(bool disabled) override', + 'mesh::RadioParamApplyResult trySetRxPowerSavingRfRxDisabled(bool disabled) override', 'bool isRxPowerSavingRfRxDisabled() const override')) admission='\n'.join(body(radio,signature) for signature in ( 'uint8_t RadioLibWrapper::beginReconfigure()', diff --git a/test/test_companion_radio_gain_restore.py b/test/test_companion_radio_gain_restore.py index 9a09d140e..c2c6418b2 100644 --- a/test/test_companion_radio_gain_restore.py +++ b/test/test_companion_radio_gain_restore.py @@ -26,7 +26,7 @@ template T nextResult(std::deque& results,T fallback) { } struct Prefs { float freq=915,bw=250; - uint8_t sf=7,cr=5,rx_boosted_gain=0; + uint8_t sf=7,cr=5,rx_boosted_gain=0,rx_ps_level=0,rx_ps_preamble=0; bool radio_fem_rxgain=false,radio_fem_txgain=false,cad_enabled=false; bool rx_powersaving_enabled=false; uint32_t rx_ps_rx_us=1000,rx_ps_sleep_us=2000; @@ -42,9 +42,16 @@ struct Board { } board; struct Driver { bool gain=false,supported=true,hardware_present=true; + bool rxps_supported=false,rxps_enabled=false; int power=0; unsigned gain_calls=0,power_calls=0; std::deque gain_results,power_results; + Apply trySetRxBoostedGainMode(bool enabled){return setRxBoostedGainMode(enabled)?Apply::APPLIED:Apply::FAILED;} + Apply trySetTxPower(int8_t power){return setTxPower(power)?Apply::APPLIED:Apply::FAILED;} + Apply trySetRxPowerSaving(bool enabled,uint32_t,uint32_t){ + assert(hardware_present);rxps_enabled=enabled;return Apply::APPLIED; + } + Apply trySetRxPowerSavingRfRxDisabled(bool){return Apply::APPLIED;} bool supportsRxBoostedGainMode(){return supported;} bool setRxBoostedGainMode(bool value){ assert(hardware_present); @@ -61,18 +68,26 @@ struct Driver { if(ok)power=value; return ok; } - bool supportsRxPowerSaving(){return false;} + bool supportsRxPowerSaving(){return rxps_supported;} bool setRxPowerSaving(bool,uint32_t,uint32_t){assert(hardware_present);return true;} void setCADScanTimeoutMillis(int){} } radio_driver; +bool recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*) {return true;} struct Radio { + struct {struct {uint8_t sf=7;float bw=250;} primary;} p; + auto* profiles() {return &p;} + bool isCarrierWaveActive() const{return false;} + bool isMorseTxActive() const{return false;} + bool isMorseRxActive() const{return false;} void setCADEnabled(bool){} void recalibrateNoiseFloor(){} } radio; struct Clock { uint32_t getMillis(){return now_ms;} } clock_source; @RETRY_DELAY@ struct MyMesh { + int8_t rf_rx_disable_pending=-1;uint32_t rf_rx_retry_at=0; Prefs _prefs; + bool _primary_radio_blocked=false; Radio* _radio=&radio; Clock* _ms=&clock_source; bool _radio_available=true,saved_radio_apply_pending=false; @@ -97,13 +112,17 @@ struct MyMesh { return result; } bool savePrefs(){++saves;return false;} + struct {void servicePendingTuning(){}} _radio_profiles; + void servicePendingTxPower(){} + void queueSavedRadioApply(); + void servicePendingLocalRadioSettings(); void configureRadioFromPrefs(); bool applySavedRadioParams(); bool applyAndSaveRxBoostedGain(bool); #if COMPANION_FEATURE_TEMP_RADIO void serviceTempRadio(); #endif - void recover(){@RECOVERY@} + void recover(){servicePendingLocalRadioSettings();} }; @METHODS@ @@ -168,6 +187,20 @@ static void checkStartupAndRetry(){ m.saved_radio_apply_pending=true;m.recover(); assert(!m.saved_radio_apply_pending && !radio_driver.gain_calls); } + { + // Accepting RXPS off while unavailable must survive both configuration + // passes. A tuple with no RXPS timings does not disable the cached mode. + MyMesh m;m.saved_radio_apply_pending=true;m._radio_available=false; + radio_driver.hardware_present=false; + radio_driver.rxps_supported=true;radio_driver.rxps_enabled=true; + m.configureRadioFromPrefs(); + assert(m.saved_radio_apply_pending && radio_driver.rxps_enabled); + m._radio_available=true;radio_driver.hardware_present=true; + m.configureRadioFromPrefs(); + assert(m.saved_radio_apply_pending && radio_driver.rxps_enabled); + m.recover(); + assert(!m.saved_radio_apply_pending && !radio_driver.rxps_enabled && !m.saves); + } } #if COMPANION_FEATURE_TEMP_RADIO @@ -211,6 +244,8 @@ static void checkTempRadioRestoration(){ assert(radio_driver.gain==bool(m._prefs.rx_boosted_gain)); assert(!m._temp_radio_applied && !m._temp_radio_revert_at); assert(!m._temp_radio_retry_at && !m._temp_radio_failures); + assert(m.saved_radio_apply_pending==rollback_pending); + m.recover(); assert(!m.saved_radio_apply_pending && m.live_freq==m._prefs.freq); assert(m.saves==unsigned(rollback_pending)); // Restoring never saves settings. } @@ -218,9 +253,22 @@ static void checkTempRadioRestoration(){ MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms; m.saved_radio_apply_pending=true;radio_driver.supported=false; radio_driver.gain_results={false};m.serviceTempRadio(); - assert(!m._temp_radio_revert_at && !m.saved_radio_apply_pending); + assert(!m._temp_radio_revert_at && m.saved_radio_apply_pending); + m.recover();assert(!m.saved_radio_apply_pending); assert(!radio_driver.gain_calls); } + { + // A pending RXPS-off setting is not fulfilled by restoring only the + // TempRadio tuple, power and gain. The durable service still owns it. + MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms; + m.saved_radio_apply_pending=true; + radio_driver.rxps_supported=true;radio_driver.rxps_enabled=true; + m.serviceTempRadio(); + assert(!m._temp_radio_applied && m.saved_radio_apply_pending); + assert(radio_driver.rxps_enabled); + m.recover(); + assert(!m.saved_radio_apply_pending && !radio_driver.rxps_enabled && !m.saves); + } { MyMesh m;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms; m.outbound=true;m.serviceTempRadio(); @@ -247,6 +295,8 @@ class CompanionRadioGainRestoreTests(unittest.TestCase): 'void MyMesh::configureRadioFromPrefs(', 'bool MyMesh::applySavedRadioParams(', 'bool MyMesh::applyAndSaveRxBoostedGain(', + 'void MyMesh::queueSavedRadioApply(', + 'void MyMesh::servicePendingLocalRadioSettings(', )) methods += '\n#if COMPANION_FEATURE_TEMP_RADIO\n' methods += extract_braced(text, 'void MyMesh::serviceTempRadio(') @@ -254,7 +304,6 @@ class CompanionRadioGainRestoreTests(unittest.TestCase): replacements = { '@METHODS@': methods, '@RETRY_DELAY@': extract_braced(text, 'static uint32_t nextRadioApplyRetryDelay('), - '@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'), } harness = HARNESS for marker, value in replacements.items(): diff --git a/test/test_companion_radio_settings_transaction.py b/test/test_companion_radio_settings_transaction.py index 80cec7a7e..aa0cc2690 100644 --- a/test/test_companion_radio_settings_transaction.py +++ b/test/test_companion_radio_settings_transaction.py @@ -21,8 +21,11 @@ HARNESS = r''' #include #include #include +#include +#include #define MESH_DEBUG_PRINTLN(...) ((void)0) #define MAX_LORA_TX_POWER 22 +#define PUB_KEY_SIZE 32 #define COMPANION_FEATURE_TEMP_RADIO 1 #define COMMAND_RADIO_APPLY_TIMEOUT_MS 3000 @CODES@ @@ -30,9 +33,12 @@ static bool tx_inflight=false; static std::vector completion_order; namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; -struct Packet {}; +struct Packet {bool radio_reply=false;}; struct Mesh { void onSendComplete(Packet*) { completion_order.push_back(1); } + void onSendFail(Packet*) {} + void onRadioProfileCopyQueued(Packet*,const Packet*,uint8_t) {} + bool allowDirectRetry(const Packet*,const uint8_t*,uint8_t) const {return true;} }; } using Apply = mesh::RadioParamApplyResult; @@ -53,18 +59,40 @@ struct Prefs { int8_t tx_power_dbm=3; bool rx_boosted_gain=false, rx_powersaving_enabled=false; }; -void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t, +bool recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t, uint32_t* rx,uint32_t* sleep) { - *rx=sf*100; *sleep=uint32_t(bw*100); + *rx=sf*100; *sleep=uint32_t(bw*100);return true; } struct Radio { bool carrier=false, morse_tx=false, morse_rx=false; bool isCarrierWaveActive() const { return carrier; } bool isMorseTxActive() const { return morse_tx; } bool isMorseRxActive() const { return morse_rx; } + struct {struct {uint8_t sf=7;float bw=250;} primary;} p; + auto* profiles() {return &p;} } radio; +struct ContactInfo { + struct {uint8_t pub_key[32]={};} id; + bool isRemoteCLIAllowed() const {return true;} +}; +namespace mesh { +struct RadioProfileCLI { + static bool parseSuffix(const char* input,unsigned fields,char* out,size_t size,uint16_t& preamble) { + return cli::parseRadioPreambleSuffix(input,fields,out,size,preamble); + } +}; +} +#define TXT_TYPE_CLI_COMMAND 1 struct Driver { - bool partial_failure=false, power_verified=true; + bool partial_failure=false, power_verified=true, rf_supported=true, rf_disabled=false; + std::deque rf_results; + bool supportsRxPowerSavingRfRxDisable() const {return rf_supported;} + Apply trySetRxPowerSavingRfRxDisabled(bool disabled) { + const auto result=rf_results.empty()?Apply::APPLIED:rf_results.front(); + if(!rf_results.empty())rf_results.pop_front(); + if(result==Apply::APPLIED)rf_disabled=disabled; + return result; + } int8_t power=3, invalid_power=127; unsigned calls=0; std::deque accepted; @@ -81,14 +109,52 @@ struct Driver { return result; } bool setTxPower(int8_t requested) { return trySetTxPower(requested)==Apply::APPLIED; } + Apply trySetRxBoostedGainMode(bool) { return Apply::APPLIED; } + bool rxps_supported=false; + uint32_t rx_us=0,sleep_us=0; + Apply trySetRxPowerSaving(bool,uint32_t rx,uint32_t sleep) { + rx_us=rx;sleep_us=sleep;return Apply::APPLIED; + } bool supportsRxBoostedGainMode() const { return false; } bool setRxBoostedGainMode(bool) { return true; } - bool supportsRxPowerSaving() const { return false; } + bool supportsRxPowerSaving() const { return rxps_supported; } bool setRxPowerSaving(bool,uint32_t,uint32_t) { return true; } } radio_driver; @RETRY_DELAY@ +struct CommonRadioPrefs {static bool isValidBandwidth(float){return true;}}; struct MyMesh : mesh::Mesh { + int8_t rf_rx_disable_pending=-1;uint32_t rf_rx_retry_at=0; + struct Profiles { + uint16_t preamble=0,persisted_preamble=0; + bool storage_ok=true,dirty=false; + std::deque save_results; + bool acceptsPrimary(float f,float bw,uint8_t sf,uint8_t cr,uint16_t) const { + return f>=150 && f<=2500 && bw>=7 && bw<=500 && sf>=5 && sf<=12 && cr>=5 && cr<=8; + } + uint16_t primaryPreamble() const { return preamble; } + bool savePrimaryPreamble(uint16_t next) { + bool ok=storage_ok; + if(!save_results.empty()){ok=save_results.front();save_results.pop_front();} + if(!ok)return false; + preamble=persisted_preamble=next;dirty=false;return true; + } + bool restorePrimaryPreamble(uint16_t previous) { + if(savePrimaryPreamble(previous))return true; + preamble=previous;dirty=true;return false; + } + void servicePendingTuning() {if(dirty)savePrimaryPreamble(preamble);} + bool hasPendingTuning() const {return dirty;} + void appendPrimaryChirpWarning(char*,size_t,uint8_t,float,uint16_t) {} + } _radio_profiles; + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_blocked=false,_primary_radio_reply_admission=false; Prefs _prefs, durable, live; + uint16_t live_preamble=0; + float _temp_radio_freq=916,_temp_radio_bw=125; + uint8_t _temp_radio_sf=8,_temp_radio_cr=6; + uint16_t _temp_radio_preamble=16; + uint32_t _temp_radio_retry_at=0; + uint8_t _temp_radio_failures=0; BaseSerialInterface serial, original, other; BaseSerialInterface* _serial=&serial; Clock* _ms=&clock_source; @@ -110,7 +176,18 @@ struct MyMesh : mesh::Mesh { std::vector replies; struct { void complete(mesh::Packet*) { completion_order.push_back(3); } + void fail(mesh::Packet*) {} + void copy(const mesh::Packet*,const mesh::Packet*) {} + bool ownsReply(const mesh::Packet*) const {return false;} + uint32_t generation() const {return 0;} + void beginCommand(const uint8_t*) {} + void endCommand() {} + bool pending() const {return false;} + const uint8_t* actor() const {static uint8_t key[32]={};return key;} + void prepareReply(mesh::Packet*) {} + void armReply(mesh::Packet*,bool) {} } _morse_reply; + bool _morse_reply_admission=false; @RESULT_ENUM@; MyMesh() { serial.current=&original;radio_driver=Driver();radio=Radio();now_ms=100; @@ -124,23 +201,45 @@ struct MyMesh : mesh::Mesh { bool savePrefs() { ++saves; if(!storage_accepts)return false; durable=_prefs; return true; } void writeOKFrame(BaseSerialInterface* route=nullptr) { replies.push_back({0,route?route:serial.current}); } void writeErrFrame(uint8_t code,BaseSerialInterface* route=nullptr) { replies.push_back({code,route?route:serial.current}); } - Apply tryApplyRadioParams(float freq,float bw,uint8_t sf,uint8_t cr) { + Apply tryApplyRadioParams(float freq,float bw,uint8_t sf,uint8_t cr,bool=false,uint16_t=0) { ++applies; Apply result=Apply::APPLIED; if(!accepted.empty()) { result=accepted.front(); accepted.pop_front(); } - if(result==Apply::APPLIED) { live.freq=freq;live.bw=bw;live.sf=sf;live.cr=cr; } + if(result==Apply::APPLIED) { + live.freq=freq;live.bw=bw;live.sf=sf;live.cr=cr; + radio.p.primary.sf=sf;radio.p.primary.bw=bw;live_preamble=_radio_profiles.preamble; + } return result; } bool applySavedRadioParams(); RadioSettingResult applyAndSaveTxPower(int8_t); void servicePendingTxPower(); void onSendComplete(mesh::Packet*); - void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr); - void deferRadioCommand(); - void servicePendingRadioParamApply(); - void cancelPendingRadioParamApply(); + void onSendFail(mesh::Packet*); + void onRadioProfileCopyQueued(mesh::Packet*,const mesh::Packet*,uint8_t); + bool allowDirectRetry(const mesh::Packet*,const uint8_t*,uint8_t) const; + void finishPrimaryRadioReply(bool); + void onCLICommandRecv(const ContactInfo&,mesh::Packet*,uint32_t,const char*,char*); + void onCLIReplyPrepared(const ContactInfo&,mesh::Packet*); + void onCLIReplyQueued(const ContactInfo&,mesh::Packet*,bool); + void markConnectionActive(const ContactInfo&) {} + template void queueMessage(T&&...) {} + bool handleCommand(const char* command,uint32_t sender_timestamp,char* reply) { + const size_t reply_capacity=160; + @RADIO_CLI_BRANCH@ + @FREQ_CLI_BRANCH@ + return false; + } + RadioSettingResult applyAndSaveRadioParams(float,float,uint8_t,uint8_t,uint8_t,uint16_t,bool=false); + bool scheduleTempRadio(float,float,uint8_t,uint8_t,uint32_t,char*,size_t); + void scheduleNormalRadio(char*,size_t); + void appendRxPowerSavingAdjustmentNote(char*,size_t,uint8_t,float) const {} + void serviceTempRadio(); + void queueSavedRadioApply(); + void servicePendingLocalRadioSettings(); + bool applyRxPowerSavingRfRxDisabled(const char*,char*,size_t); void frame(size_t len) { @FRAME_BRANCHES@ } - void recover() { @RECOVERY@ } + void recover() { servicePendingLocalRadioSettings(); } bool command(const char* command,char* reply) { const size_t reply_capacity=160; #if defined(MESH_HIL_TX_FREQ_MHZ) @@ -200,6 +299,23 @@ int main() { assert(!m.command("get tx.status",reply)); } #endif + // Runtime RXEN admission is automatic but never writes persistent prefs. + { + MyMesh m;char reply[160]={};radio_driver.rf_results={Apply::BUSY,Apply::BUSY,Apply::APPLIED}; + assert(m.applyRxPowerSavingRfRxDisabled("on",reply,sizeof(reply)) && !strcmp(reply,"OK")); + assert(m.rf_rx_disable_pending==1 && !radio_driver.rf_disabled && !m.saves); + assert(m.applyRxPowerSavingRfRxDisabled("off",reply,sizeof(reply)) && !strcmp(reply,"OK")); + assert(m.rf_rx_disable_pending==0); + m.servicePendingLocalRadioSettings(); + assert(m.rf_rx_disable_pending<0 && !radio_driver.rf_disabled && !m.saves); + for(const char* invalid:{"", "ON", "off extra", "2"}) { + assert(m.applyRxPowerSavingRfRxDisabled(invalid,reply,sizeof(reply)) && strstr(reply,"Error")); + assert(m.rf_rx_disable_pending<0 && !m.saves); + } + radio_driver.rf_supported=false; + assert(m.applyRxPowerSavingRfRxDisabled("on",reply,sizeof(reply)) && strstr(reply,"unsupported")); + assert(m.rf_rx_disable_pending<0 && !m.saves); + } // Invalid input never reaches hardware or storage. for (int value : {-10,23}) { MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG); @@ -213,14 +329,14 @@ int main() { // a pending tuple prevents the ordinary hardware setter from being called. for(bool tuple_pending : {false,true}) { MyMesh m;radio_driver.accepted={Apply::BUSY};m.powerFrame(17); - m.replies.clear();m.command_radio_apply_pending=tuple_pending; + m.replies.clear();m.saved_radio_apply_pending=tuple_pending; m.tx_power_apply_retry_at=now_ms+1000;radio_driver.invalid_power=18; const unsigned calls=radio_driver.calls; m.powerFrame(18);m.expectError(ERR_CODE_BAD_STATE); assert(m.tx_power_apply_pending && m.tx_power_apply_retry_at==now_ms+1000); assert(m._prefs.tx_power_dbm==17 && m.durable.tx_power_dbm==17); assert(m.saves==1 && radio_driver.calls==calls); - assert(m.command_radio_apply_pending==tuple_pending); + assert(m.saved_radio_apply_pending==tuple_pending); } // Immediate application still commits before acknowledging the request. { @@ -376,20 +492,16 @@ int main() { radio=Radio();m._temp_radio_applied=true;m.servicePendingTxPower(); assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.applies==0); } - // A pending tuple retains its original route/deadline, while power is saved - // independently. Completing the tuple cannot overwrite newer power intent. + // A tuple and power setting accepted during RX share durable latest intent. { - MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame(); - assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0); - m.serial.current=&m.other;m.powerFrame(17);m.expectError(0,&m.other); - assert(m.command_radio_apply_pending && m.tx_power_apply_pending && radio_driver.calls==0); - assert(m.durable.freq==915 && m.durable.tx_power_dbm==17); - m.replies.clear();m.servicePendingTxPower();assert(radio_driver.calls==0); - m.servicePendingRadioParamApply();m.expectError(0); - assert(!m.command_radio_apply_pending && m.tx_power_apply_pending); - assert(m.durable.freq==920 && m.durable.tx_power_dbm==17); - m.servicePendingTxPower(); - assert(!m.tx_power_apply_pending && radio_driver.power==17 && m.saves==2); + MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame();m.expectError(0); + assert(m.saved_radio_apply_pending && m.saves==1 && m.durable.freq==920); + m.replies.clear();m.serial.current=&m.other;radio_driver.accepted={Apply::BUSY}; + m.powerFrame(17);m.expectError(0,&m.other); + assert(m.tx_power_apply_pending && m.durable.tx_power_dbm==17); + m.servicePendingLocalRadioSettings(); + assert(!m.saved_radio_apply_pending && !m.tx_power_apply_pending); + assert(m.live.freq==920 && radio_driver.power==17 && m.saves==2); } // Frontends retain strict validation and report accepted pending intent. { @@ -399,7 +511,7 @@ int main() { assert(m.saves==1 && m.durable.tx_power_dbm==17); m.servicePendingTxPower();assert(!m.tx_power_apply_pending && radio_driver.power==17); } - // Both immediate and deferred tuple changes still commit before OK. + // Immediate tuple changes commit before OK; failed saves restore hardware. { MyMesh m;m.tupleFrame();m.expectError(0); assert(m._prefs.freq==920 && m.durable.freq==920 && m.live.freq==920); @@ -410,27 +522,174 @@ int main() { MyMesh m;m.storage_accepts=false;m.tupleFrame();m.expectError(ERR_CODE_FILE_IO_ERROR); m.expectOriginalPrefs();assert(m.live.freq==915 && !m.saved_radio_apply_pending); } + // BUSY tuple acceptance survives disconnect and more than 30 seconds. { - MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame(); - assert(m.replies.empty() && m.command_radio_apply_pending && m.saves==0); - m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(0); - assert(!m.command_radio_apply_pending && m.durable.freq==920); + MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame();m.expectError(0); + assert(m.saved_radio_apply_pending && m.saves==1 && m.durable.freq==920); + m.serial.disconnected=&m.original; + for(uint32_t elapsed=0;elapsed<=31000;elapsed+=1000) { + now_ms=100+elapsed;m.accepted={Apply::BUSY};m.servicePendingLocalRadioSettings(); + assert(m.saved_radio_apply_pending && m.saves==1 && m.replies.size()==1); + } + m.accepted={Apply::APPLIED};m.servicePendingLocalRadioSettings(); + assert(!m.saved_radio_apply_pending && m.live.freq==920 && m.saves==1); + } + // A newer accepted tuple replaces the desired target. A failed save does not. + { + MyMesh m;m.accepted={Apply::BUSY,Apply::BUSY,Apply::BUSY,Apply::APPLIED}; + m.tupleFrame();m.replies.clear(); + assert(m.applyAndSaveRadioParams(921,62.5f,8,5,0,32)==MyMesh::RadioSettingResult::Saved); + assert(m.durable.freq==921 && m._radio_profiles.preamble==32); + m.storage_accepts=false; + assert(m.applyAndSaveRadioParams(922,125,7,5,0,16)==MyMesh::RadioSettingResult::SaveFailed); + assert(m.durable.freq==921 && m._prefs.freq==921 && m._radio_profiles.preamble==32); + m.servicePendingLocalRadioSettings(); + assert(!m.saved_radio_apply_pending && m.live.freq==921 && m.saves==3); + } + // Rollback failure queues the old durable tuple, not the rejected candidate. + { + MyMesh m;m.storage_accepts=false;m.accepted={Apply::APPLIED,Apply::BUSY,Apply::APPLIED}; + m.tupleFrame();m.expectError(ERR_CODE_FILE_IO_ERROR);m.expectOriginalPrefs(); + assert(m.saved_radio_apply_pending && m.live.freq==920); + m.servicePendingLocalRadioSettings(); + assert(!m.saved_radio_apply_pending && m.live.freq==915 && m.saves==1); + } + // A temporary lease retains the modem until it ends; no resend is needed. + { + MyMesh m;m._temp_radio_applied=true;m.tupleFrame();m.expectError(0); + assert(m.saved_radio_apply_pending && m.applies==0 && m.durable.freq==920); + m.servicePendingLocalRadioSettings();assert(m.applies==0); + m._temp_radio_applied=false;m.servicePendingLocalRadioSettings(); + assert(!m.saved_radio_apply_pending && m.live.freq==920 && m.saves==1); + } + // Saved full settings apply in the completion gap despite queued TX work. + { + MyMesh m;mesh::Packet packet;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame(); + m.outbound=true;m.onSendComplete(&packet); + assert(!m.saved_radio_apply_pending && m.live.freq==920 && m.saves==1); + } + + // Both stores can fail in succession. Compensation still restores the old + // runtime preamble, reports uncertainty, and repairs storage automatically. + { + MyMesh m;m._radio_profiles.preamble=m._radio_profiles.persisted_preamble=48; + m._radio_profiles.save_results={true,false};m.storage_accepts=false; + char reply[160]={}; + assert(m.handleCommand("set radio 920,125,9,6,32",0,reply)); + assert(!strcmp(reply,"Error: radio save unconfirmed")); + assert(m._prefs.freq==915 && m._radio_profiles.preamble==48); + assert(m._radio_profiles.persisted_preamble==32 && m._radio_profiles.dirty); + assert(m.live.freq==915 && m.live_preamble==48 && m.saved_radio_apply_pending); + m.recover(); + assert(!m._radio_profiles.dirty && m._radio_profiles.persisted_preamble==48); + assert(!m.saved_radio_apply_pending && m.saves==1); + } + // A remote change shares durable acceptance with local frontends, but its + // exact two reply copies must drain before the primary can move. RXPS still + // follows the old active SF/BW until that handoff finishes. + { + MyMesh m;ContactInfo from;mesh::Packet a,b,unrelated;char reply[160]={}; + m.saved_radio_apply_pending=true;m._prefs.rx_powersaving_enabled=true; + radio_driver.rxps_supported=true; + m.onCLICommandRecv(from,nullptr,10,"set radio 920,125,9,6",reply); + assert(!strcmp(reply,"OK") && m.saves==1 && m._primary_radio_blocked); + m.onCLIReplyPrepared(from,&a);m.onRadioProfileCopyQueued(&b,&a,0); + m.onCLIReplyQueued(from,&a,true); + assert(a.radio_reply && !m.allowDirectRetry(&a,nullptr,0)); + m.recover(); + assert(m.applies==0 && !m.saved_radio_apply_pending); + assert(radio_driver.rx_us==700 && radio_driver.sleep_us==25000); + m.onSendComplete(&unrelated);assert(m._primary_radio_blocked && m.applies==0); + m.onSendComplete(&a);assert(m._primary_radio_blocked && m.applies==0); + m.onSendFail(&b);assert(!m._primary_radio_blocked); + m.recover(); + assert(m.live.freq==920 && m.applies==1 && m.saves==1); + assert(radio_driver.rx_us==900 && radio_driver.sleep_us==12500); + } + // Every outcome pair uses the actual completion/failure hooks: one + // successful copy is enough only after both exact copies have drained. + for(unsigned successes=0;successes<4;++successes) { + MyMesh m;ContactInfo from;mesh::Packet a,b;char reply[160]={}; + m.onCLICommandRecv(from,nullptr,10,"set freq 920",reply); + m.onCLIReplyPrepared(from,&a);m.onRadioProfileCopyQueued(&b,&a,0); + m.onCLIReplyQueued(from,&a,true); + if(successes&1)m.onSendComplete(&a);else m.onSendFail(&a); + assert(m._primary_radio_blocked && m.applies==0); + if(successes&2)m.onSendComplete(&b);else m.onSendFail(&b); + m.recover(); + assert(m._primary_radio_blocked==(successes==0)); + assert(m.live.freq==(successes?920:915) && m.saves==1); + } + // Losing every response retains the old runtime channel. A duplicate of + // the saved request replays acknowledgement ownership without another save; + // an old or unrelated completion cannot release the new response barrier. + { + MyMesh m;ContactInfo from;mesh::Packet a,b,replay,unrelated;char reply[160]={}; + m.onCLICommandRecv(from,nullptr,10,"set freq 920",reply); + m.onCLIReplyPrepared(from,&a);m.onRadioProfileCopyQueued(&b,&a,0); + m.onCLIReplyQueued(from,&a,true);m.onSendFail(&a);m.onSendFail(&b); + now_ms+=31000;m.recover(); + assert(m._primary_radio_blocked && m.live.freq==915 && m.applies==0 && m.saves==1); + m.onCLICommandRecv(from,nullptr,11,"set freq 920",reply); + assert(!strcmp(reply,"OK") && m.saves==1); + m.onCLIReplyPrepared(from,&replay);m.onCLIReplyQueued(from,&replay,true); + m.onSendComplete(&a);m.onSendComplete(&unrelated); + assert(m._primary_radio_blocked && m.applies==0); + m.onSendComplete(&replay); + assert(!m._primary_radio_blocked && m.live.freq==920 && m.saves==1); + } + // A failed newer save cannot replace the older exact reply. A successful + // newer remote request does replace it; late older completions are inert. + for(bool save_new:{false,true}) { + MyMesh m;ContactInfo from;mesh::Packet old,newer;char reply[160]={}; + m.onCLICommandRecv(from,nullptr,10,"set freq 920",reply); + m.onCLIReplyPrepared(from,&old);m.onCLIReplyQueued(from,&old,true); + m.storage_accepts=save_new; + m.onCLICommandRecv(from,nullptr,11,"set freq 921",reply); + assert((!strncmp(reply,"OK",2))==save_new); + m.onCLIReplyPrepared(from,&newer);m.onCLIReplyQueued(from,&newer,true); + m.onSendComplete(&old); + if(save_new) { + assert(m._primary_radio_blocked && m.applies==0); + m.onSendComplete(&newer); + assert(!m._primary_radio_blocked && m.live.freq==921); + } else assert(!m._primary_radio_blocked && m.live.freq==920); + assert(m.saves==2); + } + // No packet allocation is not evidence of delivery. A later local primary + // setting can deliberately replace that blocked remote intent. + { + MyMesh m;ContactInfo from;mesh::Packet unrelated;char reply[160]={}; + m.onCLICommandRecv(from,nullptr,10,"set freq 920",reply); + m.onCLIReplyQueued(from,nullptr,false);m.onSendComplete(&unrelated); + assert(m._primary_radio_blocked && m.applies==0 && m.live.freq==915); + assert(m.handleCommand("set freq 922",0,reply) && !strcmp(reply,"OK")); + assert(!m._primary_radio_blocked && m.saved_radio_apply_pending); + m.recover();assert(m.live.freq==922 && m.saves==2); + } + // TempRadio owns both its active tuple and its future normal restoration. + // A remote permanent command is rejected before saving, and failing its + // error reply cannot strand an active lease when that lease expires. + for(unsigned phase=0;phase<3;++phase) { + MyMesh m;ContactInfo from;mesh::Packet error;char reply[160]={}; + if(phase==0)m._temp_radio_set_at=now_ms+10; + if(phase==1)m._temp_radio_applied=true; + if(phase==2)m._temp_radio_revert_at=now_ms+10; + m.onCLICommandRecv(from,nullptr,10,"set radio 920,125,9,6",reply); + assert(strstr(reply,"Error") && m.saves==0 && !m._primary_radio_blocked); + m.onCLIReplyPrepared(from,&error);m.onCLIReplyQueued(from,&error,true); + m.onSendFail(&error); + m._temp_radio_set_at=0;m._temp_radio_applied=true;m._temp_radio_revert_at=now_ms; + m.live.freq=916;m.serviceTempRadio(); + assert(!m._temp_radio_applied && !m._temp_radio_revert_at && m.live.freq==915); } { - MyMesh m;m.storage_accepts=false;m.accepted={Apply::BUSY,Apply::APPLIED,Apply::BUSY}; - m.tupleFrame();assert(m.replies.empty() && m.command_radio_apply_pending); - m.serial.current=&m.other;m.servicePendingRadioParamApply(); - m.expectError(ERR_CODE_FILE_IO_ERROR);m.expectOriginalPrefs(); - assert(m.saved_radio_apply_pending && m.live.freq==920 && !m.command_radio_apply_pending); - m.recover();assert(!m.saved_radio_apply_pending && m.live.freq==915); - assert(m.saves==1 && m.replies.size()==1); - } - // Disconnect still cancels an unsaved tuple. - { - MyMesh m;m.accepted={Apply::BUSY};m.tupleFrame(); - m.serial.disconnected=&m.original;m.servicePendingRadioParamApply(); - assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0); - m.expectOriginalPrefs(); + MyMesh m;ContactInfo from;char reply[160]={}; + m.onCLICommandRecv(from,nullptr,10,"set freq 920",reply); + assert(!m.scheduleTempRadio(916,125,8,6,1,reply,sizeof(reply))); + assert(strstr(reply,"acknowledgement") && !m._temp_radio_set_at); + m.scheduleNormalRadio(reply,sizeof(reply)); + assert(strstr(reply,"acknowledgement") && !m._temp_radio_revert_at); } puts("Companion automatic TX-power commit and recovery regressions passed"); } @@ -459,12 +718,14 @@ struct ContactStore { @CONTACT_RECOVERY_API@ }; struct MyMesh { + struct {bool hasPendingTuning() const{return false;}} _radio_profiles; ContactStore store; ContactStore* _store=&store; bool _radio_available=true, _iter_started=false; bool command_radio_apply_pending=false; bool saved_radio_apply_pending=false, _terminal_trace_pending=false; bool tx_power_apply_pending=false; + int8_t rf_rx_disable_pending=-1;uint32_t rf_rx_retry_at=0; uint32_t tx_power_apply_retry_at=0; uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0; uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0; @@ -494,11 +755,6 @@ struct MyMesh { int main() { MyMesh m; assert(!m.hasPendingWork()); - m.command_radio_apply_pending=true; - assert(m.hasPendingWork()); // Deferred tuples must keep the retry loop awake. - assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled)); - m.command_radio_apply_pending=false; - assert(!m.hasPendingWork()); m.tx_power_apply_pending=true; assert(m.hasPendingWork()); // TX-only intent must be serviced automatically. assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled)); @@ -520,7 +776,7 @@ int main() { m.radio_apply_retry_at=0; assert(m.hasPendingWork()); m.radio_apply_retry_at=now_ms+10; - assert(!m.hasPendingWork()); + assert(m.hasPendingWork()); // Retry backoff must not strand accepted intent. now_ms+=10; assert(m.hasPendingWork()); m.radio_apply_retry_at=0; @@ -598,12 +854,22 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase): methods = '\n'.join(extract_braced(text, signature) for signature in ( 'bool MyMesh::applySavedRadioParams(', 'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(', - 'void MyMesh::finishRadioParamApply(', - 'void MyMesh::deferRadioCommand(', 'void MyMesh::servicePendingTxPower(', + 'void MyMesh::queueSavedRadioApply(', + 'bool MyMesh::applyRxPowerSavingRfRxDisabled(', + 'MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams(', + 'void MyMesh::servicePendingLocalRadioSettings(', 'void MyMesh::onSendComplete(', - 'void MyMesh::cancelPendingRadioParamApply(', - 'void MyMesh::servicePendingRadioParamApply(', + 'void MyMesh::onSendFail(', + 'void MyMesh::onRadioProfileCopyQueued(', + 'bool MyMesh::allowDirectRetry(', + 'void MyMesh::finishPrimaryRadioReply(', + 'void MyMesh::onCLICommandRecv(', + 'void MyMesh::onCLIReplyPrepared(', + 'void MyMesh::onCLIReplyQueued(', + 'bool MyMesh::scheduleTempRadio(', + 'void MyMesh::scheduleNormalRadio(', + 'void MyMesh::serviceTempRadio(', )) frame_branches = ' else '.join(extract_braced(text, f'if (cmd_frame[0] == {command})') for command in ('CMD_SET_RADIO_PARAMS', 'CMD_SET_RADIO_TX_POWER')) @@ -615,7 +881,8 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase): '@FRAME_BRANCHES@': frame_branches, '@TX_STATUS_BRANCH@': extract_braced(text, 'if (strcmp(command, "get tx.status") == 0)'), '@CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)'), - '@RECOVERY@': extract_braced(text, 'if (!command_radio_apply_pending && saved_radio_apply_pending && !hasOutbound()'), + '@RADIO_CLI_BRANCH@': extract_braced(extract_braced(text, 'bool MyMesh::handleCommand('), 'if (!strncmp(command, "set radio ", 10))'), + '@FREQ_CLI_BRANCH@': extract_braced(text, 'if (strncmp(command, "set freq ", 9) == 0)'), } harness = HARNESS for marker, value in replacements.items(): @@ -638,7 +905,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase): self.assertIn('handleCommand(command, 0, local_reply)', terminal) web = extract_braced(text, 'if (strcmp(key, "tx") == 0)') self.assertIn('applyAndSaveTxPower(', web) - self.assertIn('servicePendingTxPower();', extract_braced(text, 'void MyMesh::loop()')) + self.assertIn('servicePendingLocalRadioSettings();', extract_braced(text, 'void MyMesh::loop()')) self.assertIn('RadioSettingResult::SaveFailed', extract_braced(text, 'if (strncmp(command, "set tx ", 7) == 0)')) diff --git a/test/test_companion_rxps_getters.py b/test/test_companion_rxps_getters.py index 712792bb4..81bcc95cd 100644 --- a/test/test_companion_rxps_getters.py +++ b/test/test_companion_rxps_getters.py @@ -258,7 +258,8 @@ int main() { frame = extract_braced(source, "} else if (mesh::companion::isRunCliFrame(") frame = frame.replace("} else if", "if", 1) methods = "\n".join(( - extract_braced(source, "bool MyMesh::applyAndSaveRxPowerSaving("), entry, adapter, + extract_braced(source, "bool MyMesh::applyAndSaveRxPowerSaving("), + extract_braced(source, "void MyMesh::queueSavedRadioApply("), entry, adapter, )) constants = "\n".join(line for line in source.splitlines() if line.startswith(("#define RESP_CODE_CLI_REPLY ", diff --git a/test/test_companion_wifi_network_power.py b/test/test_companion_wifi_network_power.py index ca2af7a9e..33a84fb15 100644 --- a/test/test_companion_wifi_network_power.py +++ b/test/test_companion_wifi_network_power.py @@ -373,10 +373,14 @@ class CompanionWiFiNetworkPowerTests(unittest.TestCase): #include constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6; struct { int status() const { return WL_DISCONNECTED; } } WiFi; +struct { bool ota = false; bool isOTAUpdateRunning() const { return ota; } } board; namespace mesh { struct Log { void println(const char*) {} }; inline Log& usbLoggingPort() { static Log log; return log; } } struct Bridge { int starts = 0; bool running = false; + void loop() {} + bool isStopping() const { return false; } + bool isStartPending() const { return false; } void begin() { ++starts; running = true; mesh::wifi::networkPower().setManaged(true); } bool isRunning() const { return running; } }; @@ -395,6 +399,9 @@ struct MyMesh { int main() { Bridge bridge; MyMesh node; node._mqtt_bridge = &bridge; assert(WiFi.status() == WL_DISCONNECTED); + board.ota = true; + node.serviceMQTT("saved-network", "saved-password"); + assert(bridge.starts == 0); board.ota = false; node._wc_batch_active = true; node.serviceMQTT("saved-network", "saved-password"); assert(bridge.starts == 0); diff --git a/test/test_esp32_full_power_lifecycle.py b/test/test_esp32_full_power_lifecycle.py index 7e6c069f2..76d473c91 100644 --- a/test/test_esp32_full_power_lifecycle.py +++ b/test/test_esp32_full_power_lifecycle.py @@ -131,6 +131,7 @@ struct RoleCLI { ObserverPrefs* getObserverPrefs() { return &prefs; } bool hasActiveUserGpioTimer() const { return false; } bool hasPendingTxPower() const { return false; } + bool hasPendingRadioSettings() const { return false; } }; struct AbstractBridge { bool running = false, stopping = false, allow_flash = true; @@ -152,6 +153,7 @@ struct MQTTNodeInfo { struct MQTTBridge : AbstractBridge { bool network_suspended = false; bool isNetworkSuspended() const { return network_suspended; } + bool isStartPending() const { return false; } template explicit MQTTBridge(Args...) {} void setDeviceID(const char*) {} void setFirmwareVersion(const char*) {} void setBoardModel(const char*) {} void setBuildDate(const char*) {} @@ -197,6 +199,8 @@ struct WebConfigServer { return true; } bool startAutoMode(char* reply) { return startSetupMode(reply); } + bool requestStart(bool ap, char* reply) { return ap ? startSetupMode(reply) : startAutoMode(reply); } + bool isStartPending() const { return false; } void requestStop() { running = false; stopping = true; } bool stopForOTA(char*) { running = stopping = false; WiFi.mode(WIFI_STA); return true; } void tick(uint32_t now) { @@ -393,7 +397,7 @@ class ESP32FullPowerLifecycleTest(unittest.TestCase): "bool startSharedEspNowBridgeIfReady()", "bool setEspNowBridgeState(", "bool setMqttBridgeState(", "bool setBridgeState(", "bool isEspNowBridgeRunning()", "bool isMqttBridgeRunning()", - "bool isMqttBridgeStopping()", + "bool isMqttBridgeStopping()", "bool isMqttBridgeStartPending()", "bool isWebConfigActive() const", "bool isWebConfigStopping() const", "bool hasWirelessNetworkClient() const", "bool stopWebConfigForOTA(")) methods = "\n".join(extract_braced(source, signature) for signature in ( diff --git a/test/test_fleet_radio_schedules.py b/test/test_fleet_radio_schedules.py index 54f97d41b..829c8f0d6 100644 --- a/test/test_fleet_radio_schedules.py +++ b/test/test_fleet_radio_schedules.py @@ -373,7 +373,7 @@ void finalizeHMAC(const uint8_t* key,size_t size,void* out,size_t n){{SHA256Base 'void MyMesh::checkRxInactivityWatchdog()', 'void MyMesh::setTempRadioTiming(', 'void MyMesh::updateAdvertTimer()', 'void MyMesh::updateFloodAdvertTimer()', 'void MyMesh::queueSavedRadioApply()', 'bool MyMesh::applySavedRadioParams()', - 'void MyMesh::refreshScheduledRadioState()', 'void MyMesh::processScheduledRadioSettings()', + 'void MyMesh::refreshScheduledRadioState()', 'void MyMesh::processScheduledRadioSettings(bool tx_gap)', 'void MyMesh::applyTempRadioParams(', 'bool MyMesh::scheduleNormalRadio()', 'void MyMesh::clearScheduledRadioSetting(', 'int MyMesh::findFreeScheduledRadioSlot() const', 'int MyMesh::countScheduledRadioSettings(', 'bool MyMesh::scheduledRadioConflicts(', diff --git a/test/test_fleet_role_wiring.py b/test/test_fleet_role_wiring.py index 1d926b9f0..1c7ef88f0 100644 --- a/test/test_fleet_role_wiring.py +++ b/test/test_fleet_role_wiring.py @@ -65,7 +65,7 @@ struct CachedSensors { ++reads; lat = latitude; lon = longitude; return valid; } }; -struct Profiles {}; +struct Profiles {void servicePendingTuning(){}}; struct Fleet { unsigned received = 0, serviced = 0; bool scoped = false; @@ -88,6 +88,7 @@ struct Fleet { struct CLI { Fleet fleet; void servicePendingTxPower() {} + void servicePendingRxSettings() {} Profiles profiles; struct NoMorseReply { void complete(const void*) {} @@ -144,6 +145,7 @@ class Room : public mesh::Mesh { void onGroupPacketRecv(mesh::Packet*); void serviceFleet(); #if MESH_ENABLE_FLEET_CONTROL + void servicePendingPrimaryRadioSettings(bool) {} void onSendComplete(mesh::Packet*) override; void onSendFail(mesh::Packet*) override; void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t) override; diff --git a/test/test_local_cli_access.py b/test/test_local_cli_access.py index e06151ce0..ddb5ab100 100644 --- a/test/test_local_cli_access.py +++ b/test/test_local_cli_access.py @@ -13,6 +13,7 @@ SANITIZER_FLAGS = [] if os.name == "nt" else ["-fsanitize=address,undefined", "- PREAMBLE = r''' #include +#include #include #include #include @@ -55,6 +56,7 @@ struct RadioProfileCLI { } bool acceptsPrimary(float, float, uint8_t, uint8_t, uint16_t) { return true; } bool savePrimaryPreamble(uint16_t) { return true; } + bool restorePrimaryPreamble(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 {} @@ -91,7 +93,8 @@ struct Store { bool formatFileSystem(){++erased;return success;} }; struct Prefs { - uint8_t lost_reply=0; + uint8_t lost_reply=0, client_repeat=0; + bool rx_powersaving_enabled=false; float tx_delay_factor=0.5f, direct_tx_delay_factor=0.2f; uint8_t interference_threshold=0, agc_reset_interval=0; float tz_offset=0; @@ -101,7 +104,7 @@ struct Prefs { int8_t tx_power_dbm=10; char node_name[32]="test"; Prefs* getRadioPrefs(){return this;} - bool handleCommand(const char*,uint32_t,char*){return false;} + bool handleCommand(const char*,uint32_t,char*,bool=true){return false;} bool isDirty() const {return false;} void clearDirty(){} }; @@ -116,7 +119,7 @@ struct Board { bool supportsBatteryChargeTarget(uint16_t mv){return mv==4100 || mv==4200;} bool setBatteryChargeTarget(uint16_t mv){++charge_writes;charge_target=mv;return true;} bool rebootToUf2Bootloader(){return false;} - bool handleCommand(const char*,uint32_t,char*){return false;} + bool handleCommand(const char*,uint32_t,char*,bool=true){return false;} void powerOff(){++power_offs;} } board; struct Radio { @@ -134,7 +137,7 @@ struct Radio { } radio_driver; struct AdvertDataParser {static bool isValidName(const char*){return true;}}; struct StrHelper {static void strncpy(char* out,const char* in,size_t len){::strncpy(out,in,len);}}; -void recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*){} +bool recalcRxPowerSavingFromLevel(uint8_t,uint8_t,float,uint8_t,uint32_t*,uint32_t*){return true;} struct StatsFormatHelper { template static void formatCoreStats(char* out,T&&...){strcpy(out,"core");} template static void formatRadioStats(char* out,T&&...){strcpy(out,"radio");} @@ -146,7 +149,11 @@ struct WebConfigServer { }; const char* FIRMWARE_VERSION="test"; const char* FIRMWARE_BUILD_DATE="test"; +struct CommonRadioPrefs {static bool isValidBandwidth(float){return true;}}; struct MyMesh : CompanionFleetFixture { + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_blocked=false,_primary_radio_reply_admission=false; + mesh::MorseReplyHandoff _morse_reply; bool _morse_reply_admission = false; mesh::RadioProfileCLI _radio_profiles; @@ -164,8 +171,13 @@ struct MyMesh : CompanionFleetFixture { unsigned radio_apply_retry_at=0; uint8_t radio_apply_failures=0; int resets=0, saves=0; - enum class RadioSettingResult { Saved, RadioRejected, SaveFailed }; + enum class RadioSettingResult { Saved, RadioRejected, SaveFailed, SaveUnconfirmed }; RadioSettingResult applyAndSaveTxPower(int8_t); + RadioSettingResult applyAndSaveRadioParams(float,float,uint8_t,uint8_t,uint8_t,uint16_t,bool=false); + void queueSavedRadioApply(); + bool isValidClientRepeatFreq(uint32_t) const {return true;} + mesh::RadioParamApplyResult tryApplyRadioParams(float,float,uint8_t,uint8_t) {return mesh::RadioParamApplyResult::APPLIED;} + bool applySavedRadioParams(){return true;} bool saveAdvertName(const char*); bool handleCommand(const char*,uint32_t,char*); bool handleDirectCommand(const char*,char*,size_t); @@ -261,15 +273,15 @@ int main() { } assert(node.handleCommand("set freq 915.25",0,reply)); assert(!node._radio_profiles.remote_origin); - assert(node._prefs.freq==915.25f && strstr(reply,"reboot to apply")); + assert(node._prefs.freq==915.25f && !strncmp(reply,"OK",2)); assert(node.handleCommand("set freq 920",100,reply)); assert(node._radio_profiles.remote_origin); - assert(node._prefs.freq==920.0f && strstr(reply,"reboot to apply")); + assert(node._prefs.freq==920.0f && !strcmp(reply,"OK")); for(uint32_t stamp:{0u,100u}) { reply[0]=0; node.onCLICommandRecv(ContactInfo{},nullptr,stamp,"A7|set freq 921.125",reply); assert(node._radio_profiles.remote_origin); // even a zero-timestamp LoRa command - assert(node._prefs.freq==921.125f && !strcmp(reply,"A7|OK - reboot to apply")); + assert(node._prefs.freq==921.125f && !strcmp(reply,"A7|OK")); assert(node._prefs.bw==125.0f && node._prefs.sf==7 && node._prefs.cr==5); for(const char* bad:{"set freq 149.9","set freq 2500.1","set freq 920oops", "set freq NaN","set freq inf","set freq 1e3","set freq "}) { @@ -471,6 +483,8 @@ int main() { implementation = "\n".join(extract_braced(source, signature) for signature in ( "static bool isCompanionRadioPrefsCommand(", "MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(", + "MyMesh::RadioSettingResult MyMesh::applyAndSaveRadioParams(", + "void MyMesh::queueSavedRadioApply(", "bool MyMesh::saveAdvertName(", "void MyMesh::onContactCacheFlushed()", "bool MyMesh::handleDirectCommand(", "bool MyMesh::handleCommand(", diff --git a/test/test_morse_loop_policy.py b/test/test_morse_loop_policy.py index 9e62b4706..7b3c23915 100644 --- a/test/test_morse_loop_policy.py +++ b/test/test_morse_loop_policy.py @@ -172,10 +172,11 @@ struct CommonCLI { Callbacks callbacks; Callbacks* _callbacks=&callbacks; UserGpio _user_gpio; struct Profiles { unsigned loops=0; void loop() { ++loops; } } _radio_profiles; - unsigned management=0, tx_services=0; + unsigned management=0, tx_services=0, rx_services=0; void loopBatteryForwarding(bool = false) {} void loopManagement() { ++management; } void servicePendingTxPower() { ++tx_services; } + void servicePendingRxSettings() { ++rx_services; } void loop(); }; @LOOP@ @@ -187,10 +188,12 @@ int main() { const unsigned ordinary=mode==1 || mode==2 ? 0:1; assert(cli._radio_profiles.loops==ordinary && cli.management==ordinary); assert(cli.tx_services==ordinary); + assert(cli.rx_services==ordinary); radio_driver.tx=radio_driver.rx=false; cli.loop(); assert(cli._radio_profiles.loops==ordinary+1 && cli.management==ordinary+1); assert(cli.tx_services==ordinary+1); + assert(cli.rx_services==ordinary+1); assert(cli._user_gpio.loops==2 && cli.callbacks.completed==1); } } diff --git a/test/test_mqtt_ap_ownership.py b/test/test_mqtt_ap_ownership.py index 319876d6f..adf569873 100644 --- a/test/test_mqtt_ap_ownership.py +++ b/test/test_mqtt_ap_ownership.py @@ -72,6 +72,9 @@ class MQTTAccessPointOwnershipTest(unittest.TestCase): def test_cold_start_and_repeated_restart_register_events_once(self): self.run_fixture("cold_and_restart") + def test_changed_credentials_replace_connected_station_after_ownership_release(self): + self.run_fixture("changed_credentials") + def test_sdk_read_and_station_enable_failures_defer_and_retry(self): self.run_fixture("failures") diff --git a/test/test_mqtt_handshake_scheduler.py b/test/test_mqtt_handshake_scheduler.py index 653582196..c48ec1d55 100644 --- a/test/test_mqtt_handshake_scheduler.py +++ b/test/test_mqtt_handshake_scheduler.py @@ -197,6 +197,7 @@ struct MQTTBridge { void* _identity = this; bool _slots_setup_done = true; bool _wifi_status_initialized = true, _manage_wifi = false; + bool _wifi_credentials_changed = false; wl_status_t _last_wifi_status = WL_CONNECTED; unsigned long _last_wifi_check = 0, _last_wifi_reconnect_attempt = 0; uint8_t _wifi_reconnect_backoff_attempt = 0, _wifi_power_save = 1; diff --git a/test/test_mqtt_transport_results.py b/test/test_mqtt_transport_results.py index c35a50305..78575ff73 100644 --- a/test/test_mqtt_transport_results.py +++ b/test/test_mqtt_transport_results.py @@ -428,6 +428,7 @@ struct MQTTBridge { std::atomic _stop_requested{false}, _stop_acked{false}; std::atomic _network_suspended{true}; bool _initialized = true, resources_owned = true; + bool _start_requested = false; uint32_t _start_retry_at = 0; bool _slots_setup_done = true, _staged_raw_valid = true; bool _ntp_estimate_requested = true, _ntp_estimate_done = true, _ntp_estimate_ok = true; uint32_t _ntp_estimate_epoch = 123; @@ -441,6 +442,7 @@ struct MQTTBridge { mesh::wifi::networkPower().setSuspended(true); } void cancelNtpRefresh() { ++cancels; } + void begin() { assert(false); } void requestStop(); void end(); void finishStopped(); diff --git a/test/test_nrf52_usb_logging_contract.py b/test/test_nrf52_usb_logging_contract.py index 4f0afd147..9d03c0d81 100644 --- a/test/test_nrf52_usb_logging_contract.py +++ b/test/test_nrf52_usb_logging_contract.py @@ -674,10 +674,8 @@ int main() { self.assertIn( "_delayed_replies.request.route == route", session_operations ) - self.assertIn( - "command_radio_reply_route == route", session_operations - ) - self.assertIn("cancelPendingRadioParamApply();", session_operations) + self.assertNotIn("cancelPendingRadioParamApply", session_operations) + self.assertNotIn("saved_radio_apply_pending = false", session_operations) self.assertIn("if (_cli_rescue)", session_operations) self.assertIn( "memset(cli_command, 0, sizeof(cli_command));", @@ -712,16 +710,12 @@ int main() { self.assertIn("serial->writeFrameToRoute(reply.route,", service) self.assertNotRegex(service, r"serial->writeFrame\(") self.assertNotIn("_serial->writeFrame(out_frame, i);", pending_writer) - self.assertIn( - "command_radio_reply_route = _serial->captureReplyRoute();", - mesh_source, - ) - self.assertIn( - "isReplyRouteAvailable(command_radio_reply_route)", mesh_source - ) - self.assertIn( - "isReplyRouteWriteBusy(command_radio_reply_route)", mesh_source - ) + # Settings now acknowledge a durable target immediately. A host + # reconnect must not discard it or route an unrelated late reply. + radio_setter = extract_braced(mesh_source, 'if (cmd_frame[0] == CMD_SET_RADIO_PARAMS)') + self.assertIn("applyAndSaveRadioParams(", radio_setter) + self.assertNotIn("captureReplyRoute", radio_setter) + self.assertNotIn("command_radio_reply_route", mesh_source) completion = source[ source.index("static void completePrimaryUsbSessionReset(") : diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index e3ed25ae7..53e954ec1 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -26,6 +26,9 @@ HARNESS = r''' #include #include #include +#ifndef MESH_RECEIVE_HARNESS_HAS_DISPATCHER +namespace mesh { enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; } +#endif #define STATE_IDLE 0 #define STATE_RX 1 #define STATE_TX_WAIT 3 @@ -85,7 +88,8 @@ struct RadioLibWrapper { uint32_t _nf_published_at[2] = {}, _nf_published_generation[2] = {}; uint8_t _nf_published_valid = 0; bool _cad_enabled = true, packet = false, busy = false, inject_arm_irq = false; - bool _rx_boosted_gain_valid = false, _cur_rx_boosted_gain = false, gain_apply_success = true; + bool _rx_boosted_gain_valid = false, _rx_boosted_gain_applied = false; + bool _cur_rx_boosted_gain = false, gain_apply_success = true, physical_gain = false; bool _wd_last_busy = false; uint8_t _startrx_fails = 0, _rx_mode_failures = 0; uint32_t _rx_retry_at = 0; @@ -125,10 +129,13 @@ struct RadioLibWrapper { unsigned long getEstAirtimeFor(int) { return 10; } void stopReceiveDutyCycle() { ++stops; _rx_ps_armed = false; } void doResetAGC() { ++agc_resets; } - bool applyRxBoostedGainMode(bool) { ++gain_changes; return gain_apply_success; } + bool applyRxBoostedGainMode(bool enabled) { + ++gain_changes; physical_gain = enabled; return gain_apply_success; + } uint8_t beginReconfigure() { return 0; } // counted SPI/RX boundaries are separate void endReconfigure(bool) {} bool supportsRxPowerSaving() const { return true; } + bool supportsRxBoostedGainMode() const { return true; } int startReceiveMode() { ++arms; if (_nf_calib_active) ++calibration_arms; @@ -146,7 +153,9 @@ struct RadioLibWrapper { bool isChannelActive(); bool isReceivingPassive(int); bool setRxPowerSaving(bool, uint32_t, uint32_t); + mesh::RadioParamApplyResult trySetRxPowerSaving(bool, uint32_t, uint32_t); bool setRxBoostedGainMode(bool); + mesh::RadioParamApplyResult trySetRxBoostedGainMode(bool); void startRecv(); void checkReceiveMode(uint32_t); void loop(); @@ -343,12 +352,33 @@ static void periodicRefreshContracts() { } static void agcAdmissionContracts() { + { + using Result = mesh::RadioParamApplyResult; + RadioLibWrapper w; + w._rx_boosted_gain_valid = w._rx_boosted_gain_applied = true; + w.gain_apply_success = false; + assert(w.trySetRxBoostedGainMode(true) == Result::FAILED); + assert(!w._rx_boosted_gain_applied && w._rx_boosted_gain_valid && !w._cur_rx_boosted_gain); + assert(w.physical_gain); // Failure deliberately models a partial chip write. + const auto failed_writes = w.gain_changes; + w.gain_apply_success = true; + assert(w.trySetRxBoostedGainMode(false) == Result::APPLIED); + assert(w._rx_boosted_gain_applied && w._rx_boosted_gain_valid && !w._cur_rx_boosted_gain); + assert(!w.physical_gain); + assert(w.gain_changes == failed_writes + 1); + const auto repaired_writes = w.gain_changes; + assert(w.trySetRxBoostedGainMode(false) == Result::APPLIED); + assert(w.gain_changes == repaired_writes); // Verified matching gain is a true no-op. + w._cw_active = true; + assert(w.trySetRxBoostedGainMode(false) == Result::BUSY); + assert(w.gain_changes == repaired_writes); + } unsigned cases = 0; for (unsigned mode : {0U, 1U, 2U}) for (unsigned owned : {0U, 1U, 2U, 3U, 4U}) { if (mode == 1 && owned == 1) continue; // known RXPS sleep has an explicit wake path RadioLibWrapper w; selectMode(w, mode); w._rx_ps_armed = mode == 1; - w._rx_boosted_gain_valid = w._cur_rx_boosted_gain = true; + w._rx_boosted_gain_valid = w._rx_boosted_gain_applied = w._cur_rx_boosted_gain = true; w._nf_last_calib = now_ms; w._nf_calib_deadline = now_ms + 800; w._nf_sample_from = now_ms + NF_CALIB_SETTLE_MS; @@ -416,7 +446,7 @@ static void agcAdmissionContracts() { for (bool legacy : {false, true}) { RadioLibWrapper w; selectMode(w, mode); w._rx_ps_armed = mode == 1 && !active_window; - w._rx_boosted_gain_valid = w._cur_rx_boosted_gain = true; + w._rx_boosted_gain_valid = w._rx_boosted_gain_applied = w._cur_rx_boosted_gain = true; w._noise_floor_valid = primary; w._noise_floor_secondary_valid = secondary; w._nf_calib_active = active_window; w._nf_last_calib = now_ms; w._nf_calib_deadline = now_ms + 800; @@ -702,6 +732,8 @@ def transition_harness(source=None): names = [('int16_t', 'performChannelScanWithTimeout'), ('bool', 'isPacketPendingOrReceiving'), ('bool', 'isChannelActive'), ('bool', 'isReceivingPassive'), ('bool', 'setRxPowerSaving'), ('bool', 'setRxBoostedGainMode'), + ('mesh::RadioParamApplyResult', 'trySetRxPowerSaving'), + ('mesh::RadioParamApplyResult', 'trySetRxBoostedGainMode'), ('void', 'startRecv'), ('void', 'checkReceiveMode'), ('void', 'loop'), ('void', 'triggerNoiseFloorCalibrate'), ('void', 'requestRestartRecv'), ('void', 'noiseFloorCalibCheck'), @@ -886,6 +918,7 @@ int main() { #include #include #include +namespace mesh { enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; } struct NodePrefs { uint8_t rx_powersaving_enabled = 1, rx_ps_level = 6, rx_ps_preamble = 32, sf = 8; uint32_t rx_ps_rx_us = 40000, rx_ps_sleep_us = 32000; @@ -895,11 +928,25 @@ struct Callbacks { bool accept = true; unsigned calls = 0; bool setRxPowerSaving(bool, uint32_t, uint32_t) { ++calls; return accept; } + bool supportsRxPowerSaving() const { return true; } }; struct CommonCLI { NodePrefs* _prefs; Callbacks* _callbacks; unsigned saves = 0; + static constexpr uint8_t RxPowerSaving = 2; + uint8_t _rx_settings_pending = 0; + uint32_t _rx_settings_retry_at = 0; + mesh::RadioParamApplyResult applyRxSetting(uint8_t) { + return _callbacks->setRxPowerSaving(_prefs->rx_powersaving_enabled, + _prefs->rx_ps_rx_us, _prefs->rx_ps_sleep_us) + ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; + } + bool saveRxSetting(uint8_t, mesh::RadioParamApplyResult result, char*) { + if (result == mesh::RadioParamApplyResult::FAILED) return false; + ++saves; return true; + } + void restoreRxSetting(uint8_t, mesh::RadioParamApplyResult, uint8_t, uint32_t) {} void savePrefs() { ++saves; } bool calculateRxPowerSavingLevel(uint32_t, uint8_t, float, uint32_t, uint32_t*, uint32_t*); void set(const char* config, char* reply); diff --git a/test/test_radio_receive_retry.py b/test/test_radio_receive_retry.py index 6dff17ba6..8dce12de9 100644 --- a/test/test_radio_receive_retry.py +++ b/test/test_radio_receive_retry.py @@ -47,6 +47,8 @@ class RadioReceiveRetryTests(unittest.TestCase): ('bool', 'isPacketPendingOrReceiving'), ('bool', 'isChannelActive'), ('bool', 'isReceivingPassive'), ('bool', 'setRxPowerSaving'), ('bool', 'setRxBoostedGainMode'), ('void', 'startRecv'), + ('mesh::RadioParamApplyResult', 'trySetRxPowerSaving'), + ('mesh::RadioParamApplyResult', 'trySetRxBoostedGainMode'), ('void', 'checkReceiveMode'), ('void', 'loop'), ('void', 'triggerNoiseFloorCalibrate'), ('void', 'requestRestartRecv'), ('void', 'noiseFloorCalibCheck'), ('void', 'endNoiseFloorCalib'), @@ -66,7 +68,7 @@ class RadioReceiveRetryTests(unittest.TestCase): settle = header[start:header.index('#endif', start) + len('#endif')] harness = HARNESS[:HARNESS.index('int main() {')] harness = harness.replace('#include ', - '#include \n#include \n' + '#define MESH_RECEIVE_HARNESS_HAS_DISPATCHER 1\n#include \n#include \n' '#include \n#define BD_STARTUP_RX_PACKET 1') harness = harness.replace('struct RadioLibWrapper {', 'struct RadioLibWrapper : mesh::Radio {') harness = harness.replace('Radio radio{&events}; Radio* _radio', diff --git a/test/test_radio_reply_mutation.py b/test/test_radio_reply_mutation.py index 6fdf61704..b24a46097 100644 --- a/test/test_radio_reply_mutation.py +++ b/test/test_radio_reply_mutation.py @@ -36,6 +36,7 @@ struct Mesh { }; } struct Profiles { + void servicePendingTuning() {} bool pending = false, accepted = false; unsigned completions = 0; bool finishReplyMutation(bool delivered) { @@ -44,7 +45,9 @@ struct Profiles { }; struct CLI { Profiles profiles; + void servicePendingRxSettings() {} void servicePendingTxPower() {} + void servicePendingLocalRadioSettings() {} struct NoMorseReply { void complete(const void*) {} void fail(const void*) {} @@ -63,9 +66,15 @@ struct Manager { }; struct MyMesh : mesh::Mesh { CLI _cli; + void servicePendingRxSettings() {} void servicePendingTxPower() {} + void servicePendingLocalRadioSettings() {} Manager manager; Manager* _mgr = &manager; mesh::TempRadioReplyBarrier temp_radio_reply_barrier; + mesh::TempRadioReplyBarrier _primary_radio_reply; + bool _primary_radio_blocked = false, _primary_radio_reply_admission = false; + void finishPrimaryRadioReply(bool) {} + void queueSavedRadioApply() {} bool radio_reply_secondary = false, primary_radio_mutation_starts_temp = false; uint32_t radio_reply_deadline = 0; mesh::Packet* pending_battery_alert_packet = nullptr; @@ -78,6 +87,8 @@ struct MyMesh : mesh::Mesh { void cancelOutboundRadioRetry(const mesh::Packet* p) { assert(p == outbound); ++cancellations; } void releasePacket(mesh::Packet*) { ++released; } bool scheduleNormalRadio() { ++normal_schedules; return true; } + void processScheduledRadioSettings(bool) {} + void servicePendingPrimaryRadioSettings(bool) {} void onSendComplete(mesh::Packet*) override; void onSendFail(mesh::Packet*) override; void onRadioProfileCopyQueued(mesh::Packet*, const mesh::Packet*, uint8_t) override; diff --git a/test/test_radio_rx_settings_transaction.py b/test/test_radio_rx_settings_transaction.py new file mode 100644 index 000000000..f003ba5b5 --- /dev/null +++ b/test/test_radio_rx_settings_transaction.py @@ -0,0 +1,345 @@ +"""Exercise actual infrastructure RX setters, checked saves, and pending service.""" +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] + + +def preprocess(source): + return subprocess.run([os.environ.get("CXX", "g++"), "-E", "-P", "-x", "c++", + "-DUSE_LR2021", "-"], input=source, text=True, + capture_output=True, check=True).stdout + + +def fixture(): + source = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii") + methods = "\n".join(extract_braced(source, signature) for signature in ( + "mesh::RadioParamApplyResult CommonCLI::applyRxSetting(", + "void CommonCLI::servicePendingRxSettings(", "bool CommonCLI::saveRxSetting(", + "void CommonCLI::restoreRxSetting(", "bool CommonCLI::trySavePrefs(", + "void CommonCLI::loop(", "bool CommonCLI::calculateRxPowerSavingLevel(")) + setter = preprocess(extract_braced(source, "void CommonCLI::handleSetCmd(")) + branches = [extract_braced(setter, signature) for signature in ( + 'if (memcmp(config, "radio.rxgain ", 13) == 0)', + 'if (memcmp(config, "radio.rxps ", 11) == 0)', + 'if (strncmp(config, "radio.rxps.rfrx_disabled ", 25) == 0)', + 'if (strcmp(config, "extra.sf") == 0 || memcmp(config, "extra.sf ", 9) == 0)')] + values = { + "METHODS": preprocess(methods), + "SETTERS": preprocess(" else ".join(branches)), + "ENUM": extract_braced(header, "enum RxSetting : uint8_t") + ";", + "PENDING": extract_braced(header, "bool hasPendingRadioSettings() const"), + "NOTE": preprocess(extract_braced(source, "static void appendRxPowerSavingAdjustmentNote(")), + } + result = HARNESS + for key, value in values.items(): + result = result.replace("@" + key + "@", value) + return result + + +HARNESS = r''' +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#define MIN_LOCAL_ADVERT_INTERVAL 60 +static uint32_t now_ms = 1000; +uint32_t millis() { return now_ms; } +namespace mesh { +enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; +struct Radio { + bool carrier = false, morse = false; + struct Profiles { + bool dual = false; + struct { uint8_t sf = 7; float bw = 125; } primary; + bool enabled() const { return dual; } + } profile_state; + const Profiles* profiles() const { return &profile_state; } + bool isCarrierWaveActive() const { return carrier; } +}; +bool morseTimingActive(const Radio* radio) { return radio && radio->morse; } +} +using Result = mesh::RadioParamApplyResult; +struct Prefs { + uint8_t rx_boosted_gain = 0, rx_powersaving_enabled = 0; + uint32_t rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; + uint32_t rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + uint8_t rx_ps_level = 0, rx_ps_preamble = 0, sf = 7, advert_interval = 0; + float bw = 125; + uint8_t extra_sf[4] = {}; + void clearDirty() {} +}; +using NodePrefs = Prefs; +class CommonCLI; +struct Callbacks { + CommonCLI* owner = nullptr; + mesh::Radio radio; + bool gain_supported = true, ps_supported = true, rf_supported = true; + bool transmitting = false, save_ok = true, acknowledge = true; + bool hardware_gain = false, hardware_rf = false; + RxPowerSavingConfig hardware_ps; + uint8_t hardware_sf[4] = {}; + Prefs persisted; + unsigned saves = 0, gain_attempts = 0, ps_attempts = 0, sf_attempts = 0, rf_attempts = 0; + std::deque gain_results, ps_results, sf_results, rf_results; + Result admission(std::deque& results) { + if (transmitting) return Result::BUSY; + if (results.empty()) return Result::APPLIED; + Result result = results.front(); results.pop_front(); return result; + } + bool supportsRxBoostedGain() const { return gain_supported; } + bool supportsRxPowerSaving() const { return ps_supported; } + bool supportsRxPowerSavingRfRxDisable() const { return rf_supported; } + bool isRxPowerSavingRfRxDisabled() const { return hardware_rf; } + Result trySetRxBoostedGain(bool enabled) { + ++gain_attempts; const Result result = admission(gain_results); + if (result != Result::BUSY) hardware_gain = enabled; // FAILED can be a partial write. + return result; + } + Result trySetRxPowerSaving(bool enabled, uint32_t rx, uint32_t sleep) { + ++ps_attempts; const Result result = admission(ps_results); + if (result != Result::BUSY) hardware_ps = RxPowerSavingConfig(enabled, rx, sleep, 0, 0); + return result; + } + Result tryConfigSideDetectors(const uint8_t* sfs, uint8_t count, float) { + ++sf_attempts; const Result result = admission(sf_results); + if (result != Result::BUSY) { + memset(hardware_sf, 0, sizeof(hardware_sf)); memcpy(hardware_sf, sfs, count); + } + return result; + } + Result trySetRxPowerSavingRfRxDisabled(bool disabled) { + ++rf_attempts; const Result result = admission(rf_results); + if (result != Result::BUSY) hardware_rf = disabled; + return result; + } + mesh::Radio* getProfileRadio() { return &radio; } + void updateAdvertTimer() {} + void savePrefs(PrefsSaveRouting::Scope); +}; +@NOTE@ +class CommonCLI { +public: + @ENUM@ + Prefs prefs; + Callbacks callbacks; + Prefs* _prefs = &prefs; + Callbacks* _callbacks = &callbacks; + uint8_t _rx_settings_pending = 0; + uint32_t _rx_settings_retry_at = 0; + bool _tx_power_pending = false, _common_save_result_known = false, _common_save_succeeded = false; + struct Profiles { + bool pending = false; + void loop() {} + bool hasPendingTuning() const { return pending; } + } _radio_profiles; + CommonCLI() { callbacks.owner = this; } + @PENDING@ + void loopManagement() {} + void servicePendingTxPower() {} + void loop(); + Result applyRxSetting(uint8_t); + void servicePendingRxSettings(); + bool saveRxSetting(uint8_t, Result, char*); + void restoreRxSetting(uint8_t, Result, uint8_t, uint32_t); + bool trySavePrefs(PrefsSaveRouting::Scope scope = PrefsSaveRouting::Scope::Common); + bool calculateRxPowerSavingLevel(uint32_t, uint8_t, float, uint32_t, uint32_t*, uint32_t*); + void command(const char* input, char* reply) { + char buffer[160] = {}; + strncpy(buffer, input, sizeof(buffer) - 1); + const char* config = buffer; + @SETTERS@ + else strcpy(reply, "unknown config"); + } +}; +void Callbacks::savePrefs(PrefsSaveRouting::Scope scope) { + assert(scope == PrefsSaveRouting::Scope::Common); ++saves; + if (acknowledge) { + owner->_common_save_result_known = true; + owner->_common_save_succeeded = save_ok; + } + if (save_ok) persisted = owner->prefs; +} +@METHODS@ +static bool ok(const char* reply) { return strncmp(reply, "OK", 2) == 0; } +int main() { + char reply[160]; + { + CommonCLI cli; + for (const char* invalid : {"radio.rxgain banana", "radio.rxgain onjunk", "radio.rxgain off "}) { + cli.command(invalid, reply); assert(!ok(reply)); + } + cli.callbacks.gain_supported = false; + cli.command("radio.rxgain on", reply); + assert(!ok(reply) && !cli.callbacks.gain_attempts && !cli.callbacks.saves && !cli.hasPendingRadioSettings()); + cli.callbacks.ps_supported = false; + cli.prefs.rx_ps_rx_us = 0; cli.prefs.rx_ps_sleep_us = 0; + for (const char* value : {"radio.rxps banana", "radio.rxps 0 3000", "radio.rxps on"}) { + cli.command(value, reply); assert(!ok(reply)); + assert(!cli.prefs.rx_ps_rx_us && !cli.prefs.rx_ps_sleep_us); + } + assert(!cli.callbacks.ps_attempts && !cli.callbacks.saves); + cli.command("extra.sf 99", reply); + assert(!ok(reply) && !cli.callbacks.sf_attempts && !cli.callbacks.saves); + cli.callbacks.radio.profile_state.dual = true; + cli.callbacks.transmitting = true; + cli.command("extra.sf 8", reply); + assert(!ok(reply) && !cli.callbacks.sf_attempts && !cli.callbacks.saves + && !cli.hasPendingRadioSettings()); + cli.callbacks.radio.profile_state.dual = false; + cli.callbacks.radio.profile_state.primary.sf = 8; + cli.command("extra.sf 8", reply); + assert(!ok(reply) && !cli.callbacks.sf_attempts && !cli.hasPendingRadioSettings()); + cli.callbacks.rf_supported = false; + cli.command("radio.rxps.rfrx_disabled on", reply); + assert(!ok(reply) && !cli.callbacks.rf_attempts && !cli.hasPendingRadioSettings()); + } + { + CommonCLI cli; cli.callbacks.transmitting = true; + cli.command("radio.rxgain on", reply); + assert(ok(reply) && strstr(reply, "pending") && cli.hasPendingRadioSettings()); + assert(cli.prefs.rx_boosted_gain && cli.callbacks.persisted.rx_boosted_gain); + cli.command("radio.rxps 2000 3000", reply); + assert(ok(reply) && strstr(reply, "pending")); + cli.command("extra.sf 8,9", reply); + assert(ok(reply) && strstr(reply, "pending")); + assert(cli.callbacks.saves == 3); + cli.command("radio.rxps.rfrx_disabled on", reply); + assert(ok(reply) && strstr(reply, "pending") && cli.callbacks.saves == 3); + cli.loop(); assert(!cli.callbacks.hardware_gain && !cli.callbacks.hardware_ps.enabled); + cli.callbacks.transmitting = false; + cli.loop(); + assert(!cli.hasPendingRadioSettings() && cli.callbacks.hardware_gain); + assert(cli.callbacks.hardware_ps.enabled && cli.callbacks.hardware_ps.rx_us == 2000); + assert(cli.callbacks.hardware_sf[0] == 8 && cli.callbacks.hardware_sf[1] == 9); + assert(cli.callbacks.hardware_rf && cli.callbacks.saves == 3); + cli.loop(); assert(cli.callbacks.saves == 3); + cli._radio_profiles.pending = true; assert(cli.hasPendingRadioSettings()); + } + { + CommonCLI cli; cli.callbacks.transmitting = true; + cli.command("radio.rxgain on", reply); + cli.command("radio.rxgain off", reply); + cli.command("radio.rxps 2000 3000", reply); + cli.command("radio.rxps 4000 5000", reply); + cli.command("extra.sf 8", reply); cli.command("extra.sf 9", reply); + cli.command("radio.rxps.rfrx_disabled on", reply); + cli.command("radio.rxps.rfrx_disabled off", reply); + cli.callbacks.transmitting = false; cli.servicePendingRxSettings(); + assert(!cli.hasPendingRadioSettings() && !cli.callbacks.hardware_gain && !cli.callbacks.hardware_rf); + assert(cli.callbacks.hardware_ps.rx_us == 4000 && cli.callbacks.hardware_sf[0] == 9); + assert(cli.callbacks.saves == 6 && cli.callbacks.persisted.rx_ps_rx_us == 4000); + } + { + CommonCLI cli; + cli.callbacks.save_ok = false; + cli.command("radio.rxgain on", reply); + assert(!ok(reply) && !cli.prefs.rx_boosted_gain && !cli.callbacks.hardware_gain); + cli.command("radio.rxps 2000 3000", reply); + assert(!ok(reply) && !cli.prefs.rx_powersaving_enabled && !cli.callbacks.hardware_ps.enabled); + assert(cli.prefs.rx_ps_rx_us == RX_POWERSAVING_DEFAULT_RX_US && cli.prefs.rx_ps_level == 0); + cli.command("extra.sf 8", reply); + assert(!ok(reply) && !cli.prefs.extra_sf[0] && !cli.callbacks.hardware_sf[0]); + assert(!cli.hasPendingRadioSettings()); + cli.callbacks.save_ok = true; cli.callbacks.acknowledge = false; + cli.command("radio.rxgain on", reply); + assert(!ok(reply) && !cli.prefs.rx_boosted_gain && !cli.callbacks.hardware_gain); + } + { + CommonCLI cli; cli.callbacks.transmitting = true; + cli.command("radio.rxgain on", reply); + cli.callbacks.save_ok = false; + cli.command("radio.rxgain off", reply); + assert(!ok(reply) && cli.prefs.rx_boosted_gain && cli.hasPendingRadioSettings()); + cli.callbacks.transmitting = false; cli.servicePendingRxSettings(); + assert(cli.callbacks.hardware_gain && !cli.hasPendingRadioSettings() && cli.callbacks.saves == 2); + } + { + CommonCLI cli; cli.callbacks.gain_results = {Result::APPLIED, Result::BUSY}; + cli.callbacks.save_ok = false; + cli.command("radio.rxgain on", reply); + assert(!ok(reply) && !cli.prefs.rx_boosted_gain && cli.hasPendingRadioSettings()); + assert(cli.callbacks.hardware_gain); + cli.servicePendingRxSettings(); + assert(!cli.callbacks.hardware_gain && !cli.hasPendingRadioSettings()); + cli.callbacks.gain_results = {Result::FAILED, Result::FAILED}; + cli.command("radio.rxgain on", reply); + assert(!ok(reply) && !cli.prefs.rx_boosted_gain && cli.hasPendingRadioSettings()); + const unsigned attempts = cli.callbacks.gain_attempts; + cli.servicePendingRxSettings(); assert(cli.callbacks.gain_attempts == attempts); + now_ms += 1000; cli.servicePendingRxSettings(); + assert(!cli.hasPendingRadioSettings() && !cli.callbacks.hardware_gain); + } + { + CommonCLI cli; cli.callbacks.radio.morse = true; + cli.command("radio.rxgain on", reply); + cli.command("radio.rxps 2000 3000", reply); + cli.command("extra.sf 8", reply); + cli.command("radio.rxps.rfrx_disabled on", reply); + cli.servicePendingRxSettings(); cli.loop(); + assert(!cli.callbacks.gain_attempts && !cli.callbacks.ps_attempts + && !cli.callbacks.sf_attempts && !cli.callbacks.rf_attempts); + cli.callbacks.radio.morse = false; cli.callbacks.radio.carrier = true; + cli.servicePendingRxSettings(); + assert(!cli.callbacks.gain_attempts && !cli.callbacks.ps_attempts); + cli.callbacks.radio.carrier = false; cli.servicePendingRxSettings(); + assert(!cli.hasPendingRadioSettings() && cli.callbacks.hardware_gain && cli.callbacks.hardware_rf); + assert(cli.callbacks.saves == 3); + } + { + CommonCLI cli; + cli.command("radio.rxps level 8 preamble 32", reply); + assert(ok(reply) && cli.prefs.rx_ps_level == 8 && cli.prefs.rx_ps_preamble == 32); + const auto old_rx = cli.prefs.rx_ps_rx_us; + cli.callbacks.save_ok = false; + cli.command("radio.rxps 2000 3000", reply); + assert(!ok(reply) && cli.prefs.rx_ps_level == 8 && cli.prefs.rx_ps_preamble == 32); + assert(cli.prefs.rx_ps_rx_us == old_rx && cli.callbacks.hardware_ps.rx_us == old_rx); + } +} +''' + + +class InfrastructureRxSettingsTests(unittest.TestCase): + def test_actual_commands_and_pending_transactions(self): + with tempfile.TemporaryDirectory(prefix="mesh-infra-rx-") as directory: + source, exe = Path(directory) / "test.cpp", Path(directory) / "test" + source.write_text(fixture(), encoding="ascii") + built = subprocess.run([ + os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-I", str(ROOT / "src"), str(source), "-o", str(exe), + ], text=True, capture_output=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(exe)], text=True, capture_output=True) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_pending_radio_settings_keep_all_infrastructure_roles_awake(self): + for role, name in (("simple_repeater", "MyMesh"), ("simple_room_server", "MyMesh"), + ("simple_sensor", "SensorMesh")): + with self.subTest(role=role): + source = (ROOT / "examples" / role / (name + ".cpp")).read_text(encoding="ascii") + header = (ROOT / "examples" / role / (name + ".h")).read_text(encoding="ascii") + self.assertIn("_cli.hasPendingRadioSettings()", extract_braced( + source, f"mesh::powerdiag::Mask {name}::pendingWorkReason() const")) + self.assertIn("!_cli.hasPendingRadioSettings()", extract_braced( + header, "bool canRecoverUsbLogging() const")) + tracker = (ROOT / "examples/simple_sensor/Tracker.cpp").read_text(encoding="ascii") + self.assertIn("_cli.hasPendingRadioSettings()", extract_braced( + tracker, "bool SensorMesh::trackerNeedsRadio() const")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_radio_rxps_rejection.py b/test/test_radio_rxps_rejection.py index 9d007656d..9cb338f55 100644 --- a/test/test_radio_rxps_rejection.py +++ b/test/test_radio_rxps_rejection.py @@ -27,11 +27,24 @@ struct NodePrefs { struct Callbacks { RadioLibWrapper& radio_driver; bool setRxPowerSaving(bool, uint32_t, uint32_t); + bool supportsRxPowerSaving() const { return radio_driver.supportsRxPowerSaving(); } }; struct CommonCLI { NodePrefs* _prefs; Callbacks* _callbacks; unsigned saves = 0; + static constexpr uint8_t RxPowerSaving = 2; + uint8_t _rx_settings_pending = 0; + uint32_t _rx_settings_retry_at = 0; + mesh::RadioParamApplyResult applyRxSetting(uint8_t) { + return _callbacks->radio_driver.trySetRxPowerSaving(_prefs->rx_powersaving_enabled, + _prefs->rx_ps_rx_us, _prefs->rx_ps_sleep_us); + } + bool saveRxSetting(uint8_t, mesh::RadioParamApplyResult result, char*) { + if (result == mesh::RadioParamApplyResult::FAILED) return false; + ++saves; return true; + } + void restoreRxSetting(uint8_t, mesh::RadioParamApplyResult, uint8_t, uint32_t) {} void savePrefs() { ++saves; } bool calculateRxPowerSavingLevel(uint32_t,uint8_t,float,uint32_t,uint32_t*,uint32_t*); void set(const char*, char*); @@ -196,6 +209,7 @@ class RadioRxpsRejectionTest(unittest.TestCase): ('void', 'serviceProfileScan'), ('bool', 'setRxPowerSaving'), ('void', 'recalibrateNoiseFloor'), ('bool', 'validateProfile')] names += [('mesh::RadioParamApplyResult', name) for name in ( + 'trySetRxPowerSaving', 'tuneProfile', 'prepareTransmitProfile', 'tryRestoreCodingRate', 'trySetParams', 'trySetPrimaryParams')] methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') @@ -220,6 +234,7 @@ class RadioRxpsRejectionTest(unittest.TestCase): '_rx_ps_armed=supported && _rx_ps_enabled;') harness = harness.replace(' void recalibrateNoiseFloor() { _noise_floor_valid=false; }', ' void recalibrateNoiseFloor();\n bool setRxPowerSaving(bool,uint32_t,uint32_t);\n' + ' mesh::RadioParamApplyResult trySetRxPowerSaving(bool,uint32_t,uint32_t);\n' + method(header, 'bool isCalibratingNoiseFloor() const').replace(' override', '')) harness = harness.replace('bool validateProfile(const mesh::RadioProfileParams& p) const { return mesh::RadioProfiles::valid(p); }', 'bool validateProfile(const mesh::RadioProfileParams&) const;') @@ -229,16 +244,17 @@ class RadioRxpsRejectionTest(unittest.TestCase): .replace('MyMesh::', 'Callbacks::')) cases = cases.replace('@CALCULATOR@', method(common, 'bool CommonCLI::calculateRxPowerSavingLevel(')) cases = cases.replace('@CLI@', method(common, 'if (memcmp(config, "radio.rxps ", 11) == 0)')) - setter = method(source, 'bool RadioLibWrapper::setRxPowerSaving(') - guard = ' if (enabled && !supportsRxPowerSaving()) return false;\n' + setter = method(source, 'mesh::RadioParamApplyResult RadioLibWrapper::trySetRxPowerSaving(') + guard = '|| !supportsRxPowerSaving()' if setter.count(guard) != 1: raise AssertionError('expected actual early capability rejection') suspended = method(setter, 'if (_profile_rxps_suspended)') - if suspended.count('return true;') != 1: + if suspended.count('return Result::APPLIED;') != 1: raise AssertionError('expected actual accepted deferred branch') legacy = setter.replace(guard, '', 1).replace(suspended, - suspended.replace('return true;', 'return !enabled || supportsRxPowerSaving();') - + '\n ' + guard.strip(), 1) + suspended.replace('return Result::APPLIED;', + 'return !enabled || supportsRxPowerSaving() ? Result::APPLIED : Result::FAILED;') + + '\n if (enabled && !supportsRxPowerSaving()) return Result::FAILED;', 1) sanitize = [] if not sys.platform.startswith('linux') else [ '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-omit-frame-pointer', '-fno-pie', '-no-pie'] @@ -296,8 +312,10 @@ class RadioRxpsRejectionTest(unittest.TestCase): def test_original_suspended_branch_has_intended_semantic_failure(self): # Outside-scan rejection is a healthy control for the historical code. self.check_case('reject-outside', 'legacy') - for case in ('reject-inside', 'cli-inside'): - self.check_case(case, 'legacy', fails=True) + self.check_case('reject-inside', 'legacy', fails=True) + # The CLI now checks runtime capability before driver admission, so it + # independently prevents this historical driver's deferred-state bug. + self.check_case('cli-inside', 'legacy') self.check_case('reject-restoration', 'legacy', fails=True, diagnostic='rejected RXPS lost calibration request or sleep fence') diff --git a/test/test_repeater_radio_timing_integration.py b/test/test_repeater_radio_timing_integration.py index 5869fc4df..1759b559c 100644 --- a/test/test_repeater_radio_timing_integration.py +++ b/test/test_repeater_radio_timing_integration.py @@ -185,7 +185,7 @@ public: void updateFloodAdvertTimer(); void queueSavedRadioApply(); void refreshScheduledRadioState(); - void processScheduledRadioSettings(); + void processScheduledRadioSettings(bool tx_gap = false); void applyTempRadioParams(float, float, uint8_t, uint8_t, int, uint16_t = 0); bool scheduleNormalRadio(); void clearScheduledRadioSetting(int, bool); @@ -235,6 +235,16 @@ int main() { assert(radio_driver.active_gain == m._prefs.rx_boosted_gain); assert(radio_driver.active_power == m._prefs.tx_power_dbm); } + { // Queue pressure must not postpone settings past a completed TX gap. + MyMesh m; + m.queueSavedRadioApply(); m.outbound = true; + m.processScheduledRadioSettings(); + assert(m.saved_radio_apply_pending && m.scheduled_radio_retry_at); + assert(!m.scheduled_radio_retry_failures && !m.restores); + m.processScheduledRadioSettings(true); + assert(!m.saved_radio_apply_pending && !m.scheduled_radio_retry_at); + assert(m.restores == 1); + } { // A later failed due entry must not undo an earlier durable change. MyMesh m; char reply[160]; @@ -575,7 +585,7 @@ int main() { "void MyMesh::queueSavedRadioApply()", "bool MyMesh::applySavedRadioParams()", "void MyMesh::refreshScheduledRadioState()", - "void MyMesh::processScheduledRadioSettings()", + "void MyMesh::processScheduledRadioSettings(bool tx_gap)", "void MyMesh::applyTempRadioParams(", "bool MyMesh::scheduleNormalRadio()", "void MyMesh::clearScheduledRadioSetting(", diff --git a/test/test_room_sensor_radio_restore.py b/test/test_room_sensor_radio_restore.py index 283dfd723..5a8ee7e41 100644 --- a/test/test_room_sensor_radio_restore.py +++ b/test/test_room_sensor_radio_restore.py @@ -22,6 +22,8 @@ HARNESS = r''' #include #include #include +#include +#include // Negative controls must fail without opening a Windows CRT abort dialog. #undef assert #define assert(condition) do { if (!(condition)) { \ @@ -48,11 +50,20 @@ struct Driver { bool _profile_repair_required = false, _profile_refresh_required = false; bool _rx_hold_continuous = false; bool _dbm_valid = true, _dbm_applied = true, _rx_boosted_gain_valid = false; + bool _rx_boosted_gain_applied = false; int8_t _cur_dbm = 7; bool _cur_rx_boosted_gain = false; unsigned power_commands = 0, gain_commands = 0; + unsigned side_commands = 0; + uint8_t side_sfs[4] = {}; + struct Profiles { + bool active = false; + bool enabled() const { return active; } + } profile_state; + const Profiles* profiles() const { return &profile_state; } std::deque power_results; std::deque gain_results; + std::deque side_results; Chip* _radio = &chip; bool hasPendingRadioInterrupt() { return pending_irq; } bool claimRadioInterrupt() { const bool pending = pending_irq; pending_irq = false; return pending; } @@ -64,19 +75,30 @@ struct Driver { int16_t applyCachedTxPower(int8_t) { ++power_commands; return next(power_results, int16_t(0)); } -#if ROOM_ROLE bool supportsRxBoostedGainMode() { return supported; } bool applyRxBoostedGainMode(bool) { assert(supported); ++gain_commands; return next(gain_results, true); } bool setRxBoostedGainMode(bool); -#endif + Apply trySetRxBoostedGainMode(bool); bool supportsRxPowerSaving() { return true; } uint8_t beginReconfigure(); void endReconfigure(bool); bool setTxPower(int8_t); bool isTxPowerValid(int8_t) const { return true; } mesh::RadioParamApplyResult trySetTxPower(int8_t); + bool configSideDetectors(const uint8_t* sfs, uint8_t count, float) { + ++side_commands; + const uint8_t resume = beginReconfigure(); + if (resume > 1) return false; + const bool accepted = next(side_results, true); + if (accepted) { + memset(side_sfs, 0, sizeof(side_sfs)); + if (count) memcpy(side_sfs, sfs, count); + } + endReconfigure(resume); + return accepted; + } } radio_driver; @WRAPPER_METHODS@ struct Prefs { @@ -86,6 +108,7 @@ struct Prefs { bool rx_boosted_gain = true, rx_powersaving_enabled = false; uint8_t rx_ps_level = 0, rx_ps_preamble = 32; uint32_t rx_ps_rx_us = 1234, rx_ps_sleep_us = 5678; + uint8_t extra_sf[4] = {10, 0, 0, 0}; }; struct Profiles { float live_freq = 906.5f, live_bw = 125; @@ -126,6 +149,8 @@ struct @CLASS@ { bool saved_radio_apply_pending = false, temp_radio_applied = false, outbound = false; uint32_t set_radio_at = 0, revert_radio_at = 0, radio_apply_retry_at = 0; uint8_t radio_apply_failures = 0; + unsigned saves = 0; + void savePrefs() { ++saves; } float pending_freq = 916.5f, pending_bw = 125; uint8_t pending_sf = 8, pending_cr = 7, pending_preamble = 16; @CLASS@() { now_ms = 100; radio_driver = Driver(); } @@ -135,7 +160,7 @@ struct @CLASS@ { uint32_t rxPowerSavingPreambleForParams(uint8_t, float) { return 32; } bool applySavedRadioParams(); void startup() { @STARTUP@ } - void service() { @SERVICE@ } + void service(bool tx_gap = false) { @SERVICE@ } }; @APPLY@ using Node = @CLASS@; @@ -145,7 +170,7 @@ static void restored(const Node& node) { assert(node._cli.profiles.live_sf == node._prefs.sf); assert(node.active_cr == node._prefs.cr); assert(radio_driver._cur_dbm == node._prefs.tx_power_dbm); -#if ROOM_ROLE +#if 1 if (radio_driver.supported) assert(radio_driver._cur_rx_boosted_gain == node._prefs.rx_boosted_gain); #endif @@ -155,12 +180,10 @@ static unsigned calls(const Node& node) { } static void startupAndRetry() { for (unsigned fault = 0; fault < 3; ++fault) { -#if !ROOM_ROLE - if (fault == 0) continue; // Sensor has no RX-gain feature. -#endif + Node node; node._prefs.rx_powersaving_enabled = true; -#if ROOM_ROLE +#if 1 if (fault == 0) radio_driver.gain_results = {false, false, true}; #endif if (fault == 1) node._cli.profiles.results = {Apply::BUSY, Apply::FAILED, Apply::APPLIED}; @@ -221,12 +244,10 @@ static void receiveBetweenTupleAndPower() { } static void tempRadioRestoration() { for (bool pending : {false, true}) for (unsigned fault = 0; fault < 3; ++fault) { -#if !ROOM_ROLE - if (fault == 0) continue; -#endif + Node node; node.saved_radio_apply_pending = pending; node.temp_radio_applied = true; node.revert_radio_at = now_ms - 1; -#if ROOM_ROLE +#if 1 if (fault == 0) radio_driver.gain_results = {false, true}; #endif if (fault == 1) node._cli.profiles.results = {Apply::BUSY, Apply::APPLIED}; @@ -255,7 +276,48 @@ static void tempRadioRestoration() { } } int main() { +#if defined(USE_LR2021) + { + Node node; node.startup(); + assert(!node.saved_radio_apply_pending && radio_driver.side_sfs[0] == 10); + assert(radio_driver.side_commands == 1); + } + { + Node node; radio_driver.side_results = {false, true}; node.startup(); + assert(node.saved_radio_apply_pending && "startup must retain incomplete saved radio work"); + node.service(); assert(!node.saved_radio_apply_pending && radio_driver.side_sfs[0] == 10); + } + { + Node node; node._prefs.extra_sf[0] = radio_driver.side_sfs[0] = 7; + node.startup(); + assert(!node.saved_radio_apply_pending && !node._prefs.extra_sf[0]); + assert(!radio_driver.side_sfs[0] && node.saves == 1 && radio_driver.side_commands == 2); + } + { + Node node; radio_driver.profile_state.active = true; node.startup(); + assert(!node.saved_radio_apply_pending && !radio_driver.side_commands); + assert(node._prefs.extra_sf[0] == 10); // Retain saved intent while dual radio disables it. + } +#endif startupAndRetry(); receiveBetweenTupleAndPower(); tempRadioRestoration(); + { + Node node; + node.outbound = true; + node.set_radio_at = now_ms - 1; + node.revert_radio_at = now_ms + 10000; + node.service(true); + assert(!calls(node) && !node.temp_radio_applied && node.set_radio_at); + node.outbound = false; node.service(); + assert(node.temp_radio_applied && !node.set_radio_at); + } + { + Node node; + node.outbound = true; + node.saved_radio_apply_pending = true; + node.service(); assert(!calls(node)); + node.service(true); // A queued next packet cannot starve the completed-TX gap. + assert(!node.saved_radio_apply_pending); restored(node); + } std::puts("PASS: production saved radio startup/retry/TempRadio restoration"); } ''' @@ -273,6 +335,10 @@ def harness(role, mutation=None): checked = "&& !radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain)) return false;" assert checked in apply apply = apply.replace(checked, ") (void)radio_driver.setRxBoostedGainMode(_prefs.rx_boosted_gain);") + elif mutation == "unchecked-side": + checked = "&& !radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw)) return false;" + assert checked in apply + apply = apply.replace(checked, ") (void)radio_driver.configSideDetectors(_prefs.extra_sf, extra_sf_count, _prefs.bw);") begin = body(source, f"void {name}::begin(") start = begin.index(" saved_radio_apply_pending = !applySavedRadioParams();") startup = begin[start:begin.index(" const bool fem_gain_changed", start)] @@ -284,8 +350,8 @@ def harness(role, mutation=None): "void RadioLibWrapper::endReconfigure(bool resume_rx)", "bool RadioLibWrapper::setTxPower(int8_t dbm)", "mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm)"] - if role == "room": - signatures.append("bool RadioLibWrapper::setRxBoostedGainMode(bool enabled)") + signatures.extend(["bool RadioLibWrapper::setRxBoostedGainMode(bool enabled)", + "mesh::RadioParamApplyResult RadioLibWrapper::trySetRxBoostedGainMode(bool enabled)"]) methods = "\n".join(body(wrapper, signature) for signature in signatures) methods = methods.replace("RadioLibWrapper::", "Driver::") values = { @@ -300,7 +366,7 @@ def harness(role, mutation=None): class RoomSensorRadioRestoreTests(unittest.TestCase): - def run_case(self, role, mutation=None): + def run_case(self, role, mutation=None, lr2021=False): compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") self.assertIsNotNone(compiler) with tempfile.TemporaryDirectory(prefix="room-sensor-radio-restore-") as folder: @@ -310,6 +376,8 @@ class RoomSensorRadioRestoreTests(unittest.TestCase): flags = [] if os.name == "nt" else ["-fsanitize=address,undefined", "-fno-pie", "-no-pie"] built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", f"-DROOM_ROLE={int(role == 'room')}", *flags, + *(["-DUSE_LR2021=1"] if lr2021 else []), + "-I", str(ROOT / "src"), str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=45) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) @@ -334,6 +402,12 @@ class RoomSensorRadioRestoreTests(unittest.TestCase): def test_old_unchecked_room_gain_cannot_complete(self): self.run_case("room", "unchecked-gain") + def test_lr2021_saved_detectors_restore_or_keep_recovery_pending(self): + for role in ROLES: + with self.subTest(role=role): + self.run_case(role, lr2021=True) + self.run_case(role, "unchecked-side", lr2021=True) + if __name__ == "__main__": unittest.main() diff --git a/test/test_room_web_http.py b/test/test_room_web_http.py index 974588247..ad75c5f8b 100644 --- a/test/test_room_web_http.py +++ b/test/test_room_web_http.py @@ -166,6 +166,10 @@ struct WebConfigServer { struct Terminal { bool running = false; struct { bool pending = false; } queue; }; Terminal* _terminal = nullptr; bool _stopping = false, _stop_warned = false, _board_cmds_probed = true; + int _start_mode = MODE_OFF; + uint32_t _start_retry_at = 0, _start_retry_delay = 5000; + bool _start_retryable = false; + void serviceStartRetry(uint32_t) {} uint32_t _handler_refs = 0, _stop_warn_at = 0, _last_activity = 0; uint32_t _network_activity_until = 0; std::mutex mutex; diff --git a/test/test_rs232_mqtt_runtime.py b/test/test_rs232_mqtt_runtime.py index 1aedad5d8..3d60f4a41 100644 --- a/test/test_rs232_mqtt_runtime.py +++ b/test/test_rs232_mqtt_runtime.py @@ -43,6 +43,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase): "bool startSharedEspNowBridgeIfReady()", "bool isEspNowBridgeRunning()", "bool setEspNowBridgeState(bool enable)", "bool restartEspNowBridge()", "bool isMqttBridgeRunning()", "bool requestMqttBridgeStop()", "bool isMqttBridgeStopping()", + "bool isMqttBridgeStartPending()", "bool setMqttBridgeState(bool enable)", "bool restartMqttBridge()", "bool setBridgeState(bool enable)", "bool restartBridge()", )) @@ -141,7 +142,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase): "bool rs232BridgeEnabled() const", "bool isBridgeRunning() const override", "bool isRs232BridgeRunning()", "bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()", "bool isMqttBridgeRunning()", "bool requestMqttBridgeStop()", - "bool isMqttBridgeStopping()", "bool setBridgeState(bool enable)", + "bool isMqttBridgeStopping()", "bool isMqttBridgeStartPending()", "bool setBridgeState(bool enable)", "bool restartBridge()")) code = (ROOT / "test/fixtures/rs232_mqtt_runtime.cpp").read_text(encoding="ascii") code = code[:code.index("int main() {")] @@ -225,10 +226,10 @@ class RS232MQTTRuntimeTests(unittest.TestCase): assert(!mesh.bridge && !mesh.isMqttBridgeRunning() && !mesh.bridgesPreventSleep()); cli.set("rs232.enabled on", reply); mesh.mqtt_bridge->fail_starts = 1; cli.set("mqtt.enabled on", reply); - assert(strstr(reply, "setting saved") && !mesh.isMqttBridgeRunning()); + assert(!strcmp(reply, "OK") && !mesh.isMqttBridgeRunning() && mesh.isMqttBridgeStartPending()); assert(mesh.isRs232BridgeRunning() && mesh.bridgesPreventSleep()); - cli.set("mqtt.enabled on", reply); - assert(strcmp(reply, "OK") == 0 && mesh.isMqttBridgeRunning()); + mesh.serviceBridges(); // Automatic start retries without another CLI attempt. + assert(mesh.isMqttBridgeRunning() && !mesh.isMqttBridgeStartPending()); }''' with tempfile.TemporaryDirectory(prefix="rs232-mqtt-only-") as directory: work = Path(directory) diff --git a/test/test_rxps_hardware_capability.py b/test/test_rxps_hardware_capability.py index 234d93ade..ac446d9b7 100644 --- a/test/test_rxps_hardware_capability.py +++ b/test/test_rxps_hardware_capability.py @@ -45,11 +45,25 @@ struct NodePrefs { }; struct Callbacks { unsigned calls=0; + bool supportsRxPowerSaving() const { return false; } bool setRxPowerSaving(bool on,uint32_t,uint32_t){++calls;return !on;} }; struct CommonCLI { NodePrefs* _prefs; Callbacks* _callbacks; unsigned saves=0; CommonCLI(NodePrefs& p,Callbacks& c):_prefs(&p),_callbacks(&c){} + static constexpr uint8_t RxPowerSaving = 2; + uint8_t _rx_settings_pending = 0; + uint32_t _rx_settings_retry_at = 0; + mesh::RadioParamApplyResult applyRxSetting(uint8_t) { + return _callbacks->setRxPowerSaving(_prefs->rx_powersaving_enabled, + _prefs->rx_ps_rx_us, _prefs->rx_ps_sleep_us) + ? mesh::RadioParamApplyResult::APPLIED : mesh::RadioParamApplyResult::FAILED; + } + bool saveRxSetting(uint8_t, mesh::RadioParamApplyResult result, char*) { + if (result == mesh::RadioParamApplyResult::FAILED) return false; + ++saves; return true; + } + void restoreRxSetting(uint8_t, mesh::RadioParamApplyResult, uint8_t, uint32_t) {} static bool calculateRxPowerSavingLevel(uint32_t,uint8_t,float,uint32_t,uint32_t*,uint32_t*); static bool recalculateRxPowerSavingFromLevel(NodePrefs*); void savePrefs(){++saves;} @@ -177,7 +191,7 @@ int main() { common = (ROOT / 'src/helpers/CommonCLI.cpp').read_text() setter = body(common, 'if (memcmp(config, "radio.rxps ", 11) == 0)') if mutate: - guard = body(setter, 'if (!WRAPPER_CLASS::SupportsRxPowerSaving)') + guard = body(setter, 'if (!_callbacks->supportsRxPowerSaving()') setter = setter.replace(guard, '', 1) code = HARNESS.replace('@SETTER@', setter) code = code.replace('@GETTER@', body(common, 'if (strcmp(config, "radio.rxps.config") == 0)')) diff --git a/test/test_rxps_runtime.py b/test/test_rxps_runtime.py index 505830e34..7ad95b06d 100644 --- a/test/test_rxps_runtime.py +++ b/test/test_rxps_runtime.py @@ -15,6 +15,7 @@ HARNESS = r''' #include #include #include +namespace mesh { enum class RadioParamApplyResult : uint8_t { APPLIED, BUSY, FAILED }; } #define MESH_ENABLE_TX_BEACONS 1 #define MESH_DEBUG_PRINTLN(...) ((void)0) @@ -29,6 +30,7 @@ struct RadioLibWrapper { bool _rx_ps_enabled = true, _rx_ps_armed = false; bool _rx_ps_continuous_fallback = false, _nf_calib_active = false; bool _profile_rxps_suspended = false; + bool _profile_repair_required = false; bool _profile_saved_rxps = false, _beacon_owned = false, _cw_active = false; uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000; uint32_t _rx_ps_eff_rx_us = 0, _rx_ps_eff_sleep_us = 0; @@ -72,6 +74,7 @@ struct RadioLibWrapper { int startReceivePowerSaving(); void stopReceiveDutyCycle(); bool setRxPowerSaving(bool, uint32_t, uint32_t); + mesh::RadioParamApplyResult trySetRxPowerSaving(bool, uint32_t, uint32_t); RxPowerSavingStatus getRxPowerSavingStatus() const; @RESET_STATS@ }; @@ -98,6 +101,26 @@ static void exhaust(RadioLibWrapper& radio) { } int main() { + { + using Result = mesh::RadioParamApplyResult; + RadioLibWrapper radio; + assert(radio.trySetRxPowerSaving(true, 50000, 50000) == Result::APPLIED); + assert(!radio.begin_calls && !radio.end_calls); // Same accepted timings do not re-arm RX. + radio.reconfigure_result = 2; + assert(radio.trySetRxPowerSaving(true, 70000, 90000) == Result::BUSY); + assert(radio._rx_ps_rx_us == 50000 && !radio.end_calls); + radio.reconfigure_result = 3; + assert(radio.trySetRxPowerSaving(true, 70000, 90000) == Result::FAILED); + assert(radio._rx_ps_rx_us == 50000 && !radio.end_calls); + radio.supported = false; + const unsigned before = radio.begin_calls; + assert(radio.trySetRxPowerSaving(true, 70000, 90000) == Result::FAILED); + assert(radio.trySetRxPowerSaving(true, 0, 90000) == Result::FAILED); + assert(radio.begin_calls == before); + radio.supported = true; radio._cw_active = true; + assert(radio.trySetRxPowerSaving(true, 50000, 50000) == Result::BUSY); + assert(radio.begin_calls == before); + } { RadioLibWrapper radio; exhaust(radio); @@ -214,6 +237,7 @@ def production_source(): "int RadioLibWrapper::startReceivePowerSaving()", "void RadioLibWrapper::stopReceiveDutyCycle()", "bool RadioLibWrapper::setRxPowerSaving(", + "mesh::RadioParamApplyResult RadioLibWrapper::trySetRxPowerSaving(", "RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus()")) return HARNESS.replace("@METHODS@", methods).replace( "@RESET_STATS@", body(header, "void resetStats()")) diff --git a/test/test_sensor_tracker_sleep.py b/test/test_sensor_tracker_sleep.py index 6b1ad84f8..4197948b3 100644 --- a/test/test_sensor_tracker_sleep.py +++ b/test/test_sensor_tracker_sleep.py @@ -34,7 +34,7 @@ struct Radio {bool watchdog=false,calibration=false; struct Cli { bool gpio=false,tx_pending=false; bool hasActiveUserGpioTimer()const{return gpio;} - bool hasPendingTxPower()const{return tx_pending;} + bool hasPendingRadioSettings()const{return tx_pending;} }; struct Manager {int queued=0;int getOutboundTotal()const{return queued;}}; struct Clock {uint32_t now=100;uint32_t getCurrentTime()const{return now;}}; diff --git a/test/test_service_start_retry.py b/test/test_service_start_retry.py new file mode 100644 index 000000000..a98934acb --- /dev/null +++ b/test/test_service_start_retry.py @@ -0,0 +1,303 @@ +"""Execute service start intent, bounded retries and explicit cancellation.""" +from pathlib import Path +import unittest + +from test_replay_reset_integration import extract_braced +import test_wifi_ota_start as wifi_start +import test_mqtt_transport_results as mqtt + +ROOT = Path(__file__).resolve().parents[1] + +WEB = r''' +#include +#include +#include +#include +uint32_t now = 1000; +uint32_t millis() { return now; } +namespace mesh { +namespace wireless { +enum { WiFi = 1 }; +struct Control { bool disabled = false; bool blocked(int) { return disabled; } }; +Control& control() { static Control c; return c; } +} +namespace wifi { +bool compatible = true; +bool accessPointCompatibleWithLongRange() { return compatible; } +} +struct Log { void println(const char*) {} }; +Log& usbDebugPort() { static Log log; return log; } +} +struct Endpoint { unsigned stops = 0; void end() { ++stops; } void stop() { ++stops; } }; +struct WebConfigServer { + enum Mode { MODE_OFF, MODE_SETUP, MODE_CONNECTING, MODE_LAN }; + Mode _mode = MODE_OFF, _start_mode = MODE_OFF; + uint32_t _start_retry_at = 0, _start_retry_delay = 5000; + bool _start_retryable = false, _stopping = false, _stop_warned = false; + uint32_t _stop_warn_at = 0; + static constexpr unsigned STOP_WARN_MS = 1000; + Endpoint server, dns; Endpoint* _server = &server; Endpoint* _dns = &dns; + char _wifi_ssid[32] = {}; + bool fail_start = false, invalid = false; + unsigned starts = 0; bool last_ap = false; char last_ssid[32] = {}; + bool isRunning() const { return _mode != MODE_OFF; } + bool isStopping() const { return _stopping; } + bool isStartPending() const { return _start_mode != MODE_OFF; } + void detachRoutes() {} + void refreshNetworkPowerHold(uint32_t) {} + bool start(bool ap, char* reply) { + ++starts; last_ap = ap; strcpy(last_ssid, _wifi_ssid); + _start_retryable = fail_start && !invalid; + if (fail_start || invalid) { strcpy(reply, "Err: startup failed"); return false; } + _mode = ap ? MODE_SETUP : MODE_CONNECTING; + strcpy(reply, "WebConfig started"); return true; + } + bool startSetupMode(char* reply) { return start(true, reply); } + bool startAutoMode(char* reply) { return start(false, reply); } + bool requestStart(bool, char*); + void requestRestart(); void requestStop(); void serviceStartRetry(uint32_t); +}; +@METHODS@ +int main() { + char reply[160]; WebConfigServer s; + s.fail_start = true; + assert(s.requestStart(true, reply) && s.isStartPending() && s.starts == 1); + assert(strstr(reply, "pending")); + for (unsigned i = 0; i < 100; ++i) s.serviceStartRetry(now); + assert(s.starts == 1); + now = s._start_retry_at - 1; s.serviceStartRetry(now); assert(s.starts == 1); + ++now; s.serviceStartRetry(now); assert(s.starts == 2); + assert(s._start_retry_at == now + 10000); + for (unsigned i = 0; i < 8; ++i) { + now = s._start_retry_at; s.serviceStartRetry(now); + assert(s._start_retry_at > now && s._start_retry_at <= now + 60000); + } + s.fail_start = false; now = s._start_retry_at; s.serviceStartRetry(now); + assert(s.isRunning() && !s.isStartPending() && s.last_ap); + + // Off/on during teardown waits for cleanup and never loses desired on. + s.requestStop(); const auto before = s.starts; + assert(s.requestStart(false, reply) && s.isStartPending()); + now = s._start_retry_at; s.serviceStartRetry(now); assert(s.starts == before); + s._stopping = false; s.serviceStartRetry(now); + assert(s.starts == before + 1 && !s.last_ap && !s.isStartPending()); + + // SSID/password writes share one restart with the newest saved tuple. + strcpy(s._wifi_ssid, "first"); s.requestRestart(); + now += 500; strcpy(s._wifi_ssid, "latest"); s.requestRestart(); + s._stopping = false; now += 999; s.serviceStartRetry(now); + assert(s.starts == before + 1); + ++now; s.serviceStartRetry(now); + assert(s.starts == before + 2 && !strcmp(s.last_ssid, "latest")); + + // Explicit off cancels both a deferred teardown restart and a failed start. + s.requestRestart(); s.requestStop(); + s.requestRestart(); // A credential save during explicit off must stay off. + assert(!s.isStartPending()); s._stopping = false; + now += 120000; s.serviceStartRetry(now); + assert(!s.isStartPending() && s.starts == before + 2); + s.fail_start = true; assert(s.requestStart(true, reply)); s.requestStop(); + now += 120000; s.serviceStartRetry(now); assert(!s.isStartPending()); + + // Configuration and ownership conflicts remain errors, not accepted jobs. + s.fail_start = false; s.invalid = true; + assert(!s.requestStart(false, reply) && !s.isStartPending()); s.invalid = false; + mesh::wireless::control().disabled = true; + assert(!s.requestStart(false, reply) && !s.isStartPending()); + mesh::wireless::control().disabled = false; mesh::wifi::compatible = false; + assert(!s.requestStart(true, reply) && !s.isStartPending()); + mesh::wifi::compatible = true; + + // Retry deadlines remain correct when millis() wraps. + now = UINT32_MAX - 500; s.fail_start = true; assert(s.requestStart(true, reply)); + const auto attempts = s.starts; + now += 4999; s.serviceStartRetry(now); assert(s.starts == attempts); + ++now; s.fail_start = false; s.serviceStartRetry(now); + assert(s.starts == attempts + 1 && !s.isStartPending()); +} +''' + + +class ServiceStartRetryTests(unittest.TestCase): + compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run + + def test_webconfig_retry_coalescing_cancellation_conflicts_and_rollover(self): + source = (ROOT / 'src/helpers/esp32/WebConfigServer.cpp').read_text() + methods = '\n'.join(extract_braced(source, signature) for signature in ( + 'bool WebConfigServer::requestStart(', 'void WebConfigServer::requestRestart(', + 'void WebConfigServer::requestStop(', 'void WebConfigServer::serviceStartRetry(')) + self.compile_and_run(WEB.replace('@METHODS@', methods)) + + def test_pending_starts_are_owned_work_without_claiming_mqtt_running(self): + for role, worker in (('simple_repeater', 'mqtt_bridge'), + ('simple_room_server', 'bridge')): + with self.subTest(role=role): + header = (ROOT / 'examples' / role / 'MyMesh.h').read_text() + methods = '\n'.join(extract_braced(header, signature).replace(' override', '') + for signature in ('bool isWebConfigActive() const', + 'bool isMqttBridgeRunning()', + 'bool isMqttBridgeStartPending()')) + fixture = r''' +#include +struct Service { + bool running = false, stopping = false, pending = true; + bool isRunning() const { return running; } + bool isStopping() const { return stopping; } + bool isStartPending() const { return pending; } +} web, mqtt; +struct MyMesh { + Service* _webconfig = &web; + Service* @WORKER@ = &mqtt; +@METHODS@ +}; +int main() { + MyMesh node; + assert(node.isWebConfigActive()); + assert(!node.isMqttBridgeRunning() && node.isMqttBridgeStartPending()); + web.pending = mqtt.pending = false; + assert(!node.isWebConfigActive() && !node.isMqttBridgeStartPending()); +} +'''.replace('@METHODS@', methods).replace('@WORKER@', worker) + self.compile_and_run(fixture, '-DWITH_MQTT_BRIDGE=1') + + def test_companion_credential_reload_finishes_with_a_restarted_lan_listener(self): + source = (ROOT / 'examples/companion_radio/main.cpp').read_text() + actual = extract_braced(source, 'static void serviceCompanionWiFiCredentialReload(') + fixture = r''' +#include +#include +#include +#define WITH_WEBCONFIG 1 +#define WIFI_DEBUG_PRINTLN(...) ((void)0) +uint32_t now = 1000; +uint32_t millis() { return now; } +bool companion_wifi_credential_reload_pending = true; +uint32_t companion_wifi_credential_reload_at = 1500; +bool companion_wifi_espnow_restore_pending = true; +bool companion_wifi_requested = true, companion_wifi_active = true; +bool companion_wifi_has_credentials = true; +char configured_wifi_ssid[32] = {}, configured_wifi_password[65] = {}; +const char* saved_ssid = "latest", *saved_password = "latest-password"; +bool suspended = false, station_allowed = true; +bool companionWiFiBackgroundSuspended() { return suspended; } +void loadCompanionWiFiCredentials() { + strcpy(configured_wifi_ssid, saved_ssid); + strcpy(configured_wifi_password, saved_password); +} +void resetCompanionWiFiRecoveryState() {} +struct { + bool transition = false, active = true; + bool isWebConfigWiFiTransitionActive() const { return transition; } + bool isWebConfigActiveOrStopping() const { return active; } +} the_mesh; +constexpr int WIFI_STA = 1, WIFI_IF_STA = 0; +struct { void mode(int) {} void disconnect(bool, bool) {} } WiFi; +namespace mesh { namespace wifi { +unsigned starts = 0; +bool stationMutationAllowed() { return station_allowed; } +void applyProtocolMask(int) {} void setStationAutoReconnect(bool) {} +void beginStation(const char* ssid, const char* password) { + ++starts; assert(!strcmp(ssid, "latest")); assert(!strcmp(password, "latest-password")); +} +} } +struct { void noteAttempt(uint32_t) {} } wifi_reconnect_tracker; +@METHOD@ +int main() { + serviceCompanionWiFiCredentialReload(); assert(companion_wifi_credential_reload_pending); + now = 1500; the_mesh.transition = true; + serviceCompanionWiFiCredentialReload(); assert(companion_wifi_credential_reload_pending); + the_mesh.transition = false; // The LAN listener is active, but does not own association. + assert(the_mesh.isWebConfigActiveOrStopping()); + serviceCompanionWiFiCredentialReload(); + assert(!companion_wifi_credential_reload_pending && !companion_wifi_espnow_restore_pending); + assert(mesh::wifi::starts == 1); + serviceCompanionWiFiCredentialReload(); assert(mesh::wifi::starts == 1); +} +'''.replace('@METHOD@', actual) + self.compile_and_run(fixture) + + def test_mqtt_reenable_waits_for_cleanup_and_failed_start_retries_without_leaks(self): + source = (ROOT / 'src/helpers/bridges/MQTTBridge.cpp').read_text() + # Execute the real begin admission and complete queue/task acquisition, + # rollback and commit. Endpoint parsing between them has its own tests. + begin = extract_braced(source, 'void MQTTBridge::begin()') + admission = begin[:begin.index(' cancelNtpRefresh();')] + resources = begin[begin.index(' // Reset the handshake'):] + begin = admission + ' ++starts; allocateRuntimeBuffers();\n' + resources + preamble = mqtt.ASYNC_STOP_PREAMBLE.replace(' void begin() { assert(false); }', r''' + unsigned starts = 0; + void* _packet_queue_handle = nullptr; uint8_t* _packet_queue_storage = nullptr; + int _packet_queue_struct = 0; void* _mqtt_task_handle = nullptr; + struct QueuedPacket { uint8_t data[8]; }; + static constexpr unsigned MAX_QUEUE_SIZE = 4; + static void mqttTask(void*) {} + void allocateRuntimeBuffers() { resources_owned = true; } + void releaseRuntimeBuffers() { resources_owned = false; } + void begin(); void requestRestart(); +''') + allocator = r''' +#include +#define BOARD_HAS_PSRAM 1 +using BaseType_t = int; +constexpr int pdPASS = 1; +bool fail_queue = false, fail_task = false; +unsigned storage_live = 0, queues_live = 0; +void* psram_malloc(size_t n) { ++storage_live; return malloc(n); } +void psram_free(void* p) { if (p) { --storage_live; free(p); } } +void* xQueueCreateStatic(unsigned, size_t, uint8_t*, int*) { + if (fail_queue) return nullptr; ++queues_live; return malloc(1); +} +void* xQueueCreate(unsigned n, size_t size) { return xQueueCreateStatic(n, size, nullptr, nullptr); } +void vQueueDelete(void* p) { assert(p); --queues_live; free(p); } +int xTaskCreatePinnedToCore(void (*)(void*), const char*, unsigned, void*, unsigned, void** task, int) { + *task = fail_task ? nullptr : reinterpret_cast(1); return fail_task ? 0 : pdPASS; +} +''' + # The fake endpoint's release effect owns the same queue/storage that + # the production begin block acquires; lifecycle ordering stays real. + preamble = preamble.replace('struct MQTTBridge;', allocator + '\nstruct MQTTBridge;', 1) + preamble = preamble.replace(' _b->resources_owned = false;', ''' _b->resources_owned = false; + if (_b->_packet_queue_handle) vQueueDelete(_b->_packet_queue_handle); + _b->_packet_queue_handle = nullptr; + psram_free(_b->_packet_queue_storage); _b->_packet_queue_storage = nullptr;''') + methods = '\n'.join(extract_braced(source, signature) for signature in ( + 'static inline uint32_t mqttStopTimeoutForSlots(int slots)', + 'void MQTTBridge::requestStop()', 'void MQTTBridge::requestRestart()', + 'void MQTTBridge::end()', 'void MQTTBridge::finishStopped()', + 'void MQTTBridge::loop()', 'uint32_t MQTTBridge::LifecycleOps::nowMs()', + 'void MQTTBridge::LifecycleOps::startTask()', + 'void MQTTBridge::LifecycleOps::deliverStop()')) + scenarios = r''' +int main() { + MQTTBridge b; bridge_waiting = &b; + b.requestStop(); b.begin(); // A fresh on request cannot cancel an active worker stop. + assert(b._start_requested && b._lifecycle.isStopInProgress() && !b.starts); + for (unsigned i = 0; i < 20; ++i) b.loop(); + assert(!b.starts && b.releases == 0); + b._stop_acked = true; b.loop(); assert(!b._initialized && b.releases == 1); + fail_queue = true; b.loop(); + assert(b.starts == 1 && !b._initialized && b._start_requested); + assert(!b.resources_owned && !queues_live && !storage_live); + for (unsigned i = 0; i < 100; ++i) b.loop(); assert(b.starts == 1); + clock_ms += 4999; b.loop(); assert(b.starts == 1); + ++clock_ms; fail_queue = false; fail_task = true; b.loop(); + assert(b.starts == 2 && !b._initialized && !queues_live && !storage_live); + clock_ms += 5000; fail_task = false; b.loop(); + assert(b.starts == 3 && b._initialized && queues_live == 1 && storage_live == 1); + b.requestRestart(); b.requestStop(); // Explicit off cancels a queued restart. + b._stop_acked = true; b.loop(); clock_ms += 10000; b.loop(); + assert(!b._start_requested && !b._initialized && b.starts == 3); + assert(!queues_live && !storage_live); + clock_ms = UINT32_MAX - 100; fail_queue = true; b.begin(); + const auto before = b.starts; + clock_ms += 4999; b.loop(); assert(b.starts == before); + ++clock_ms; b.loop(); assert(b.starts == before + 1); + b.end(); assert(!b._start_requested); // Off cancels even an uninitialized failure. + clock_ms += 10000; b.loop(); assert(b.starts == before + 1); +} +''' + self.compile_and_run(preamble + begin + methods + scenarios) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_udp_bridge_integration.py b/test/test_udp_bridge_integration.py index 5cd415ecb..0b3ccdf6a 100644 --- a/test/test_udp_bridge_integration.py +++ b/test/test_udp_bridge_integration.py @@ -245,6 +245,7 @@ class MyMesh : public Base { public: bool isUDPBridgeNetworkEnabled() const { return true; } bool isMqttBridgeRunning() const { return false; } + bool isMqttBridgeStartPending() const { return false; } @ACCESS@: @METHOD@ public: @@ -303,6 +304,7 @@ struct { bool isUDPBridgeConfigured()const{return configured;} bool setUDPBridgeNetworkState(bool enabled){paused=!enabled;events.push_back(enabled?5:1);if(enabled)WiFi.mode(WIFI_STA);return true;} bool isMqttBridgeRunning()const{return mqtt;} + bool isMqttBridgeStartPending()const{return false;} bool setMqttBridgeState(bool value){mqtt=value;return true;} bool isWebConfigActive()const{return web;} bool isWebConfigStopping()const{return false;} diff --git a/test/test_usb_logging_watchdog_roles.py b/test/test_usb_logging_watchdog_roles.py index 805de22cd..8b29ead50 100644 --- a/test/test_usb_logging_watchdog_roles.py +++ b/test/test_usb_logging_watchdog_roles.py @@ -45,9 +45,12 @@ struct SerialInterface { bool isPassthroughMode() const{return passthrough;} }; struct RoleMesh { + int8_t rf_rx_disable_pending=-1; + struct {bool pending=false;bool hasPendingTuning() const{return pending;}} _radio_profiles; struct { bool pending=false; bool hasPendingTxPower() const{return pending;} + bool hasPendingRadioSettings() const{return pending;} } _cli; uint32_t dirty_contacts_expiry=0, _ota_update_at=0, set_radio_at=0, _scheduled_reboot_at=0; @@ -199,10 +202,12 @@ class UsbLoggingWatchdogRoleTests(unittest.TestCase): callback = method((directory / 'main.cpp').read_text(), 'static bool usbLoggingRecoverySafe(void*)') role_checks = r''' - the_mesh.command_radio_apply_pending=true;assert(!usbLoggingRecoverySafe(nullptr)); - the_mesh.command_radio_apply_pending=false; the_mesh.tx_power_apply_pending=true;assert(!usbLoggingRecoverySafe(nullptr)); the_mesh.tx_power_apply_pending=false; + the_mesh.rf_rx_disable_pending=1;assert(!usbLoggingRecoverySafe(nullptr)); + the_mesh.rf_rx_disable_pending=-1; + the_mesh._radio_profiles.pending=true;assert(!usbLoggingRecoverySafe(nullptr)); + the_mesh._radio_profiles.pending=false; for(auto slot : {&the_mesh._delayed_replies.request, &the_mesh._delayed_replies.trace}){ slot->phase=mesh::CompanionDelayedReplies::AwaitRadio; assert(!usbLoggingRecoverySafe(nullptr)); diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index 4b87465d9..ff7503b21 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -198,6 +198,7 @@ struct MockMesh { bool web = false, mqtt = false, espnow = false; bool isWebConfigActive() const { return web; } bool isMqttBridgeRunning() const { return mqtt; } + bool isMqttBridgeStartPending() const { return false; } bool isEspNowBridgeRunning() const { return espnow; } } the_mesh; diff --git a/test/test_wifi_ota_start.py b/test/test_wifi_ota_start.py index 897f9267e..c1ac87e80 100644 --- a/test/test_wifi_ota_start.py +++ b/test/test_wifi_ota_start.py @@ -383,7 +383,7 @@ int main() { #include #include struct Callbacks { - bool active = true, allow_stop = true, mqtt = true, espnow = true; + bool active = true, allow_stop = true, mqtt = true, espnow = true, mqtt_pending = false; int stops = 0, bridge_stops = 0, bridge_starts = 0; int espnow_stops = 0, espnow_starts = 0; bool isWebConfigActive() const { return active; } @@ -393,10 +393,11 @@ struct Callbacks { active = false; strcpy(reply, "WebConfig stopped"); return true; } bool isMqttBridgeRunning() const { return mqtt; } + bool isMqttBridgeStartPending() const { return mqtt_pending; } bool isEspNowBridgeRunning() const { return espnow; } bool setMqttBridgeState(bool enabled) { if (enabled) ++bridge_starts; else ++bridge_stops; - mqtt = enabled; return true; + mqtt = enabled; mqtt_pending = false; return true; } bool setEspNowBridgeState(bool enabled) { if (enabled) ++espnow_starts; else ++espnow_stops; @@ -474,6 +475,12 @@ int main() { cli.run("start ota ap", reply.text); cli.run("stop ota", reply.text); assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 1); + // A pending-only start is paused before OTA and resumed only after stop. + callbacks.active = false; callbacks.mqtt = false; callbacks.mqtt_pending = true; + cli.run("start ota", reply.text); + assert(!callbacks.mqtt_pending && !callbacks.mqtt && callbacks.bridge_stops == 2); + cli.run("stop ota", reply.text); + assert(callbacks.mqtt && callbacks.bridge_starts == 1); assert(reply.guard == 123); } ''' diff --git a/test/test_wireless_control.py b/test/test_wireless_control.py index a93e6d538..3e231bbf3 100644 --- a/test/test_wireless_control.py +++ b/test/test_wireless_control.py @@ -348,15 +348,16 @@ struct { void init(){active=true;}void end(){active=false;} } radio_driver; struct { - bool web=true,stopping=false,bridge=true,network=false; + bool web=true,stopping=false,bridge=true,network=false,mqtt_pending=false; bool isWebConfigActive(){return web||stopping;} bool isWebConfigStopping(){return stopping;} bool hasWirelessNetworkClient(){return network;} bool isBridgeRunning(){return bridge;} bool setBridgeState(bool on){bridge=on;return true;} bool isMqttBridgeRunning(){return bridge;} + bool isMqttBridgeStartPending(){return mqtt_pending;} bool isEspNowBridgeRunning(){return bridge;} - bool setMqttBridgeState(bool on){bridge=on;return true;} + bool setMqttBridgeState(bool on){bridge=on;mqtt_pending=false;return true;} bool setEspNowBridgeState(bool on){bridge=on;return true;} bool stopWebConfig(char*){web=false;stopping=true;return true;} bool startWebConfig(bool,char*){web=true;return true;} @@ -382,6 +383,14 @@ int main(){ InfrastructureWirelessBackend ota_backend; assert(ota_backend.set(mesh::wireless::WiFi,false)==Result::Done); assert(!board.ota); + // Failed startup is still requested WiFi work and must participate in off/on. + the_mesh.mqtt_pending=true; + assert(b.enabled()&mesh::wireless::WiFi); + InfrastructureWirelessBackend pending_backend; + assert(pending_backend.set(mesh::wireless::WiFi,false)==Result::Done); + assert(!the_mesh.mqtt_pending&&!the_mesh.bridge); + assert(pending_backend.set(mesh::wireless::WiFi,true)==Result::Done); + assert(the_mesh.bridge&&!the_mesh.web); the_mesh.web=the_mesh.bridge=true; // Individual WiFi off retains the ESP-NOW driver and stops both its own services. c.handle("set wifi off",reply,sizeof(reply),600,Independent);c.service(850);