From 8dfdba3f6dd66f37a06beacdc2c64b07f4a46ad7 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sun, 25 Oct 2026 11:57:03 -0700 Subject: [PATCH] Defer busy Companion TX-power updates without interrupting RX A settings save could reach the power setter while RX, TX, duty cycling or a profile transition owned the radio, returning BAD_STATE after earlier settings had already saved. Distinguish busy admission from a hardware rejection and reuse the existing command route, storage and three-second deadline for power. Acknowledge only after hardware acceptance and a successful preference save. Skip hardware work for verified unchanged power. Invalidate that verification after failed writes or radio initialization, retaining rollback and exclusive beacon/Morse ownership. Keep the existing firmware memory limits and features. Cover retries, captured routes, timeout/rollover, disconnect, partial writes, failed saves and recovery. Keep ESP-NOW/simulation adapters compatible and add ESP-NOW Companion compilation to the existing sequential smoke job. --- .github/workflows/run-unit-tests.yml | 7 +- docs/companion_protocol.md | 6 + examples/companion_radio/MyMesh.cpp | 71 +++++-- examples/companion_radio/MyMesh.h | 8 +- examples/companion_radio/main.cpp | 3 + src/helpers/esp32/ESPNOWRadio.h | 4 + src/helpers/radiolib/RadioLibWrappers.cpp | 35 ++-- src/helpers/radiolib/RadioLibWrappers.h | 2 + src/helpers/sim/SimRadio.h | 3 + test/test_beacon_legacy_lora_protocol.py | 2 +- test/test_beacon_radio.py | 11 +- test/test_carrier_wave.py | 8 +- ...st_companion_radio_settings_transaction.py | 177 ++++++++++++++++-- test/test_espnow_radio_lifecycle.py | 7 +- test/test_meshtext_transport.py | 2 +- test/test_radio_tx_power_transaction.py | 175 +++++++++++++++++ test/test_room_sensor_radio_restore.py | 6 +- 17 files changed, 470 insertions(+), 57 deletions(-) create mode 100644 test/test_radio_tx_power_transaction.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 8b01e0ada..22d437af6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -201,9 +201,11 @@ jobs: - uses: actions/checkout@v4 - name: Setup Build Environment uses: ./.github/actions/setup-build-environment - - name: Compile RP2040 contact storage and Arduino 3 ESP32-C6 room services + - name: Compile RP2040 contacts, ESP32-C6 rooms and ESP-NOW Companion # One process: PlatformIO environments share this checkout's build tree. - run: pio run -e Xiao_rp2040_companion_radio_usb -e LilyGo_Tlora_C6_room_server_ + run: >- + pio run -e Xiao_rp2040_companion_radio_usb + -e LilyGo_Tlora_C6_room_server_ -e Generic_ESPNOW_comp_radio_usb management-reports: runs-on: ubuntu-latest @@ -736,6 +738,7 @@ jobs: python3 -B test/test_radio_profile_scan.py python3 -B test/test_radio_profile_clock.py -v python3 -B test/test_radio_profile_repair.py -v + python3 -B test/test_radio_tx_power_transaction.py -v python3 -B test/test_radio_profile_runtime_recommendation.py -v python3 -B test/test_radio_profile_idle_dispatcher.py -v python3 -B test/test_radio_rxps_rejection.py -v diff --git a/docs/companion_protocol.md b/docs/companion_protocol.md index bf7b51f66..3bf98c04f 100644 --- a/docs/companion_protocol.md +++ b/docs/companion_protocol.md @@ -246,6 +246,12 @@ catalog; bytes `0x2C`-`0x31` are parked and `0x35` is unused. The sections below detail the most common frames. Refer to the source named above for command bodies that are not expanded here. +Radio changes (`0x0B`, `0x0C`) can wait briefly for a safe radio window before +replying. TX power waits up to three seconds when reception or transmission is +busy. Its OK response confirms both hardware acceptance and a saved setting; +timeout or disconnect discards a waiting power change. Successful settings +commands are saved independently, so an error does not undo earlier commands. + ### Private text when only the sender knows the recipient Clients continue to send an ordinary `CMD_SEND_TXT_MSG` (`0x02`) with diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index cdbfca1a6..bdb467056 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3546,12 +3546,24 @@ void MyMesh::finishRadioParamApply(float freq, float bw, uint8_t sf, writeOKFrame(route); } +void MyMesh::deferRadioCommand(uint8_t kind) { + command_radio_reply_route = _serial->captureReplyRoute(); + if (command_radio_reply_route == NULL) { + writeErrFrame(ERR_CODE_BAD_STATE); + return; + } + command_radio_apply_pending = kind; + command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS); +} + void MyMesh::cancelPendingRadioParamApply() { if (!command_radio_apply_pending) return; + const bool restore_tuple = command_radio_apply_pending == 1; command_radio_apply_pending = false; command_radio_apply_deadline = 0; command_radio_reply_route = NULL; + if (!restore_tuple) return; // The requested tuple was not committed. Reassert the persisted tuple in // case a failed hardware apply only restored part of the old configuration. saved_radio_apply_pending = true; @@ -3569,6 +3581,22 @@ void MyMesh::servicePendingRadioParamApply() { // Leave enough queue headroom for the eventual command response. if (_serial->isReplyRouteWriteBusy(command_radio_reply_route)) return; + const bool expired = static_cast(static_cast( + _ms->getMillis() - command_radio_apply_deadline)) >= 0; + if (command_radio_apply_pending == 2) { + const auto result = expired ? RadioSettingResult::RadioBusy + : applyAndSaveTxPower(static_cast(command_radio_repeat), true); + if (result == RadioSettingResult::RadioBusy + && !expired) return; + if (result == RadioSettingResult::Saved) writeOKFrame(command_radio_reply_route); + else writeErrFrame(result == RadioSettingResult::SaveFailed + ? ERR_CODE_FILE_IO_ERROR : ERR_CODE_BAD_STATE, command_radio_reply_route); + // The checked setter owns any failed-save rollback. Retire this command + // without forcing a tuple restore or consuming a pending receive. + cancelPendingRadioParamApply(); + return; + } + mesh::RadioParamApplyResult result = tryApplyRadioParams( command_radio_freq, command_radio_bw, command_radio_sf, command_radio_cr); if (result == mesh::RadioParamApplyResult::APPLIED) { @@ -3585,8 +3613,7 @@ void MyMesh::servicePendingRadioParamApply() { } else if (result == mesh::RadioParamApplyResult::FAILED) { writeErrFrame(ERR_CODE_ILLEGAL_ARG, command_radio_reply_route); cancelPendingRadioParamApply(); - } else if (command_radio_apply_deadline == _ms->getMillis() - || millisHasNowPassed(command_radio_apply_deadline)) { + } else if (expired) { writeErrFrame(ERR_CODE_BAD_STATE, command_radio_reply_route); cancelPendingRadioParamApply(); } @@ -3608,7 +3635,7 @@ 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 companion radio change is already pending"); + snprintf(reply, reply_size, "ERR radio change pending"); return false; } @@ -5648,13 +5675,13 @@ void MyMesh::execCommand(char* cmd, char* reply) { if (strcmp(key, "tx") == 0) { long parsed; if (!wcParseLong(value, -9, MAX_LORA_TX_POWER, parsed)) { - snprintf(reply, 160, "Error: TX power must be -9 to %d", MAX_LORA_TX_POWER); + snprintf(reply, 160, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER); } else { const auto result = applyAndSaveTxPower(static_cast(parsed)); strcpy(reply, result == RadioSettingResult::Saved ? "OK" - : result == RadioSettingResult::RadioRejected + : result != RadioSettingResult::SaveFailed ? "Error: radio busy or TX power rejected" - : "Error: TX power could not be saved"); + : "Error: TX not saved"); } return; } @@ -6767,18 +6794,12 @@ void MyMesh::handleCmdFrame(size_t len) { // 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_reply_route = _serial->captureReplyRoute(); - if (command_radio_reply_route == NULL) { - writeErrFrame(ERR_CODE_BAD_STATE); - return; - } - command_radio_apply_pending = true; command_radio_freq = new_freq; command_radio_bw = new_bw; command_radio_sf = sf; command_radio_cr = cr; command_radio_repeat = repeat; - command_radio_apply_deadline = futureMillis(COMMAND_RADIO_APPLY_TIMEOUT_MS); + deferRadioCommand(1); MESH_DEBUG_PRINTLN("Deferred CMD_SET_RADIO_PARAMS while radio is busy"); return; } @@ -6806,8 +6827,15 @@ void MyMesh::handleCmdFrame(size_t len) { int8_t power = (int8_t)cmd_frame[1]; if (power < -9 || power > MAX_LORA_TX_POWER) { writeErrFrame(ERR_CODE_ILLEGAL_ARG); + } else if (command_radio_apply_pending) { + writeErrFrame(ERR_CODE_BAD_STATE); } else { const auto result = applyAndSaveTxPower(power); + if (result == RadioSettingResult::RadioBusy) { + command_radio_repeat = static_cast(power); + deferRadioCommand(2); + return; + } if (result == RadioSettingResult::Saved) writeOKFrame(); else writeErrFrame(result == RadioSettingResult::RadioRejected ? ERR_CODE_BAD_STATE : ERR_CODE_FILE_IO_ERROR); @@ -8451,10 +8479,15 @@ bool MyMesh::saveAdvertLocation(double latitude, double longitude) { return false; } -MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power) { +MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(int8_t power, bool from_pending) { + if (command_radio_apply_pending && !from_pending) return RadioSettingResult::RadioBusy; const int8_t previous = _prefs.tx_power_dbm; - if (_radio_available && !radio_driver.setTxPower(power)) { - return RadioSettingResult::RadioRejected; + if (_radio_available) { + const auto result = radio_driver.trySetTxPower(power); + if (result != mesh::RadioParamApplyResult::APPLIED) { + return result == mesh::RadioParamApplyResult::BUSY + ? RadioSettingResult::RadioBusy : RadioSettingResult::RadioRejected; + } } _prefs.tx_power_dbm = power; if (savePrefs()) return RadioSettingResult::Saved; @@ -10924,15 +10957,15 @@ bool MyMesh::handleCommand(const char* command, uint32_t sender_timestamp, if (!mesh::cli::parseIntegerStrict(command + 7, parsed)) { mesh::cli::formatError(reply, reply_capacity, "invalid TX power"); } else if (parsed < -9 || parsed > MAX_LORA_TX_POWER) { - snprintf(reply, reply_capacity, "Error: TX power must be -9 to %d", + snprintf(reply, reply_capacity, "Error: TX -9..%d dBm", MAX_LORA_TX_POWER); } else { const int8_t requested = static_cast(parsed); const auto result = applyAndSaveTxPower(requested); - if (result == RadioSettingResult::RadioRejected) { + if (result == RadioSettingResult::RadioRejected || result == RadioSettingResult::RadioBusy) { mesh::cli::formatError(reply, reply_capacity, "radio busy or TX power rejected"); } else if (result == RadioSettingResult::SaveFailed) { - mesh::cli::formatError(reply, reply_capacity, "TX power could not be saved"); + mesh::cli::formatError(reply, reply_capacity, "TX not saved"); } else { strcpy(reply, "OK"); } diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 4a4859ada..1bfac09f9 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -675,8 +675,8 @@ private: bool applyAndSaveFemRxGain(bool enabled); bool applyAndSaveFemTxGain(bool enabled); bool applyAndSaveRxBoostedGain(bool enabled); - enum class RadioSettingResult { Saved, RadioRejected, SaveFailed }; - RadioSettingResult applyAndSaveTxPower(int8_t power); + enum class RadioSettingResult { Saved, RadioRejected, SaveFailed, RadioBusy }; + RadioSettingResult applyAndSaveTxPower(int8_t power, bool from_pending = false); bool handleCadCommand(const char* command, char* reply, size_t reply_size); bool saveBluetoothNameOverride(const char* name); bool applyAndSaveBluetoothName(const char* value, char* reply, @@ -749,6 +749,7 @@ private: void finishRadioParamApply(float freq, float bw, uint8_t sf, uint8_t cr, uint8_t repeat, BaseSerialInterface* route = nullptr); + void deferRadioCommand(uint8_t kind); void cancelPendingRadioParamApply(); void servicePendingRadioParamApply(); void servicePendingSerialReply(); @@ -912,7 +913,8 @@ private: bool _radio_available; unsigned long radio_apply_retry_at; uint8_t radio_apply_failures; - bool command_radio_apply_pending; + // 0: none, 1: modulation tuple, 2: TX power (stored in the repeat byte). + uint8_t command_radio_apply_pending; float command_radio_freq; float command_radio_bw; uint8_t command_radio_sf; diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 8aa5203e9..3ebca00bd 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -16,6 +16,9 @@ #include "CompanionBluetooth.h" #include "CompanionWireless.h" #include "CompanionWiFi.h" +#if defined(MESH_PRIMARY_ESPNOW) && MESH_PRIMARY_ESPNOW +#include +#endif #if COMPANION_FEATURE_USB_MOTA_SOURCE || COMPANION_FEATURE_BLE_MOTA_SOURCE #include #include diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index dd7900afc..ef33805c7 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -69,6 +69,10 @@ public: uint32_t intID(); bool setTxPower(int8_t dbm); + mesh::RadioParamApplyResult trySetTxPower(int8_t dbm) { + return setTxPower(dbm) ? mesh::RadioParamApplyResult::APPLIED + : mesh::RadioParamApplyResult::FAILED; + } }; #if ESPNOW_DEBUG_LOGGING && ARDUINO diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index 978c2f49d..e6c31ac5e 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -243,6 +243,7 @@ void RadioLibWrapper::begin() { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) return; #endif + _dbm_applied = false; #if MESH_HIL_PROFILE_TIMING mesh::profiletiming::initializeClock(); #endif @@ -320,8 +321,13 @@ uint32_t RadioLibWrapper::getRngSeed() { } bool RadioLibWrapper::setTxPower(int8_t dbm) { + return trySetTxPower(dbm) == mesh::RadioParamApplyResult::APPLIED; +} + +mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm) { + using Result = mesh::RadioParamApplyResult; #if MESH_ENABLE_TX_BEACONS - if (_beacon_owned) return false; + if (_beacon_owned) return Result::BUSY; #endif #if MESH_ENABLE_CARRIER_TOOLS || MESH_ENABLE_MORSE_RX // A CW power-meter sweep may re-key the carrier. During Morse that would @@ -331,37 +337,42 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { #if MESH_ENABLE_MORSE_RX || _morse_rx_owned #endif - ) && _cw_active) return false; + ) && _cw_active) return Result::BUSY; #if MESH_ENABLE_CARRIER_TOOLS if (_cw_active) { - if (_cw_stopping) return false; + if (_cw_stopping) return Result::BUSY; #if defined(MESH_HIL_TX_FREQ_MHZ) if (!hilPacketTransmitAllowed() || _cw_profile != _active_profile - || _cw_generation != _profile_generation) return false; + || _cw_generation != _profile_generation) return Result::FAILED; #endif // Permit meter sweeps through the board's normal power/PA calibration. // SX127x's power setter enters standby, so re-key without resetting the // modem or extending the original CW deadline. if (applyCachedTxPower(dbm) != RADIOLIB_ERR_NONE) { stopCarrierWave(); - return false; + return Result::FAILED; } if (_radio->transmitDirect() != RADIOLIB_ERR_NONE) { stopCarrierWave(); - return false; + return Result::FAILED; } _cur_dbm = dbm; _dbm_valid = true; - return true; + return Result::APPLIED; } #else - if (_cw_active) return false; + if (_cw_active) return Result::BUSY; #endif #endif + // Settings screens also submit TX power when only another field changed. + // The verified normal power needs no SPI, standby, or interruption of RX. + if (_dbm_valid && _dbm_applied && _cur_dbm == dbm + && !_profile_repair_required) return Result::APPLIED; 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 int16_t status = applyCachedTxPower(dbm); + _dbm_applied = status == RADIOLIB_ERR_NONE; if (status == RADIOLIB_ERR_NONE) { _cur_dbm = dbm; _dbm_valid = true; @@ -370,7 +381,7 @@ bool RadioLibWrapper::setTxPower(int8_t dbm) { (int)dbm, (int)status); } endReconfigure(resume_rx); - return status == RADIOLIB_ERR_NONE; + return status == RADIOLIB_ERR_NONE ? Result::APPLIED : Result::FAILED; } uint8_t RadioLibWrapper::beginReconfigure() { @@ -516,6 +527,7 @@ bool RadioLibWrapper::restoreAfterDeepInit() { #if MESH_ENABLE_TX_BEACONS if (_beacon_owned) cacheBeaconRestoreParams(); #endif + _dbm_applied = false; if (!radioDeepInit()) return false; _rx_ps_armed = false; @@ -533,7 +545,8 @@ bool RadioLibWrapper::restoreAfterDeepInit() { restored = restored && applyProtocol(_physical_protocol); #endif if (_dbm_valid) { - restored = applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE && restored; + _dbm_applied = applyCachedTxPower(_cur_dbm) == RADIOLIB_ERR_NONE; + restored = _dbm_applied && restored; } if (_rx_boosted_gain_valid) { restored = applyRxBoostedGainMode(_cur_rx_boosted_gain) && restored; diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index 7314c7a94..f742262f7 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -108,6 +108,7 @@ protected: int8_t _cur_dbm; bool _cur_rx_boosted_gain; bool _params_valid, _dbm_valid, _rx_boosted_gain_valid; + bool _dbm_applied = false; mesh::RadioProfiles _profiles; uint8_t _active_profile = 0; uint16_t _physical_preamble = 0; @@ -574,6 +575,7 @@ public: const uint32_t* rx_ps_timings = NULL); uint32_t getRngSeed(); bool setTxPower(int8_t dbm); + mesh::RadioParamApplyResult trySetTxPower(int8_t dbm); virtual float getCurrentRSSI() =0; virtual uint8_t getSpreadingFactor() const { return LORA_SF; } diff --git a/src/helpers/sim/SimRadio.h b/src/helpers/sim/SimRadio.h index 9c5f495ae..ab47bdeed 100644 --- a/src/helpers/sim/SimRadio.h +++ b/src/helpers/sim/SimRadio.h @@ -76,6 +76,9 @@ public: } void powerOff() {} bool setTxPower(int8_t /*dbm*/) { return true; } + mesh::RadioParamApplyResult trySetTxPower(int8_t /*dbm*/) { + return mesh::RadioParamApplyResult::APPLIED; + } uint32_t getRngSeed() { return _simRandom(); } uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsSent() const { return n_sent; } diff --git a/test/test_beacon_legacy_lora_protocol.py b/test/test_beacon_legacy_lora_protocol.py index 109d5a44e..475c244ed 100644 --- a/test/test_beacon_legacy_lora_protocol.py +++ b/test/test_beacon_legacy_lora_protocol.py @@ -75,7 +75,7 @@ struct LLCC68 : PhysicalLayer { int16_t setCRC(uint8_t length) { return setCrc(l struct RadioLibWrapper { PhysicalLayer* _radio; mesh::MainBoard* _board; - bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _rx_boosted_gain_valid = false; + bool _params_valid = true, _rx_ps_armed = true, _dbm_valid = false, _dbm_applied = false, _rx_boosted_gain_valid = false; bool _profile_repair_required = false; int state = 1; float _cur_freq = 906.875f, _cur_bw = 250; diff --git a/test/test_beacon_radio.py b/test/test_beacon_radio.py index 39a52b475..319bc5577 100644 --- a/test/test_beacon_radio.py +++ b/test/test_beacon_radio.py @@ -81,7 +81,7 @@ struct RadioLibWrapper { float _cur_freq = 906, _cur_bw = 62.5; uint8_t _cur_sf = 7, _cur_cr = 5; int8_t _cur_dbm = 20; - bool _params_valid = true, _dbm_valid = true; + bool _params_valid = true, _dbm_valid = true, _dbm_applied = true; bool _cw_active = false, pending = false, packet = false, busy = false, calibrating = false; bool _rx_ps_enabled = false, _rx_ps_armed = false, _rx_hold_continuous = false; bool _rx_ps_continuous_fallback = false, _nf_calib_active = false; @@ -190,6 +190,15 @@ mesh::RadioProfileParams beacon(bool meshtext = false) { } int main() { unsigned cases = 0; + { + RadioLibWrapper w; w.deep_ok = false; + assert(!w.restoreAfterDeepInit() && !w._dbm_applied); + w.deep_ok = true; w.power_fail = true; + assert(!w.restoreAfterDeepInit() && !w._dbm_applied); + w.power_fail = false; + assert(w.restoreAfterDeepInit() && w._dbm_applied); + ++cases; + } for (bool dual : {false, true}) for (bool rxps : {false, true}) for (bool mt : {false, true}) { RadioLibWrapper w; now_ms = 100; if (dual) { diff --git a/test/test_carrier_wave.py b/test/test_carrier_wave.py index 103a5e105..683b6af96 100644 --- a/test/test_carrier_wave.py +++ b/test/test_carrier_wave.py @@ -159,7 +159,7 @@ struct RadioLibWrapper : mesh::Radio { float frequency=909.5; float _cur_freq=909.5; int8_t _cur_dbm=17; - bool _dbm_valid=true; + bool _dbm_valid=true, _dbm_applied=true; RadioLibWrapper() { g_mock_millis=100; state=STATE_RX; _profiles.primary.freq=909.5; @@ -193,6 +193,7 @@ struct RadioLibWrapper : mesh::Radio { } virtual float getCurrentRSSI() { ++rssi_reads; g_mock_millis+=rssi_latency; return rssi; } bool setTxPower(int8_t dbm); + mesh::RadioParamApplyResult trySetTxPower(int8_t dbm); @NATIVE_HOOKS@ #if MESH_ENABLE_MORSE_RX || !defined(STM32_PLATFORM) @RX_HOOKS@ @@ -230,9 +231,11 @@ struct RadioLibWrapper : mesh::Radio { } bool restoreAfterDeepInit() { ++resets; + _dbm_applied=false; if (reset_ok) { chip.carrier=false; chip.fsk=false; chip.stop_result=chip.standby_result=0; - return applyCachedTxPower(_cur_dbm)==0; + _dbm_applied=applyCachedTxPower(_cur_dbm)==0; + return _dbm_applied; } return reset_ok; } @@ -455,6 +458,7 @@ def production_harness(): source = (ROOT/'src/helpers/radiolib/RadioLibWrappers.cpp').read_text() signatures = ('uint8_t RadioLibWrapper::beginReconfigure(', 'bool RadioLibWrapper::setTxPower(', + 'mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(', 'mesh::RadioParamApplyResult RadioLibWrapper::setCarrierWave(', 'uint32_t RadioLibWrapper::carrierWaveRemainingMillis(', 'bool RadioLibWrapper::stopCarrierWave(', diff --git a/test/test_companion_radio_settings_transaction.py b/test/test_companion_radio_settings_transaction.py index 500c06907..c110f6627 100644 --- a/test/test_companion_radio_settings_transaction.py +++ b/test/test_companion_radio_settings_transaction.py @@ -52,14 +52,15 @@ void recalcRxPowerSavingFromLevel(uint8_t,uint8_t sf,float bw,uint8_t, struct Driver { int8_t power=3; unsigned calls=0; - std::deque accepted; - bool setTxPower(int8_t requested) { + std::deque accepted; + Apply trySetTxPower(int8_t requested) { ++calls; - bool ok=true; - if (!accepted.empty()) { ok=accepted.front(); accepted.pop_front(); } - if (ok) power=requested; - return ok; + Apply result=Apply::APPLIED; + if (!accepted.empty()) { result=accepted.front(); accepted.pop_front(); } + if (result==Apply::APPLIED) power=requested; + return result; } + bool setTxPower(int8_t requested) { return trySetTxPower(requested)==Apply::APPLIED; } bool supportsRxBoostedGainMode() const { return false; } bool setRxBoostedGainMode(bool) { return true; } bool supportsRxPowerSaving() const { return false; } @@ -74,7 +75,8 @@ struct MyMesh { bool storage_accepts=true, _radio_available=true, outbound=false; unsigned saves=0, applies=0; std::deque accepted; - bool saved_radio_apply_pending=false, command_radio_apply_pending=false; + bool saved_radio_apply_pending=false; + uint8_t command_radio_apply_pending=0; uint32_t radio_apply_retry_at=0, command_radio_apply_deadline=0; uint8_t radio_apply_failures=0; float command_radio_freq=0, command_radio_bw=0; @@ -100,8 +102,9 @@ struct MyMesh { return result; } bool applySavedRadioParams(); - RadioSettingResult applyAndSaveTxPower(int8_t); + RadioSettingResult applyAndSaveTxPower(int8_t,bool from_pending=false); void finishRadioParamApply(float,float,uint8_t,uint8_t,uint8_t,BaseSerialInterface* = nullptr); + void deferRadioCommand(uint8_t); void servicePendingRadioParamApply(); void cancelPendingRadioParamApply(); void frame(size_t len) { @FRAME_BRANCHES@ } @@ -122,6 +125,11 @@ struct MyMesh { frame(12); } void expectError(uint8_t code,BaseSerialInterface* route=nullptr) { + if(replies.size()!=1 || replies[0].error!=code) { + fprintf(stderr,"Expected reply %u; count=%zu first=%u pending=%u saves=%u now=%u\n", + unsigned(code),replies.size(),replies.empty()?255u:unsigned(replies[0].error), + unsigned(command_radio_apply_pending),saves,unsigned(now_ms)); + } assert(replies.size()==1 && replies[0].error==code); assert(replies[0].route==(route?route:&original)); } @@ -133,11 +141,12 @@ struct MyMesh { }; @METHODS@ int main() { - // Reject unavailable/busy hardware before either persisting or reporting it. + // A real hardware rejection remains an immediate error, without a save. { - MyMesh m;radio_driver.accepted={false};m.powerFrame(17); + MyMesh m;radio_driver.accepted={Apply::FAILED};m.powerFrame(17); m.expectError(ERR_CODE_BAD_STATE); - assert(m.saves==0 && m._prefs.tx_power_dbm==3 && radio_driver.power==3); + assert(!m.command_radio_apply_pending && m.saves==0); + assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3); } for (int value : {-10,23}) { MyMesh m;m.powerFrame(uint8_t(value));m.expectError(ERR_CODE_ILLEGAL_ARG); @@ -155,6 +164,137 @@ int main() { MyMesh m;m._radio_available=false;m.powerFrame(17);m.expectError(0); assert(m.durable.tx_power_dbm==17 && radio_driver.calls==0); } + // App settings can reach power while an RX packet is in progress. Keep the + // original requester and acknowledge only after the hardware and save pass. + for (int power : {-9,17,22}) { + MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::APPLIED}; + m.powerFrame(uint8_t(power)); + assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0); + assert(m.command_radio_reply_route==&m.original); + assert(int8_t(m.command_radio_repeat)==power); + assert(m.command_radio_apply_deadline==now_ms+COMMAND_RADIO_APPLY_TIMEOUT_MS); + assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3); + m.serial.current=&m.other; + m.servicePendingRadioParamApply(); + assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0); + m.servicePendingRadioParamApply();m.expectError(0); + assert(!m.command_radio_apply_pending && m.saves==1); + assert(m.command_radio_reply_route==nullptr && m.command_radio_apply_deadline==0); + assert(m._prefs.tx_power_dbm==power && m.durable.tx_power_dbm==power); + assert(radio_driver.power==power); + } + // Reproduce the app's multi-command settings save: the tuple is durable + // before the following power command encounters a temporary receive lease. + { + MyMesh m;m.tupleFrame();m.expectError(0);m.replies.clear(); + radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17); + assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==1); + assert(m.durable.freq==920 && m.durable.tx_power_dbm==3); + m.servicePendingRadioParamApply();m.expectError(0); + assert(m.saves==2 && m.durable.freq==920 && m.durable.tx_power_dbm==17); + } + // Wait for reply-queue headroom before applying or committing the candidate. + { + MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17); + m.serial.current=&m.other;m.serial.busy=true; + const unsigned calls=radio_driver.calls; + m.servicePendingRadioParamApply(); + assert(m.replies.empty() && m.saves==0 && radio_driver.calls==calls); + assert(m.command_radio_apply_pending==2 && m.durable.tx_power_dbm==3); + m.serial.busy=false;m.servicePendingRadioParamApply();m.expectError(0); + assert(!m.command_radio_apply_pending && m.durable.tx_power_dbm==17); + } + // A second settings request cannot replace the pending candidate or route. + for (bool tuple_request : {false,true}) { + MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17); + m.serial.current=&m.other; + if(tuple_request)m.tupleFrame();else m.powerFrame(12); + m.expectError(ERR_CODE_BAD_STATE,&m.other); + assert(m.command_radio_apply_pending==2 && m.command_radio_repeat==17); + assert(m.command_radio_reply_route==&m.original && m.saves==0 && radio_driver.calls==1); + char reply[160]={}; + assert(m.command("set tx 12",reply) && strstr(reply,"Error")); + assert(m.command_radio_apply_pending==2 && m.command_radio_repeat==17); + assert(m.saves==0 && radio_driver.calls==1 && m._prefs.tx_power_dbm==3); + m.replies.clear();m.servicePendingRadioParamApply();m.expectError(0); + assert(m.durable.tx_power_dbm==17); + } + // The power request also respects a tuple already occupying the shared slot. + { + MyMesh m;m.accepted={Apply::BUSY,Apply::APPLIED};m.tupleFrame(); + assert(m.command_radio_apply_pending==1); + m.serial.current=&m.other;m.powerFrame(17);m.expectError(ERR_CODE_BAD_STATE,&m.other); + assert(m.command_radio_apply_pending==1 && radio_driver.calls==0 && m.saves==0); + m.replies.clear();m.servicePendingRadioParamApply();m.expectError(0); + assert(m.durable.freq==920 && m.durable.tx_power_dbm==3); + } + // Losing the original route cancels the unsaved power, even when another + // transport supplies commands before the deferred operation is serviced. + { + MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17); + m.serial.current=&m.other;m.serial.disconnected=&m.original; + m.servicePendingRadioParamApply(); + assert(!m.command_radio_apply_pending && m.replies.empty() && m.saves==0); + assert(!m.saved_radio_apply_pending); + assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3); + assert(radio_driver.calls==1); + } + { + MyMesh m;m.serial.current=nullptr;radio_driver.accepted={Apply::BUSY};m.powerFrame(17); + assert(m.replies.size()==1 && m.replies[0].error==ERR_CODE_BAD_STATE); + assert(!m.command_radio_apply_pending && m.saves==0 && radio_driver.power==3); + } + // Sustained busy hardware times out once on the captured route. Check both + // the ordinary clock and a deadline that crosses the millis rollover. + for (uint32_t start : {100u,UINT32_MAX-1000u}) { + MyMesh m;now_ms=start; + radio_driver.accepted={Apply::BUSY,Apply::BUSY,Apply::BUSY};m.powerFrame(17); + m.serial.current=&m.other; + const uint32_t deadline=m.command_radio_apply_deadline; + now_ms=deadline-1;m.servicePendingRadioParamApply(); + assert(m.replies.empty() && m.command_radio_apply_pending==2 && m.saves==0); + now_ms=deadline;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE); + assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending && m.saves==0); + assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3 && radio_driver.power==3); + m.servicePendingRadioParamApply();assert(m.replies.size()==1); + } + // Expiry must not commit a late success, including when queue backpressure + // prevented the timeout reply until after its original deadline. + for (bool blocked_queue : {false,true}) { + MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::APPLIED};m.powerFrame(17); + m.serial.current=&m.other;now_ms=m.command_radio_apply_deadline; + if(blocked_queue) { + m.serial.busy=true;m.servicePendingRadioParamApply(); + assert(m.replies.empty() && m.saves==0 && radio_driver.calls==1); + m.serial.busy=false;++now_ms; + } + m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE); + assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending); + assert(m.saves==0 && radio_driver.calls==1); + assert(m.durable.tx_power_dbm==3 && radio_driver.power==3); + } + // A later hardware rejection must not be mistaken for another busy retry. + { + MyMesh m;radio_driver.accepted={Apply::BUSY,Apply::FAILED};m.powerFrame(17); + m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_BAD_STATE); + assert(!m.command_radio_apply_pending && !m.saved_radio_apply_pending && m.saves==0); + assert(m.durable.tx_power_dbm==3 && radio_driver.power==3); + } + // Deferred commits retain the same storage-failure rollback as immediate + // requests, and cannot send a later success after reporting the failure. + for (bool rollback_busy : {false,true}) { + MyMesh m;m.storage_accepts=false; + radio_driver.accepted={Apply::BUSY,Apply::APPLIED, + rollback_busy?Apply::BUSY:Apply::APPLIED,Apply::APPLIED}; + m.powerFrame(17);assert(m.replies.empty() && m.saves==0); + m.serial.current=&m.other;m.servicePendingRadioParamApply();m.expectError(ERR_CODE_FILE_IO_ERROR); + assert(!m.command_radio_apply_pending && m.saves==1); + assert(m._prefs.tx_power_dbm==3 && m.durable.tx_power_dbm==3); + assert(m.saved_radio_apply_pending==rollback_busy); + if(rollback_busy)m.recover(); + assert(!m.saved_radio_apply_pending && radio_driver.power==3 && m.replies.size()==1); + m.servicePendingRadioParamApply();assert(m.replies.size()==1); + } // A failed commit restores RAM and the physical output-power setting. { MyMesh m;m.storage_accepts=false;m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR); @@ -164,7 +304,8 @@ int main() { // RX may become busy during flash I/O. Retain recovery through a rejected // rollback and through a later successful tuple restore but failed power apply. { - MyMesh m;m.storage_accepts=false;radio_driver.accepted={true,false,false,true}; + MyMesh m;m.storage_accepts=false; + radio_driver.accepted={Apply::APPLIED,Apply::BUSY,Apply::FAILED,Apply::APPLIED}; m.powerFrame(17);m.expectError(ERR_CODE_FILE_IO_ERROR); assert(m._prefs.tx_power_dbm==3 && radio_driver.power==17 && m.saved_radio_apply_pending); m.recover();assert(m.saved_radio_apply_pending && radio_driver.power==17); @@ -176,7 +317,8 @@ int main() { } // ASCII and framed setters share identical hardware/durability semantics. { - MyMesh m;char reply[160]={};radio_driver.accepted={false}; + MyMesh m;char reply[160]={};radio_driver.accepted={Apply::FAILED,Apply::BUSY}; + assert(m.command("set tx 17",reply) && strstr(reply,"Error")); assert(m.command("set tx 17",reply) && strstr(reply,"Error")); assert(m.saves==0 && m._prefs.tx_power_dbm==3); m.storage_accepts=false; @@ -253,7 +395,8 @@ struct ContactStore { struct MyMesh { ContactStore store; ContactStore* _store=&store; - bool _radio_available=true, _iter_started=false, command_radio_apply_pending=false; + bool _radio_available=true, _iter_started=false; + uint8_t command_radio_apply_pending=0; bool saved_radio_apply_pending=false, _terminal_trace_pending=false; uint32_t radio_apply_retry_at=0, _scheduled_reboot_at=0; uint32_t emergency_client_repeat_send_at=0, dirty_contacts_expiry=0; @@ -283,6 +426,11 @@ struct MyMesh { int main() { MyMesh m; assert(!m.hasPendingWork()); + m.command_radio_apply_pending=2; + assert(m.hasPendingWork()); // Deferred power must keep the retry loop awake. + assert(m.pendingWorkReason()==mesh::powerdiag::mask(mesh::powerdiag::Reason::Scheduled)); + m.command_radio_apply_pending=0; + assert(!m.hasPendingWork()); m.store._contact_recovery=ContactStore::ContactRecoveryPending; assert(m.hasPendingWork()); // Storage repair still needs the outer loop. m.store._contact_recovery=ContactStore::ContactRecoveryLegacy; @@ -376,6 +524,7 @@ class CompanionRadioSettingsTransactionTests(unittest.TestCase): 'bool MyMesh::applySavedRadioParams(', 'MyMesh::RadioSettingResult MyMesh::applyAndSaveTxPower(', 'void MyMesh::finishRadioParamApply(', + 'void MyMesh::deferRadioCommand(', 'void MyMesh::cancelPendingRadioParamApply(', 'void MyMesh::servicePendingRadioParamApply(', )) diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index 55f80c5ca..42a1e97ac 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -40,7 +40,7 @@ struct FakeWiFi { bool mode(wifi_mode_t value){if(failure==1)return false;current=value;return true;} } WiFi; namespace mesh { - enum class RadioParamApplyResult {APPLIED}; + enum class RadioParamApplyResult {APPLIED,BUSY,FAILED}; namespace wifi { constexpr uint8_t kProtocolMask=15,kLongRangeRadioOwner=1,kLongRangeBridgeOwner=2; void setLongRangeOwner(uint8_t,bool){} @@ -89,6 +89,11 @@ int main(){ assert(radio.isEnabled()&&sdk_active&&wake_refs==1&&!WiFi.autoreconnect); const int started=init_calls;radio.init();assert(init_calls==started&&wake_refs==1); assert(radio.setTxPower(13)&&power==52); + assert(radio.trySetTxPower(14)==mesh::RadioParamApplyResult::APPLIED&&power==56); + failure=6; + assert(radio.trySetTxPower(15)==mesh::RadioParamApplyResult::FAILED&&power==56); + failure=0; + assert(radio.trySetTxPower(13)==mesh::RadioParamApplyResult::APPLIED&&power==52); uint8_t raw[255]={0x15,1,2,3},out[255]; deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==4&&!memcmp(raw,out,4)); // Stop in the middle of a fragmented TX: do not report success or send fragment 2. diff --git a/test/test_meshtext_transport.py b/test/test_meshtext_transport.py index 7ed38927c..625e535eb 100644 --- a/test/test_meshtext_transport.py +++ b/test/test_meshtext_transport.py @@ -324,7 +324,7 @@ struct RadioLibWrapper { bool _profile_repair_required=false; bool supported=true; bool supportsMeshtext() const { return supported; } - bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_rx_boosted_gain_valid=false; + bool _rx_ps_armed=true,_params_valid=true,_dbm_valid=false,_dbm_applied=false,_rx_boosted_gain_valid=false; int state=1;uint8_t _cur_sf=11,_cur_cr=5;float _cur_freq=906.875,_cur_bw=250; int8_t _cur_dbm=22;bool _cur_rx_boosted_gain=false; mesh::RadioProfileProtocol _physical_protocol=mesh::RadioProfileProtocol::Meshtext; diff --git a/test/test_radio_tx_power_transaction.py b/test/test_radio_tx_power_transaction.py new file mode 100644 index 000000000..daac6a51b --- /dev/null +++ b/test/test_radio_tx_power_transaction.py @@ -0,0 +1,175 @@ +"""Execute real TX-power admission, retry and unchanged-value behavior.""" +from pathlib import Path +import unittest + +from test_radio_interrupt_recovery import compile_run +from test_radio_receive_contract import method + + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#define MESH_DEBUG_PRINTLN(...) ((void)0) +#define RADIOLIB_ERR_NONE 0 +#define STATE_IDLE 0 +#define STATE_RX 1 +#define STATE_TX_WAIT 3 +namespace mesh { enum class RadioParamApplyResult { APPLIED, BUSY, FAILED }; } +using Result = mesh::RadioParamApplyResult; +struct Chip { + int standby_result = 0, power_result = 0, direct_result = 0; + int8_t power = 17; + bool write_before_failure = false; + unsigned standbys = 0, writes = 0, keys = 0; + int standby() { ++standbys; return standby_result; } + int setOutputPower(int8_t requested) { + ++writes; + if (!power_result || write_before_failure) power = requested; + return power_result; + } + int transmitDirect() { ++keys; return direct_result; } +}; +struct RadioLibWrapper { + Chip chip; + Chip* _radio = &chip; + uint8_t state = STATE_RX; + int8_t _cur_dbm = 17; + bool _dbm_valid = true, _dbm_applied = true, _profile_repair_required = false; + bool _profile_refresh_required = false, _rx_hold_continuous = false; + bool _rx_ps_armed = true, _cw_active = false, _cw_stopping = false; + bool _beacon_owned = false, _beacon_restoring = false; + bool _morse_owned = false, _morse_rx_owned = false; + bool pending = false, busy = false, packet = false; + unsigned irq_reads = 0, busy_reads = 0, packet_reads = 0, claims = 0; + unsigned resumes = 0, carrier_stops = 0; + bool hasPendingRadioInterrupt() { ++irq_reads; return pending; } + bool claimRadioInterrupt() { ++claims; bool value = pending; pending = false; return value; } + bool isChipBusy() { ++busy_reads; return busy; } + bool isReceivingPacket() { ++packet_reads; return packet; } + int16_t applyCachedTxPower(int8_t requested) { return chip.setOutputPower(requested); } + bool stopCarrierWave() { ++carrier_stops; _cw_active = false; return true; } + void startRecv() { ++resumes; state = STATE_RX; } + uint8_t beginReconfigure(); + void endReconfigure(bool); + bool setTxPower(int8_t); + Result trySetTxPower(int8_t); +}; +@METHODS@ +int main() { + // A temporary receive/TX condition does not become a hardware rejection. + for (unsigned reason = 0; reason < 4; ++reason) { + RadioLibWrapper radio; + radio.pending = reason == 0; + if (reason == 1) radio.state = STATE_TX_WAIT; + radio.busy = reason == 2; + radio.packet = reason == 3; + assert(radio.trySetTxPower(10) == Result::BUSY); + assert(!radio.setTxPower(10)); + assert(!radio.chip.writes && !radio.chip.standbys && !radio.resumes); + assert(radio._cur_dbm == 17 && radio.chip.power == 17); + if (reason == 0) assert(radio.pending && !radio.claims); + radio.pending = radio.busy = radio.packet = false; + radio.state = STATE_RX; + assert(radio.trySetTxPower(10) == Result::APPLIED); + assert(radio.chip.writes == 1 && radio.chip.standbys == 1 && radio.resumes == 1); + assert(radio._cur_dbm == 10 && radio._dbm_valid && radio._dbm_applied && radio.chip.power == 10); + } + // App saves can resend the current power while an unread packet owns RX. + for (unsigned reason = 0; reason < 4; ++reason) { + RadioLibWrapper radio; + radio.pending = reason == 0; + if (reason == 1) radio.state = STATE_TX_WAIT; + radio.busy = reason == 2; + radio.packet = reason == 3; + const auto original_state = radio.state; + assert(radio.trySetTxPower(17) == Result::APPLIED && radio.setTxPower(17)); + assert(!radio.chip.writes && !radio.chip.standbys && !radio.resumes); + assert(!radio.irq_reads && !radio.busy_reads && !radio.packet_reads && !radio.claims); + assert(radio.state == original_state && radio._rx_ps_armed); + assert(radio.pending == (reason == 0) && radio.packet == (reason == 3)); + } + // A matching number without a valid hardware cache must still be applied. + { + RadioLibWrapper radio; radio._dbm_valid = false; radio.busy = true; + assert(radio.trySetTxPower(17) == Result::BUSY && !radio.chip.writes); + radio.busy = false; + assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 1); + } + // A failed earlier transaction cannot be hidden by the unchanged shortcut. + { + RadioLibWrapper radio; radio._profile_repair_required = true; radio.busy = true; + assert(radio.trySetTxPower(17) == Result::BUSY && !radio.chip.writes); + radio.busy = false; + assert(radio.trySetTxPower(17) == Result::APPLIED); + assert(radio.chip.standbys == 1 && radio.chip.writes == 1 && radio.resumes == 1); + } + // Beacon and Morse owners remain exclusive even for the cached power. + for (unsigned owner = 0; owner < 4; ++owner) { + RadioLibWrapper radio; + radio._beacon_owned = owner == 0; + radio._cw_active = owner != 0; + radio._morse_owned = owner == 1; + radio._morse_rx_owned = owner == 2; + radio._cw_stopping = owner == 3; + assert(radio.trySetTxPower(17) == Result::BUSY && !radio.setTxPower(17)); + assert(!radio.chip.writes && !radio.chip.standbys && !radio.chip.keys); + assert(!radio.resumes && !radio.carrier_stops); + } +#if MESH_ENABLE_CARRIER_TOOLS + { + RadioLibWrapper radio; radio._cw_active = true; + assert(radio.trySetTxPower(17) == Result::APPLIED); + assert(radio.chip.writes == 1 && radio.chip.keys == 1 && !radio.resumes); + } +#endif + // A failed SPI command may already have changed the physical output power. + // The old cached request is usable again only after a verified hardware apply. + { + RadioLibWrapper radio; + radio.chip.power_result = -705; radio.chip.write_before_failure = true; + assert(radio.trySetTxPower(10) == Result::FAILED); + assert(radio._cur_dbm == 17 && radio.chip.power == 10 && !radio._dbm_applied); + radio.busy = true; + assert(radio.trySetTxPower(17) == Result::BUSY && radio.chip.writes == 1); + radio.busy = false; radio.chip.power_result = 0; + assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2); + assert(radio.chip.power == 17 && radio._dbm_applied); + assert(radio.trySetTxPower(17) == Result::APPLIED && radio.chip.writes == 2); + } + // Real command and standby failures remain failures, with the requested cache intact. + { + RadioLibWrapper radio; radio.chip.power_result = -705; + assert(radio.trySetTxPower(10) == Result::FAILED && !radio.setTxPower(10)); + assert(radio._cur_dbm == 17 && radio.chip.power == 17); + assert(!radio._dbm_applied); + assert(radio.chip.writes == 2 && radio.resumes == 2); + } + { + RadioLibWrapper radio; radio.chip.standby_result = -705; + assert(radio.trySetTxPower(10) == Result::FAILED && !radio.setTxPower(10)); + assert(!radio.chip.writes && radio._profile_repair_required && !radio.resumes); + } +} +''' + + +class RadioTxPowerTransactionTests(unittest.TestCase): + def test_actual_busy_retry_noop_ownership_and_failures(self): + source = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + methods = "\n".join(method(source, signature) for signature in ( + "uint8_t RadioLibWrapper::beginReconfigure(", + "void RadioLibWrapper::endReconfigure(", + "bool RadioLibWrapper::setTxPower(", + "mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(")) + for carrier_tools in (0, 1): + with self.subTest(carrier_tools=carrier_tools): + compile_run(HARNESS.replace("@METHODS@", methods), defines=( + "-DMESH_ENABLE_TX_BEACONS=1", "-DMESH_ENABLE_MORSE_RX=1", + f"-DMESH_ENABLE_CARRIER_TOOLS={carrier_tools}")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_room_sensor_radio_restore.py b/test/test_room_sensor_radio_restore.py index 094497711..6e4b61ce4 100644 --- a/test/test_room_sensor_radio_restore.py +++ b/test/test_room_sensor_radio_restore.py @@ -47,7 +47,7 @@ struct Driver { bool _rx_ps_armed = false, _cw_active = false; bool _profile_repair_required = false, _profile_refresh_required = false; bool _rx_hold_continuous = false; - bool _dbm_valid = true, _rx_boosted_gain_valid = false; + bool _dbm_valid = true, _dbm_applied = true, _rx_boosted_gain_valid = false; int8_t _cur_dbm = 7; bool _cur_rx_boosted_gain = false; unsigned power_commands = 0, gain_commands = 0; @@ -75,6 +75,7 @@ struct Driver { uint8_t beginReconfigure(); void endReconfigure(bool); bool setTxPower(int8_t); + mesh::RadioParamApplyResult trySetTxPower(int8_t); } radio_driver; @WRAPPER_METHODS@ struct Prefs { @@ -280,7 +281,8 @@ def harness(role, mutation=None): wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() signatures = ["uint8_t RadioLibWrapper::beginReconfigure()", "void RadioLibWrapper::endReconfigure(bool resume_rx)", - "bool RadioLibWrapper::setTxPower(int8_t dbm)"] + "bool RadioLibWrapper::setTxPower(int8_t dbm)", + "mesh::RadioParamApplyResult RadioLibWrapper::trySetTxPower(int8_t dbm)"] if role == "room": signatures.append("bool RadioLibWrapper::setRxBoostedGainMode(bool enabled)") methods = "\n".join(body(wrapper, signature) for signature in signatures)