diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index d525275de..ad3144c42 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -499,6 +499,8 @@ jobs: python3 -B test/test_esp32_power_test_controls.py -v python3 -B test/test_esp32_uart_quiet_session.py -v python3 -B test/test_companion_rxps_getters.py -v + python3 -B test/test_rxps_runtime.py -v + python3 -B test/test_rxps_hardware_capability.py -v python3 -B test/test_nrf52_sleep_cycle.py -v python3 -B test/test_nrf52_sleep_events.py -v python3 -B test/test_nrf52_profile_idle.py -v diff --git a/build-iotthinks.sh b/build-iotthinks.sh index 2e7e4f700..06a653a8f 100644 --- a/build-iotthinks.sh +++ b/build-iotthinks.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # ./build-iotthinks.sh -export FIRMWARE_VERSION="PowerSaving17.1.6" +export FIRMWARE_VERSION="PowerSaving18" ############# Repeaters ############# # Commonly-used boards diff --git a/docs/cli_commands.md b/docs/cli_commands.md index eeb3f9ded..3a5ef732b 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -1983,6 +1983,7 @@ Station G2/G3 targets default to `off`. **Usage:** - `get radio.rxps` - `get radio.rxps.config` +- `get radio.rxps.status` - `get radio.rxps.rfrx_disabled` - `get rxps.wd` - `set radio.rxps.rfrx_disabled ` @@ -2015,6 +2016,15 @@ Station G2/G3 targets default to `off`. - `get radio.rxps.config` adds the persisted level and preamble assumption to the on/off and timing values. Deployment tools use it to restore a level-based preference without converting it to fixed manual timings. +- `get radio.rxps.status` reports the saved `desired` state, actual `effective` + state (`armed` or `continuous`), hardware support, last driver error (`err`), + arm failures (`fail`), level, preamble and requested RX/sleep + periods in microseconds. Levels `9` and `10` carry `(!)` to mark experimental + settings. It includes `catch` for armed, level-based settings and + `erx`/`eslp` when the driver clamps the periods and the reply has room. + Three consecutive failed arm attempts trigger continuous receive as a + fallback. Unsupported radios stay in continuous receive. Saved preferences + remain intact. - `radio.rxps.rfrx_disabled` is a runtime-only diagnostic setting and resets to `off` after reboot. - Its default `off` state keeps the host-controlled SX1262 receive path enabled during RX duty-cycle mode. Setting it to `on` reproduces the old missing-RF_RX behavior and can significantly reduce receive sensitivity, making remote commands harder to receive. - `radio.rxps.rfrx_disabled` is supported only on SX1262 targets with a host-controlled RX enable pin. @@ -3085,6 +3095,10 @@ The G3 button also wakes the device for at least two minutes. See the for verification steps and the distinction between the fix and the published binaries. +ESP32 builds with button wake stay awake for at least 30 seconds after a press; +the G3 retains its longer two-minute hold. Repeaters wait five minutes after +startup before allowing sleep. + Companion firmware defaults this setting to `on`. Full Companion accepts the command from its local USB terminal and exposes the same setting in WebConfig. On ESP32 with processor speed set to Auto, it normally selects 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. While a second radio profile is active, Auto holds the highest supported CPU speed for faster switching; SenseCAP Indicator retains its existing policy. Explicit processor speeds remain selected. USB and each active wireless transport remain available; SenseCAP Indicator Full keeps only its selected BLE or infrastructure-WiFi secondary transport active. `set powersaving off` restores the board's normal CPU clock and disables the GPS duty cycle. This device setting is separate from LoRa RXPS (`radio.rxps`) and WiFi modem power save (`wifi.powersave`). Infrastructure uses the same commands; its hardware and active-service sleep guards determine when the node can sleep. #### Diagnose what prevents power saving @@ -3177,7 +3191,7 @@ the X1 is not opted in without that hardware confirmation. --- -#### View or set the reboot interval (Repeater and room server) +#### View or set the reboot interval (Repeater, Room Server and Sensor) **Usage:** - `get reboot.interval` - `set reboot.interval ` diff --git a/docs/radio_presets.md b/docs/radio_presets.md new file mode 100644 index 000000000..ea3b07e51 --- /dev/null +++ b/docs/radio_presets.md @@ -0,0 +1,66 @@ +# Radio Presets + +The radio presets are currently stored on the MeshCore App API server. + +This allows us to update the presets without releasing new application updates. + +The presets can be fetched from [https://api.meshcore.nz/api/v1/config](https://api.meshcore.nz/api/v1/config) + +If you'd like a new radio preset to be added, this will need to be brought up for discussion in the [MeshCore Discord](https://meshcore.gg) server, or via the [issues section](https://github.com/meshcore-dev/MeshCore/issues) in our GitHub repo. + +Before a new preset will be considered for being added, local radio regulations must be researched by the person suggesting the preset, and investigation of the frequencies with an SDR is advised to ensure the least interference to provide a decent user experience. + +Once a preset is added, it's unlikely to be changed. Adding too many presets can lead to user frustration, as new users won't know which preset to select during setup. + +At this time, presets provided by the MeshCore App API are just suggestions, but you are free to configure any radio settings through the companion applications, provided they comply with local laws/regulations. + +> Note: We are planning to implement deep linking such as meshcore:// URLs, and QR codes so local mesh networks can easily distribute their own settings, without relying on the internet, or all the companion applications to provide all the settings. + +## JSON Response + +See the example JSON response below, which shows the expected format provided by the API server. + +Your application should be programmed to expect any of the JSON keys to be missing from the response, as the format could change at any time without any notification. + +Please configure an appropriate User Agent header string when sending your request to the server. We should be able to reach out to you if needed. + +> Note: Some presets contain an optional `network_settings` section which defines the `path_hash_size` to use for this mesh network. + +```json +{ + "config": { + "suggested_radio_settings": { + "info_message": "These presets are suggested by the community.", + "entries": [ + { + "title": "Australia (Narrow)", + "description": "916.575MHz / SF7 / BW62.5 / CR7", + "frequency": "916.575", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "7" + }, + { + "title": "New Zealand (Narrow)", + "description": "917.375MHz / SF7 / BW62.5 / CR5 / 2B", + "frequency": "917.375", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "5", + "network_settings": { + "path_hash_size": 2 + } + }, + { + "title": "Portugal 868", + "description": "869.618MHz / SF7 / BW62.5 / CR6", + "frequency": "869.618", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "6" + } + ] + } + } +} +``` \ No newline at end of file diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index b033cef9b..bc095b356 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -10451,7 +10451,8 @@ static bool isCompanionRadioPrefsCommand(const char* command) { static const char* const exact_commands[] = { "get radio", "get freq", "get af", "get dutycycle", "get tx", "get rxdelay", "get path.hash.mode", "get multi.acks", - "get txdelay", "get direct.txdelay", "get int.thresh", "get agc.reset.interval" + "get txdelay", "get direct.txdelay", "get int.thresh", "get agc.reset.interval", + "get radio.rxps.status" }; for (const char* candidate : exact_commands) { if (strcmp(command, candidate) == 0) return true; diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 5885a9f24..060337f03 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -162,6 +162,11 @@ private: def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); + def("rxps_en", _parent->rx_powersaving_enabled); + def("rxps_rx_us", _parent->rx_ps_rx_us); + def("rxps_sleep_us", _parent->rx_ps_sleep_us); + def("rxps_level", _parent->rx_ps_level); + def("rxps_preamble", _parent->rx_ps_preamble); def("usb_debug", _parent->usb_debug_enabled); #ifdef TBEAM_1W def("fan", _parent->fan_mode, sizeof(_parent->fan_mode)); @@ -172,6 +177,10 @@ private: public: explicit RadioPrefs(CompanionNodePrefs* parent) : _parent(parent) { } + RxPowerSavingConfig readRxPowerSavingConfig() const override { + return {_parent->rx_powersaving_enabled, _parent->rx_ps_rx_us, + _parent->rx_ps_sleep_us, _parent->rx_ps_level, _parent->rx_ps_preamble}; + } bool getByKey(const char* key, char* value, size_t max_len) override { if (key != nullptr && strcmp(key, "usb_debug") == 0) { @@ -319,6 +328,7 @@ public: void setRepeatEn(bool enabled) { client_repeat = enabled ? 1 : 0; } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c7d45d803..573c75384 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1060,9 +1060,7 @@ public: display.setColor(UIColor::corp_blue); } int y = header_height + 2; - const int visible_pages = HomePage::Count - - (the_mesh.isDualRadioActive() ? 0 : 2); - int x = display.width() / 2 - 5 * (visible_pages - 1); + int x = 5; for (uint8_t i = 0; i < HomePage::Count; i++) { if (!isPageVisible(i)) continue; if (i == _page) { diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 3796dd4d3..3e82deab5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -3813,9 +3813,11 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc _prefs.rx_ps_preamble = DEFAULT_RXPS_PREAMBLE; _prefs.rx_ps_rx_us = RX_POWERSAVING_DEFAULT_RX_US; _prefs.rx_ps_sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; - recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, - _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, - &_prefs.rx_ps_sleep_us); + if (WRAPPER_CLASS::SupportsRxPowerSaving) { + recalcRxPowerSavingFromLevel(_prefs.rx_ps_level, _prefs.sf, _prefs.bw, + _prefs.rx_ps_preamble, &_prefs.rx_ps_rx_us, + &_prefs.rx_ps_sleep_us); + } // bridge defaults #if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \ diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index e2f88efcf..dbc90de38 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -148,7 +148,7 @@ static char ethernet_command[160]; #endif // For power saving -unsigned long POWERSAVING_FIRSTSLEEP_SECS = 120; // The first sleep (if enabled) from boot +unsigned long POWERSAVING_FIRSTSLEEP_SECS = 300; // Leave five minutes for the startup console. #if defined(PIN_USER_BTN) && defined(_SEEED_SENSECAP_SOLAR_H_) static unsigned long userBtnDownAt = 0; diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 257c6d974..fd0dfcb04 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -475,4 +475,8 @@ void loop() { delay(1); // Keep USB and the button wake interval serviced while idle. } #endif + if (the_mesh.getNodePrefs()->reboot_interval > 0 && + the_mesh.millisHasNowPassed(the_mesh.getNodePrefs()->reboot_interval * 3600000UL)) { + board.reboot(); + } } diff --git a/mkdocs.yml b/mkdocs.yml index 1bf3b239f..7c9cc301a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -74,6 +74,7 @@ nav: - host_cli_service.md - Radio, networking, and power: - radio_profiles.md + - radio_presets.md - fleet_management.md - room_services.md - carrier_wave.md diff --git a/platformio.ini b/platformio.ini index 55b856971..59c05ccad 100644 --- a/platformio.ini +++ b/platformio.ini @@ -77,6 +77,9 @@ build_src_filter = [esp32_base] extends = arduino_base platform = platformio/espressif32@6.11.0 +; PowerSaving Arduino Core 2.0.17 for low power ESP32 boards +; https://github.com/IoTThinks/powersaving-arduino-lib +platform_packages = framework-arduinoespressif32 @ https://github.com/IoTThinks/powersaving-arduino-lib/releases/download/2.0.17/esp32-2.0.17.zip monitor_filters = esp32_exception_decoder ; OTA is available on every ESP32 variant (A/B via esp_ota; the detools-sequential apply decodes into the ; inactive slot). The received .mota is staged in the INACTIVE OTA slot (OtaStoreFlashEsp32), not RAM, so @@ -147,6 +150,8 @@ lib_deps = [esp32c6_base] extends = esp32_base platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.13-1/platform-espressif32.zip +; C6 needs Arduino 3.x; do not inherit the PowerSaving Arduino 2.x package. +platform_packages = ; ESP32-S3 Full Companion images use the ROM-compatible dual-I/O flash mode. ; This is independent of the board's PSRAM type and of whether the USB @@ -528,6 +533,8 @@ build_src_filter = +<../src/helpers/ArduinoSerialInterface.cpp> +<../src/helpers/DynamicConfigSerializer.cpp> +<../src/helpers/CommonRadioPrefs.cpp> + +<../src/helpers/radiolib/RXPowerSaving.cpp> + +<../src/helpers/radiolib/RXPowerSavingCLI.cpp> +<../src/helpers/FleetCommand.cpp> +<../src/helpers/ui/MomentaryButton.cpp> lib_deps = diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index f5cb18e7a..4b70f7cf9 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -5,6 +5,9 @@ #include "CarrierTools.h" #include "PacketFileLoggingConfig.h" #include "CommonCLI.h" +#ifdef WRAPPER_CLASS +#include "target.h" +#endif #include "PrefsSaveReplyGuard.h" #include "UsbLoggingWatchdog.h" #include "PowerSavingDiagnostics.h" @@ -466,6 +469,9 @@ static const char* retryPresetName(uint8_t preset) { bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw, uint32_t preamble, uint32_t* rx_us, uint32_t* sleep_us) { +#ifdef WRAPPER_CLASS + if (!WRAPPER_CLASS::SupportsRxPowerSaving) return false; +#endif if (level < 1 || level > 10 || preamble > 0xFF || !isRxPowerSavingPreamble((uint8_t)preamble)) { return false; @@ -477,6 +483,9 @@ bool CommonCLI::calculateRxPowerSavingLevel(uint32_t level, uint8_t sf, float bw static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* prefs, uint8_t sf, float bw) { +#ifdef WRAPPER_CLASS + if (!WRAPPER_CLASS::SupportsRxPowerSaving) return; +#endif if (!reply || !prefs || !prefs->rx_powersaving_enabled || prefs->rx_ps_level < 1 || prefs->rx_ps_level > 10) { return; @@ -520,6 +529,9 @@ static void appendRxPowerSavingAdjustmentNote(char* reply, const NodePrefs* pref // radio SF/BW. No-op (returns false) for manual timings (rx_ps_level == 0). // Lets level-based RX powersaving auto-retune when SF/BW change. bool CommonCLI::recalculateRxPowerSavingFromLevel(NodePrefs* prefs) { +#ifdef WRAPPER_CLASS + if (!WRAPPER_CLASS::SupportsRxPowerSaving) return false; +#endif return recalcRxPowerSavingFromLevel(prefs->rx_ps_level, prefs->sf, prefs->bw, prefs->rx_ps_preamble, &prefs->rx_ps_rx_us, &prefs->rx_ps_sleep_us); @@ -1795,9 +1807,7 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) { _prefs->rx_ps_preamble = 0; // 0 = auto (derive from SF) } ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); - recalcRxPowerSavingFromLevel(_prefs->rx_ps_level, _prefs->sf, _prefs->bw, _prefs->rx_ps_preamble, - &_prefs->rx_ps_rx_us, - &_prefs->rx_ps_sleep_us); // retune level-based timings to the loaded SF/BW + recalculateRxPowerSavingFromLevel(_prefs); // retune supported level-based timings to loaded SF/BW // Read the footer independently of optional legacy UART tails/profile. #if defined(RAK_4631) @@ -4739,6 +4749,14 @@ 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; + } + } +#endif if (parsed.mode == RxPowerSavingArgumentMode::Off) { enable = 0; } else if (parsed.mode == RxPowerSavingArgumentMode::Manual) { @@ -5880,6 +5898,8 @@ void CommonCLI::handleGetCmd(uint32_t sender_timestamp, char* command, char* rep } else { sprintf(reply, "> %s", _callbacks->isRxPowerSavingRfRxDisabled() ? "on" : "off"); } + } else if (strcmp(config, "radio.rxps.status") == 0) { + _prefs->getRadioPrefs()->formatRxPowerSavingStatus(reply, 157); } else if (strcmp(config, "radio.rxps.config") == 0) { ensureRxPowerSavingDefaults(&_prefs->rx_ps_rx_us, &_prefs->rx_ps_sleep_us); sprintf(reply, "> %s,level=%u,preamble=%u,rx=%lu,sleep=%lu", diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 5082a63bd..38c047c57 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -316,10 +316,19 @@ private: def("agc_int", _parent->agc_reset_interval); def("hash_mode", _parent->path_hash_mode); def("multi_ack", _parent->multi_acks); + def("rxps_en", _parent->rx_powersaving_enabled); + def("rxps_rx_us", _parent->rx_ps_rx_us); + def("rxps_sleep_us", _parent->rx_ps_sleep_us); + def("rxps_level", _parent->rx_ps_level); + def("rxps_preamble", _parent->rx_ps_preamble); } public: explicit RadioPrefs(NodePrefs* parent) : _parent(parent) { } + RxPowerSavingConfig readRxPowerSavingConfig() const override { + return {_parent->rx_powersaving_enabled, _parent->rx_ps_rx_us, + _parent->rx_ps_sleep_us, _parent->rx_ps_level, _parent->rx_ps_preamble}; + } // CommonRadioPrefs interface float getFreq() const override { return _parent->freq; } void setFreq(float f) override { _parent->freq = f; markDirty(); } @@ -408,6 +417,7 @@ private: void structure() override { def("adc_mult", _parent->adc_multiplier); def("pwr_sav_en", _parent->powersaving_enabled); + def("pwr_reboot_int", _parent->reboot_interval); #if defined(ESP32_PLATFORM) def("cpu_freq", _parent->cpu_freq_mhz); #endif @@ -478,6 +488,7 @@ public: repeat(this), room(this), custom(&radio) { } CommonRadioPrefs* getRadioPrefs() { return &radio; } + const CommonRadioPrefs* getRadioPrefs() const { return &radio; } KeyValueStore* getCustom() { return &custom; } bool isDirty() const override { return ConfigSerializer::isDirty() || radio.isDirty() || custom.isDirty(); } diff --git a/src/helpers/CommonRadioPrefs.cpp b/src/helpers/CommonRadioPrefs.cpp index 7240de05c..5a0f01d5c 100644 --- a/src/helpers/CommonRadioPrefs.cpp +++ b/src/helpers/CommonRadioPrefs.cpp @@ -1,6 +1,7 @@ #include "CommonRadioPrefs.h" #include "TxtDataHelpers.h" #include "target.h" +#include "radiolib/RXPowerSavingCLI.h" #include #include @@ -179,10 +180,37 @@ bool CommonRadioPrefs::setByKey(const char* key, const char* value) { return false; } +void CommonRadioPrefs::formatRxPowerSavingStatus(char* reply, size_t reply_size) const { + auto config = readRxPowerSavingConfig(); + uint8_t sf = getSpreadFactor(); + float bw = getBandwidth(); + uint16_t wire_preamble = 0; + RxPowerSavingStatus status; +#ifdef WRAPPER_CLASS + // The primary profile includes an active tempradio lease. A status read + // must describe that hardware timing without modifying saved settings. + const auto* profiles = radio_driver.profiles(); + if (profiles && profiles->primary.sf >= 5 && profiles->primary.sf <= 12 + && profiles->primary.bw > 0.0f) { + sf = profiles->primary.sf; + bw = profiles->primary.bw; + } + radio_driver.getRxPowerSavingTimings(config.rx_us, config.sleep_us); + wire_preamble = radio_driver.currentPreambleLength(); + status = radio_driver.getRxPowerSavingStatus(); +#endif + RXPowerSavingCLI::get(config, status, sf, bw, reply, reply_size, wire_preamble); +} + bool CommonRadioPrefs::handleCommand(const char* command, uint32_t sender_timestamp, char* reply) { (void)sender_timestamp; if (command == nullptr || reply == nullptr) return false; + if (strcmp(command, "get radio.rxps.status") == 0) { + formatRxPowerSavingStatus(reply, 160); + return true; + } + if (strcmp(command, "get radio") == 0) { char freq[16], bw[16]; snprintf(freq, sizeof(freq), "%s", StrHelper::ftoa(getFreq())); diff --git a/src/helpers/CommonRadioPrefs.h b/src/helpers/CommonRadioPrefs.h index 0aeff6b45..221444243 100644 --- a/src/helpers/CommonRadioPrefs.h +++ b/src/helpers/CommonRadioPrefs.h @@ -1,6 +1,7 @@ #pragma once #include "ConfigSerializer.h" #include "KeyValueStore.h" +#include "radiolib/RXPowerSaving.h" class CommonRadioPrefs : public ConfigSerializer, public KeyValueStore { bool _is_dirty = false; @@ -62,6 +63,9 @@ public: virtual uint8_t getFEMTxGain() const = 0; virtual void setFEMTxGain(uint8_t g) = 0; + 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); bool setByKey(const char* key, const char* value) override; // for dynamic key/value access diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 60168f000..f395f7a88 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -51,6 +51,10 @@ class ESP32Board : public mesh::MainBoard { protected: uint8_t startup_reason; bool inhibit_sleep = false; +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && MOMENTARY_BUTTON_WAKE_FROM_SLEEP + bool button_wake_hold_active = false; + uint32_t button_wake_at = 0; +#endif bool ota_started_ap = false; bool ota_started_radio = false; bool companion_cpu_clock_policy = false; @@ -413,6 +417,12 @@ public: bool keep_awake = inhibit_sleep || hold(usb_sleep_held, Reason::UsbSession, 1) || hold(isRadioTestActive(), Reason::Board, 2); +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && MOMENTARY_BUTTON_WAKE_FROM_SLEEP + if (button_wake_hold_active) { + button_wake_hold_active = (uint32_t)(millis() - button_wake_at) < 30000UL; + keep_awake = hold(button_wake_hold_active, Reason::Button) || keep_awake; + } +#endif #if !defined(ARDUINO_USB_CDC_ON_BOOT) || !ARDUINO_USB_CDC_ON_BOOT // External USB-to-UART chips cannot report host attachment here. Light // sleep loses their first input bytes; UART wake loses the wake character @@ -541,6 +551,13 @@ public: gpio_wakeup_disable((gpio_num_t)button_gpio); // ESP32 MomentaryButton polls; only its sleep wake source is temporary. gpio_set_intr_type((gpio_num_t)button_gpio, GPIO_INTR_DISABLE); +#if defined(MOMENTARY_BUTTON_WAKE_FROM_SLEEP) && MOMENTARY_BUTTON_WAKE_FROM_SLEEP + if (gpio_get_level((gpio_num_t)button_gpio) == USER_BTN_PRESSED) { + // Keep the UI responsive after the wake press has been released. + button_wake_at = millis(); + button_wake_hold_active = true; + } +#endif } // Enable CPU interrupt servicing diff --git a/src/helpers/esp32/ESPNOWRadio.h b/src/helpers/esp32/ESPNOWRadio.h index 128e420af..dd7900afc 100644 --- a/src/helpers/esp32/ESPNOWRadio.h +++ b/src/helpers/esp32/ESPNOWRadio.h @@ -41,6 +41,8 @@ public: void onSendFinished() override; bool isInRecvMode() const override; + uint32_t getCADBusyCount() const { return 0; } // ESP-NOW has no LoRa CAD + uint32_t getPacketsRecv() const { return n_recv; } uint32_t getPacketsSent() const { return n_sent; } uint32_t getPacketsRecvErrors() const { return n_recv_errors; } diff --git a/src/helpers/radiolib/CustomLR1110.h b/src/helpers/radiolib/CustomLR1110.h index fe9219759..3c659ec12 100644 --- a/src/helpers/radiolib/CustomLR1110.h +++ b/src/helpers/radiolib/CustomLR1110.h @@ -29,6 +29,7 @@ class CustomLR1110 : public LR1110 { uint32_t _activityAt = 0; bool _headerSeen = false; bool _rx_boosted = false; + bool _lfClockRestoreNeeded = false; LR1110ProfileSwitchState _profileSwitch{MC_LR1110_FAST_PROFILE_SWITCH != 0}; uint8_t _profileSwitchFeatures = profileSwitchCapabilities(); bool _profileStandbyWarm = false; @@ -381,10 +382,22 @@ class CustomLR1110 : public LR1110 { } float getFreqMHz() const { return freqMHz; } + uint32_t getTcxoDelay() const { return tcxoDelay; } + + // DIO11 also carries RadioLib's IRQ2, so retain the LF RC clock. + bool needsLfClockRestore() const { return _lfClockRestoreNeeded; } + int16_t restoreLfClock() { + if (!_lfClockRestoreNeeded) return RADIOLIB_ERR_NONE; + const int16_t state = configLfClock( + RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED | RADIOLIB_LR11X0_LF_CLK_RC); + if (state == RADIOLIB_ERR_NONE) _lfClockRestoreNeeded = false; + return state; + } int16_t startReceiveDutyCycle(uint32_t rxPeriod, uint32_t sleepPeriod, RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS, - RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK) { + RadioLibIrqFlags_t irqMask = RADIOLIB_IRQ_RX_DEFAULT_MASK, + uint32_t* effectiveRxPeriod = nullptr) { uint32_t symbolPeriod = (uint32_t)(((1000.0f * (float)(1UL << this->spreadingFactor)) / this->bandwidthKhz) + 0.999f); uint32_t transitionTime = this->tcxoDelay + 1000; @@ -402,6 +415,7 @@ class CustomLR1110 : public LR1110 { if (extendedPeriod < requiredExtendedPeriod) { rxPeriod = (uint32_t)((requiredExtendedPeriod - programmedSleepPeriod + 1ULL) / 2ULL); } + if (effectiveRxPeriod) *effectiveRxPeriod = rxPeriod; uint32_t rxPeriodRaw = (uint32_t)(((uint64_t)rxPeriod * 32768UL) / 1000000UL); uint32_t sleepPeriodRaw = @@ -419,6 +433,7 @@ class CustomLR1110 : public LR1110 { // before SetRxDutyCycle. RadioLib does neither in its LoRa RXPS path. int16_t state = standby(RADIOLIB_LR11X0_STANDBY_RC); RADIOLIB_ASSERT(state); + _lfClockRestoreNeeded = true; state = configLfClock( RADIOLIB_LR11X0_LF_CLK_RC | RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED); RADIOLIB_ASSERT(state); diff --git a/src/helpers/radiolib/CustomLR1110Wrapper.h b/src/helpers/radiolib/CustomLR1110Wrapper.h index c77481206..5d79bba83 100644 --- a/src/helpers/radiolib/CustomLR1110Wrapper.h +++ b/src/helpers/radiolib/CustomLR1110Wrapper.h @@ -13,6 +13,10 @@ class CustomLR1110Wrapper : public RadioLibWrapper { DeepInitCallback _deep_init; public: + static constexpr bool SupportsRxPowerSaving = true; + uint32_t rxPowerSavingTransitionUs() const override { + return static_cast(_radio)->getTcxoDelay() + 1000UL; + } uint8_t regulatorModes() const override { return static_cast(_radio)->regulatorModes(); } mesh::power::RegulatorMode defaultRegulatorMode() const override { return static_cast(_radio)->defaultRegulatorMode(); @@ -135,6 +139,7 @@ public: } bool isReceivingPacket() override { + if (_rx_ps_armed && isChipBusy()) return false; return ((CustomLR1110 *)_radio)->isReceiving(); } bool isChipBusy() override { @@ -156,10 +161,11 @@ public: _radio->setPreambleLength(currentPreambleLength()); // overcomes weird issues with small and big pkts } - bool supportsRxPowerSaving() const override { return true; } + bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; } void onReceiveProcessed() override { finishReceiveProcessing(); } protected: + int startReceiveMode() override { return startReceivePowerSaving(); } bool isPacketReady() override { // Header errors are recovery events, not packets. Handle them before the // generic duty-cycle readiness check can discard the IRQ or a stale buffer @@ -178,51 +184,26 @@ protected: return RadioLibWrapper::isPacketReady(); } - int startReceiveMode() override { - // Do not abort a frame that started while a calibration transition was - // being scheduled. recvRaw() will move it to continuous RX afterward. - if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) { - return RADIOLIB_ERR_NONE; - } - if (_rx_ps_armed) { - // stop the previous duty-cycle sequence with an explicit standby before - // reconfiguring the radio - stopReceiveDutyCycle(); - } - if (!_rx_ps_enabled || _nf_calib_active) { - // plain continuous RX: powersaving off, or a periodic noise-floor - // calibration window is in progress - if (!_rx_ps_enabled) _rx_ps_continuous_fallback = false; - return _radio->startReceive(); - } - - if (rxPowerSavingUsesContinuousFallback(_rx_ps_rx_us, _rx_ps_sleep_us)) { - _rx_ps_continuous_fallback = true; - return _radio->startReceive(); - } - - _rx_ps_continuous_fallback = false; - - const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; - // route RX timeout (false preamble detect) and error IRQs to the IRQ pin - // as well, so the wrapper re-arms RX instead of waiting forever - const RadioLibIrqFlags_t irqMask = - (1UL << RADIOLIB_IRQ_RX_DONE) | - (1UL << RADIOLIB_IRQ_TIMEOUT) | - (1UL << RADIOLIB_IRQ_CRC_ERR) | - (1UL << RADIOLIB_IRQ_HEADER_ERR); - - int err = ((CustomLR1110 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask); - if (err == RADIOLIB_ERR_NONE) { - _rx_ps_armed = true; - return err; - } - - _rx_ps_continuous_fallback = true; - MESH_DEBUG_PRINTLN("CustomLR1110Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err); - return _radio->startReceive(); + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* effective_rx_us, uint32_t* effective_sleep_us) override { + *effective_sleep_us = _rx_ps_sleep_us; + return ((CustomLR1110*)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask, effective_rx_us); } + int16_t stopDutyCycleHardware() override { + const int16_t standby_error = _radio->standby(); + const int16_t clock_error = ((CustomLR1110*)_radio)->restoreLfClock(); + return standby_error != RADIOLIB_ERR_NONE ? standby_error : clock_error; + } + + int16_t startContinuousReceive() override { + auto* radio = static_cast(_radio); + if (!radio->needsLfClockRestore()) return _radio->startReceive(); + int16_t clock_error = _radio->standby(); + if (clock_error == RADIOLIB_ERR_NONE) clock_error = radio->restoreLfClock(); + return clock_error == RADIOLIB_ERR_NONE ? _radio->startReceive() : clock_error; + } bool radioDeepInit() override { return _deep_init != NULL && prepareRadioHardReset() && _deep_init() && _board->finishRadioHardReset(); diff --git a/src/helpers/radiolib/CustomSX1262.h b/src/helpers/radiolib/CustomSX1262.h index cceaeadea..a5d2bee40 100644 --- a/src/helpers/radiolib/CustomSX1262.h +++ b/src/helpers/radiolib/CustomSX1262.h @@ -74,6 +74,7 @@ class CustomSX1262 : public SX1262 { bool _tcxoWakePending = false; public: + uint32_t getTcxoDelay() const { return tcxoDelay; } CustomSX1262(Module *mod) : SX1262(mod) { } uint8_t regulatorModes() const { return _regulator.modes(); } diff --git a/src/helpers/radiolib/CustomSX1262Wrapper.h b/src/helpers/radiolib/CustomSX1262Wrapper.h index 94b6f82a1..f6727fe8f 100644 --- a/src/helpers/radiolib/CustomSX1262Wrapper.h +++ b/src/helpers/radiolib/CustomSX1262Wrapper.h @@ -53,6 +53,10 @@ protected: } public: + static constexpr bool SupportsRxPowerSaving = true; + uint32_t rxPowerSavingTransitionUs() const override { + return static_cast(_radio)->getTcxoDelay() + 1000UL; + } uint8_t regulatorModes() const override { return static_cast(_radio)->regulatorModes(); } mesh::power::RegulatorMode defaultRegulatorMode() const override { return static_cast(_radio)->defaultRegulatorMode(); @@ -182,6 +186,7 @@ public: } bool isReceivingPacket() override { + if (_rx_ps_armed && isChipBusy()) return false; return ((CustomSX1262 *)_radio)->isReceiving(); } bool isChipBusy() override { @@ -198,11 +203,12 @@ public: uint8_t getSpreadingFactor() const override { return ((CustomSX1262 *)_radio)->spreadingFactor; } void powerOff() { if (isCarrierWaveActive()) setCarrierWave(0, 0); + if (_rx_ps_armed) stopReceiveDutyCycle(); ((CustomSX1262 *)_radio)->standby(); ((CustomSX1262 *)_radio)->sleep(false); } - bool supportsRxPowerSaving() const override { return true; } + bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; } void onReceiveProcessed() override { finishReceiveProcessing(); } bool supportsRxPowerSavingRfRxDisable() const override { @@ -261,57 +267,20 @@ public: } protected: - int startReceiveMode() override { - // Do not abort a frame that started while a calibration transition was - // being scheduled. recvRaw() will move it to continuous RX afterward. - if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) { - return RADIOLIB_ERR_NONE; - } - if (_rx_ps_armed) { - // leaving duty-cycle mode (after RxDone or a reconfig): stop the - // sequencer and the still-running RTC, or its pending event can - // silently abort the RX we are about to start - stopReceiveDutyCycle(); - } - if (!_rx_ps_enabled || _nf_calib_active) { - // plain continuous RX: powersaving off, or a periodic noise-floor - // calibration window is in progress - if (!_rx_ps_enabled) _rx_ps_continuous_fallback = false; - return _radio->startReceive(); - } - - if (!((CustomSX1262 *)_radio)->canUseRxPowerSavingDutyCycle( - _rx_ps_rx_us, _rx_ps_sleep_us)) { - _rx_ps_continuous_fallback = true; - return _radio->startReceive(); - } - - _rx_ps_continuous_fallback = false; - - const RadioLibIrqFlags_t irqFlags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS; - const RadioLibIrqFlags_t irqMask = - (1UL << RADIOLIB_IRQ_RX_DONE) | - (1UL << RADIOLIB_IRQ_TIMEOUT) | - (1UL << RADIOLIB_IRQ_CRC_ERR) | - (1UL << RADIOLIB_IRQ_HEADER_ERR); - - int err = ((CustomSX1262 *)_radio)->startReceiveDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, irqFlags, irqMask); - if (err == RADIOLIB_ERR_NONE) { - _rx_ps_armed = true; - return err; - } - - _rx_ps_continuous_fallback = true; - MESH_DEBUG_PRINTLN("CustomSX1262Wrapper: error: startReceiveDutyCycle(%d), falling back to continuous RX", err); - return _radio->startReceive(); + int startReceiveMode() override { return startReceivePowerSaving(); } + int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* effective_rx_us, uint32_t* effective_sleep_us) override { + *effective_rx_us = _rx_ps_rx_us; + *effective_sleep_us = _rx_ps_sleep_us; + return ((CustomSX1262*)_radio)->startReceiveDutyCycle( + _rx_ps_rx_us, _rx_ps_sleep_us, irq_flags, irq_mask); } - void stopReceiveDutyCycle() override { - _radio->standby(); // also wakes the chip if it is in the sleep window - ((CustomSX1262 *)_radio)->stopRTC(); - _rx_ps_armed = false; + int16_t stopDutyCycleHardware() override { + const int16_t standby_error = _radio->standby(); + const int16_t rtc_error = ((CustomSX1262*)_radio)->stopRTC(); + return standby_error != RADIOLIB_ERR_NONE ? standby_error : rtc_error; } - bool radioDeepInit() override { // A board hook takes precedence over direct NRST because it may also need // to recover a regulator, SPI bus, or I/O expander. With neither mechanism diff --git a/src/helpers/radiolib/RXPowerSaving.cpp b/src/helpers/radiolib/RXPowerSaving.cpp new file mode 100644 index 000000000..e973ef90b --- /dev/null +++ b/src/helpers/radiolib/RXPowerSaving.cpp @@ -0,0 +1,41 @@ +#include "RXPowerSaving.h" +#include + +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols, uint32_t transition_us) { + if (config == nullptr || sf < 5 || sf > 12 || !isfinite(bw) || bw <= 0 + || bw > 1000000.0f || !(capture_cost_symbols > 0.0f) + || !isfinite(capture_cost_symbols) || transition_us >= RX_POWERSAVING_MAX_PERIOD_US) return false; + RxPowerSavingConfig proposed = *config; + proposed.enabled = proposed.enabled ? 1 : 0; + if (proposed.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL) { + proposed.level = RX_POWERSAVING_BALANCED_LEVEL; + } + if (proposed.preamble != 0 && !isRxPowerSavingPreamble(proposed.preamble)) { + proposed.preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + } + if (proposed.level == 0) { + if (proposed.rx_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US || + proposed.sleep_us < RX_POWERSAVING_MIN_MANUAL_PERIOD_US || + !isValidRxPowerSavingPeriod(proposed.rx_us) || + !isValidRxPowerSavingPeriod(proposed.sleep_us)) return false; + } else if (!recalcRxPowerSavingFromLevel(proposed.level, sf, bw, proposed.preamble, + &proposed.rx_us, &proposed.sleep_us, + nullptr, nullptr, transition_us, capture_cost_symbols)) { + return false; + } + *config = proposed; + return true; +} + +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control) { + if (config == nullptr) return false; + const bool valid = !config->enabled || normalizeRxPowerSavingConfig( + config, sf, bw, rxPowerSavingCaptureCost(control), rxPowerSavingTransition(control)); + if (control == nullptr) return !config->enabled; + const bool accepted = control->setRxPowerSaving( + config->enabled && valid, config->rx_us, config->sleep_us); + control->setRxPowerSavingConfigError(!valid); + return valid && accepted; +} diff --git a/src/helpers/radiolib/RXPowerSaving.h b/src/helpers/radiolib/RXPowerSaving.h index 072f2ccb7..139a77c32 100644 --- a/src/helpers/radiolib/RXPowerSaving.h +++ b/src/helpers/radiolib/RXPowerSaving.h @@ -1,5 +1,6 @@ #pragma once -#include +#include +#include #define RX_POWERSAVING_DEFAULT_RX_US 65625UL #define RX_POWERSAVING_DEFAULT_SLEEP_US 60000UL @@ -345,7 +346,9 @@ inline bool calcRxPowerSavingLevel(uint8_t level, uint8_t sf, float bw, uint8_t uint32_t transition_us = rxPowerSavingDefaultTransitionUs()) { if (rx_us == nullptr || sleep_us == nullptr || level < 1 || level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || sf < 5 || sf > 12 || - !(bw > 0.0f) || bw > 1000000.0f || !isRxPowerSavingPreamble(preamble)) { + !(bw > 0.0f) || bw > 1000000.0f || !isRxPowerSavingPreamble(preamble) + || !(capture_cost_symbols > 0.0f) || !(capture_cost_symbols < preamble) + || transition_us >= RX_POWERSAVING_MAX_PERIOD_US) { return false; } const bool unguarded = isRxPowerSavingUnguardedLevel(level); @@ -466,10 +469,12 @@ inline bool calcRxPowerSavingLevelAtOrAbove( uint8_t requested_level, uint8_t sf, float bw, uint8_t preamble, uint32_t transition_us, uint32_t *rx_us, uint32_t *sleep_us, uint8_t *effective_level = nullptr, - uint8_t *effective_preamble = nullptr) { + uint8_t *effective_preamble = nullptr, + float capture_cost_symbols = rxPowerSavingDefaultCaptureCost()) { if (!rx_us || !sleep_us || requested_level < 1 || requested_level > 10 || sf < 5 || sf > 12 || !(bw > 0.0f) || bw > 1000000.0f - || !isRxPowerSavingPreamble(preamble)) { + || !isRxPowerSavingPreamble(preamble) || !(capture_cost_symbols > 0.0f) + || !(capture_cost_symbols < preamble) || transition_us >= RX_POWERSAVING_MAX_PERIOD_US) { return false; } @@ -491,7 +496,7 @@ inline bool calcRxPowerSavingLevelAtOrAbove( uint32_t candidate_rx_us = 0, candidate_sleep_us = 0; if (!calcRxPowerSavingLevel(level, sf, bw, candidate_preamble, &candidate_rx_us, &candidate_sleep_us, - rxPowerSavingDefaultCaptureCost(), transition_us)) continue; + capture_cost_symbols, transition_us)) continue; // The catch assumption can be conservative, but the receive timeout // must still cover the entire preamble our senders actually transmit. @@ -544,7 +549,8 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui uint32_t *rx_ps_rx_us, uint32_t *rx_ps_sleep_us, uint8_t *effective_level = nullptr, uint8_t *effective_preamble = nullptr, - uint32_t transition_us = rxPowerSavingDefaultTransitionUs()) { + uint32_t transition_us = rxPowerSavingDefaultTransitionUs(), + float capture_cost_symbols = rxPowerSavingDefaultCaptureCost()) { if (level < 1 || level > 10) { return false; // manual: nothing to recompute } @@ -556,7 +562,7 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui uint32_t rx_us, sleep_us; if (!calcRxPowerSavingLevelAtOrAbove( level, sf, bw, preamble, transition_us, &rx_us, &sleep_us, - effective_level, effective_preamble)) { + effective_level, effective_preamble, capture_cost_symbols)) { return false; } @@ -569,3 +575,68 @@ inline bool recalcRxPowerSavingFromLevel(uint8_t level, uint8_t sf, float bw, ui return true; } + +// Runtime RXPS control is separate from the persisted role-specific layout. +class RxPowerSavingArmRetryState { + uint8_t _consecutive_failures = 0; +public: + bool canAttempt() const { return _consecutive_failures < RX_POWERSAVING_MAX_CONSEC_ARM_FAILURES; } + void recordSuccess() { _consecutive_failures = 0; } + void recordFailure() { + if (canAttempt()) ++_consecutive_failures; + } + void reset() { _consecutive_failures = 0; } + uint8_t consecutiveFailures() const { return _consecutive_failures; } +}; + +struct RxPowerSavingConfig { + uint8_t enabled = 0; + uint32_t rx_us = RX_POWERSAVING_DEFAULT_RX_US; + uint32_t sleep_us = RX_POWERSAVING_DEFAULT_SLEEP_US; + uint8_t level = RX_POWERSAVING_BALANCED_LEVEL; + uint8_t preamble = RX_POWERSAVING_PROFILE_PREAMBLE; + + constexpr RxPowerSavingConfig() = default; + constexpr RxPowerSavingConfig(uint8_t enabled_value, uint32_t rx_value, + uint32_t sleep_value, uint8_t level_value, uint8_t preamble_value) + : enabled(enabled_value), rx_us(rx_value), sleep_us(sleep_value), + level(level_value), preamble(preamble_value) { } +}; + +struct RxPowerSavingStatus { + bool supported = false; + bool armed = false; + int16_t last_error = 0; + uint32_t arm_failures = 0; + uint32_t effective_rx_us = 0; + uint32_t effective_sleep_us = 0; +}; + +class RxPowerSavingControl { +protected: + ~RxPowerSavingControl() = default; +public: + virtual bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) { + (void)rx_us; (void)sleep_us; + return !enabled; + } + virtual RxPowerSavingStatus getRxPowerSavingStatus() const { return {}; } + virtual void setRxPowerSavingConfigError(bool invalid) { (void)invalid; } + virtual float rxPowerSavingCaptureCostSymbols() const { return rxPowerSavingDefaultCaptureCost(); } + virtual uint32_t rxPowerSavingTransitionUs() const { return rxPowerSavingDefaultTransitionUs(); } +}; + +inline uint8_t rxPowerSavingPreambleForSF(uint8_t sf) { return sf <= 8 ? 32 : 16; } +inline bool isRxPowerSavingNumeric(const char* value) { return isNumeric(value); } +inline float rxPowerSavingCaptureCost(const RxPowerSavingControl* control) { + return control ? control->rxPowerSavingCaptureCostSymbols() : rxPowerSavingDefaultCaptureCost(); +} +inline uint32_t rxPowerSavingTransition(const RxPowerSavingControl* control) { + return control ? control->rxPowerSavingTransitionUs() : rxPowerSavingDefaultTransitionUs(); +} +bool normalizeRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + float capture_cost_symbols = rxPowerSavingDefaultCaptureCost(), + uint32_t transition_us = rxPowerSavingDefaultTransitionUs()); +bool applyRxPowerSavingConfig(RxPowerSavingConfig* config, uint8_t sf, float bw, + RxPowerSavingControl* control); +static constexpr int16_t RX_POWERSAVING_ERR_TIMING = -10001; diff --git a/src/helpers/radiolib/RXPowerSavingCLI.cpp b/src/helpers/radiolib/RXPowerSavingCLI.cpp new file mode 100644 index 000000000..79c839d6e --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.cpp @@ -0,0 +1,120 @@ +#include "RXPowerSavingCLI.h" +#include "../CLICommandUtils.h" +#include +#include +#include + +static const char* rxPowerSavingLevelTag(uint8_t level) { + if (isRxPowerSavingRiskyWorkingMaxLevel(level)) return "(riskyWorkingMax)"; + if (isRxPowerSavingOverdriveLevel(level)) return "(overdrive)"; + return ""; +} + +bool RXPowerSavingCLI::set(const char* value, uint8_t sf, float bw, + RxPowerSavingConfig* config, RxPowerSavingControl* control, + char* reply, size_t reply_size) { + if (!config || !reply || !reply_size) return false; + mesh::cli::RxPowerSavingArguments args = {}; + if (!mesh::cli::parseRxPowerSavingArgumentsStrict(value, args)) { + snprintf(reply, reply_size, "ERROR: use off|on|conservative|balanced|max|overdrive|riskyWorkingMax|level <1-10>| "); + return false; + } + RxPowerSavingConfig proposed = *config; + using Mode = mesh::cli::RxPowerSavingArgumentMode; + if (args.mode == Mode::Off) { + proposed.enabled = 0; + } else if (args.mode == Mode::Manual) { + proposed.enabled = 1; + proposed.level = 0; + proposed.preamble = 0; + proposed.rx_us = args.rx_us; + proposed.sleep_us = args.sleep_us; + } else { + proposed.enabled = 1; + proposed.level = args.mode == Mode::Conservative ? RX_POWERSAVING_CONSERVATIVE_LEVEL + : args.mode == Mode::Balanced ? RX_POWERSAVING_BALANCED_LEVEL : args.level; + const uint32_t preamble = args.mode == Mode::Level ? args.preamble : RX_POWERSAVING_PROFILE_PREAMBLE; + if (args.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || + proposed.level < 1 || proposed.level > RX_POWERSAVING_RISKY_WORKING_MAX_LEVEL || + (preamble != 0 && (preamble > 128 || !isRxPowerSavingPreamble(preamble)))) { + snprintf(reply, reply_size, "ERROR: level range is 1-10; preamble is 16,32,64,128"); + return false; + } + proposed.preamble = static_cast(preamble); + } + if (proposed.enabled && !normalizeRxPowerSavingConfig(&proposed, sf, bw, + rxPowerSavingCaptureCost(control), rxPowerSavingTransition(control))) { + snprintf(reply, reply_size, "ERROR: invalid RXPS timing or SF/BW"); + return false; + } + if (control ? !control->setRxPowerSaving(proposed.enabled != 0, proposed.rx_us, proposed.sleep_us) + : proposed.enabled != 0) { + snprintf(reply, reply_size, "ERROR: RX powersaving unsupported or radio busy"); + return false; + } + if (control) control->setRxPowerSavingConfigError(false); + *config = proposed; + snprintf(reply, reply_size, "OK - %s,level=%lu%s,preamble=%lu,rx=%lu,sleep=%lu", + config->enabled ? "on" : "off", (unsigned long)config->level, + rxPowerSavingLevelTag(config->level), (unsigned long)config->preamble, + (unsigned long)config->rx_us, (unsigned long)config->sleep_us); + return true; +} + +void RXPowerSavingCLI::get(const RxPowerSavingConfig* config, + const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size, + uint16_t wire_preamble) { + if (config == nullptr) return; + get(*config, control ? control->getRxPowerSavingStatus() : RxPowerSavingStatus{}, + sf, bw, reply, reply_size, wire_preamble); +} + +void RXPowerSavingCLI::get(const RxPowerSavingConfig& config, + const RxPowerSavingStatus& status, + uint8_t sf, float bw, char* reply, size_t reply_size, + uint16_t wire_preamble) { + if (reply == nullptr || reply_size == 0) return; + // Preamble symbols the node actually catches. Not simply the number the level + // asked for: the sleep floor can shorten the sleep further, and at SF6 with + // the 16-symbol profile that collapses the first few levels onto the same + // point. Reporting the requested value there would be a lie. + char margin[24]; + margin[0] = 0; + if (status.supported && status.armed && status.last_error == 0 && isValidRxPowerSavingPeriod(config.sleep_us) && + config.level >= 1 && config.level <= RX_POWERSAVING_GUARDED_LEVELS && + sf >= 5 && sf <= 12 && bw > 0.0f && bw <= 500.0f) { + const uint16_t preamble = wire_preamble ? wire_preamble : rxPowerSavingPreambleForParams(sf, bw); + const float symbol_us = (1000.0f * (float)(1UL << sf)) / bw; + const float effective = (float)preamble - (float)config.sleep_us / symbol_us; + // No %f on this platform's printf, and rounding the whole and fractional + // parts separately overflows: 4.999 came out as "4.10". + const float scaled = effective * 10.0f + (effective < 0.0f ? -0.5f : 0.5f); + // Validated 30-second periods, SF5+ and these radio-family bandwidths + // bound the result well inside int32, including a 65535-symbol override. + const int tenths = (int)scaled; + snprintf(margin, sizeof(margin), ",catch=%d.%d", tenths / 10, + tenths < 0 ? -(tenths % 10) : tenths % 10); + } + + // Overdrive has to be visible here, otherwise it reads as an ordinary level + // and nobody remembers it runs outside the datasheet rule. + int len = snprintf(reply, reply_size, + "> desired=%s,effective=%s,supported=%s,err=%d,fail=%lu,level=%lu%s%s,preamble=%lu,rx=%lu,sleep=%lu", + config.enabled ? "on" : "off", status.armed ? "armed" : "continuous", + status.supported ? "yes" : "no", (int)status.last_error, + (unsigned long)status.arm_failures, (unsigned long)config.level, + isRxPowerSavingUnguardedLevel(config.level) ? "(!)" : "", margin, + (unsigned long)config.preamble, (unsigned long)config.rx_us, + (unsigned long)config.sleep_us); + + // Only report the periods the hardware really runs with when a driver had to + // clamp them - printing them always would not fit the 160 byte reply buffer. + if (len > 0 && (size_t)len < reply_size && status.armed && + (status.effective_rx_us != config.rx_us || + status.effective_sleep_us != config.sleep_us)) { + snprintf(reply + len, reply_size - (size_t)len, ",erx=%lu,eslp=%lu", + (unsigned long)status.effective_rx_us, + (unsigned long)status.effective_sleep_us); + } +} diff --git a/src/helpers/radiolib/RXPowerSavingCLI.h b/src/helpers/radiolib/RXPowerSavingCLI.h new file mode 100644 index 000000000..0b2717490 --- /dev/null +++ b/src/helpers/radiolib/RXPowerSavingCLI.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +#include "RXPowerSaving.h" + +class RXPowerSavingCLI { +public: + static bool set(const char* value, uint8_t sf, float bw, RxPowerSavingConfig* config, + RxPowerSavingControl* control, char* reply, size_t reply_size); + // sf/bw are needed to turn the stored periods back into symbols, which is + // what the level scale is expressed in. + static void get(const RxPowerSavingConfig* config, const RxPowerSavingControl* control, + uint8_t sf, float bw, char* reply, size_t reply_size, + uint16_t wire_preamble = 0); + static void get(const RxPowerSavingConfig& config, const RxPowerSavingStatus& status, + uint8_t sf, float bw, char* reply, size_t reply_size, + uint16_t wire_preamble = 0); +}; diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index cb9f0525d..978c2f49d 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -588,6 +588,8 @@ mesh::RadioParamApplyResult RadioLibWrapper::trySetParams(float freq, float bw, else _rx_ps_enabled = true; _rx_ps_rx_us = rx_ps_timings[0]; _rx_ps_sleep_us = rx_ps_timings[1]; + _rx_ps_arm_retry.reset(); + _rx_ps_last_error = RADIOLIB_ERR_NONE; _rx_ps_continuous_fallback = rxPowerSavingUsesContinuousFallback( _rx_ps_rx_us, _rx_ps_sleep_us); } @@ -2440,7 +2442,7 @@ void RadioLibWrapper::startRecv() { // plus a worst-case packet airtime, plus margin for TCXO/transitions. // Floored at 60s so a light-sleeping MCU (ESP32 wakes every ~30s) opens // an observation window every couple of wakeups instead of on each one. - uint32_t rx_ms = _rx_ps_rx_us / 1000, sleep_ms = _rx_ps_sleep_us / 1000; + uint32_t rx_ms = _rx_ps_eff_rx_us / 1000, sleep_ms = _rx_ps_eff_sleep_us / 1000; _wd_stuck_thresh = (rx_ms + sleep_ms) + 2 * (2 * rx_ms + sleep_ms) + getEstAirtimeFor(MAX_TRANS_UNIT) + 1000; if (_wd_stuck_thresh < 60000) _wd_stuck_thresh = 60000; @@ -2464,14 +2466,65 @@ void RadioLibWrapper::startRecv() { } int RadioLibWrapper::startReceiveMode() { - return _radio->startReceive(); + return startContinuousReceive(); +} + +int RadioLibWrapper::startReceivePowerSaving() { + if (_nf_calib_active && _rx_ps_armed && isPacketPendingOrReceiving()) { + return RADIOLIB_ERR_NONE; + } + if (_rx_ps_armed) stopReceiveDutyCycle(); + if (!_rx_ps_enabled || _nf_calib_active) { + if (!_rx_ps_enabled) _rx_ps_continuous_fallback = false; + const int16_t err = startContinuousReceive(); + if (err != RADIOLIB_ERR_NONE || !_rx_ps_enabled) _rx_ps_last_error = err; + return err; + } + const bool planned_continuous = rxPowerSavingUsesContinuousFallback(_rx_ps_rx_us, _rx_ps_sleep_us); + const bool invalid_timing = !planned_continuous + && !canStartRxPowerSavingDutyCycle(_rx_ps_rx_us, _rx_ps_sleep_us, rxPowerSavingTransitionUs()); + if (planned_continuous || invalid_timing || !_rx_ps_arm_retry.canAttempt()) { + _rx_ps_continuous_fallback = true; + if (invalid_timing) _rx_ps_last_error = RX_POWERSAVING_ERR_TIMING; + const int16_t err = startContinuousReceive(); + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; + return err; + } + const RadioLibIrqFlags_t irq_flags = RADIOLIB_IRQ_RX_DEFAULT_FLAGS + | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED); + const RadioLibIrqFlags_t irq_mask = (1UL << RADIOLIB_IRQ_RX_DONE) + | (1UL << RADIOLIB_IRQ_TIMEOUT) | (1UL << RADIOLIB_IRQ_CRC_ERR) + | (1UL << RADIOLIB_IRQ_HEADER_ERR); + uint32_t effective_rx_us = _rx_ps_rx_us, effective_sleep_us = _rx_ps_sleep_us; + const int16_t err = armDutyCycle(irq_flags, irq_mask, &effective_rx_us, &effective_sleep_us); + _rx_ps_last_error = err; + if (err == RADIOLIB_ERR_NONE) { + _rx_ps_armed = true; + _rx_ps_continuous_fallback = false; + _rx_ps_eff_rx_us = effective_rx_us; + _rx_ps_eff_sleep_us = effective_sleep_us; + _rx_ps_arm_retry.recordSuccess(); + return err; + } + _rx_ps_armed = false; + _rx_ps_continuous_fallback = true; + _rx_ps_eff_rx_us = _rx_ps_eff_sleep_us = 0; + ++_rx_ps_arm_failures; + _rx_ps_arm_retry.recordFailure(); + MESH_DEBUG_PRINTLN("RadioLibWrapper: startReceiveDutyCycle(%d), continuous RX fallback", err); + stopDutyCycleHardware(); + const int16_t fallback_error = startContinuousReceive(); + if (fallback_error != RADIOLIB_ERR_NONE) _rx_ps_last_error = fallback_error; + return fallback_error; } void RadioLibWrapper::stopReceiveDutyCycle() { // The duty-cycle sequencer only stops on RxDone or an explicit standby; // issuing other mode commands while it runs leads to undefined behaviour. - _radio->standby(); + const int16_t err = stopDutyCycleHardware(); _rx_ps_armed = false; + _rx_ps_eff_rx_us = _rx_ps_eff_sleep_us = 0; + if (err != RADIOLIB_ERR_NONE) _rx_ps_last_error = err; } bool RadioLibWrapper::isPacketReady() { @@ -2547,12 +2600,17 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl if (_beacon_owned) return false; #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 (_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; } @@ -2562,12 +2620,25 @@ bool RadioLibWrapper::setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sl _rx_ps_enabled = 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; _rx_ps_continuous_fallback = enabled && rxPowerSavingUsesContinuousFallback(rx_us, sleep_us); endReconfigure(resume_rx); return true; } +RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus() const { + RxPowerSavingStatus status; + status.supported = supportsRxPowerSaving(); + status.armed = _rx_ps_armed; + status.last_error = _rx_ps_last_error; + status.arm_failures = _rx_ps_arm_failures; + status.effective_rx_us = _rx_ps_armed ? _rx_ps_eff_rx_us : 0; + status.effective_sleep_us = _rx_ps_armed ? _rx_ps_eff_sleep_us : 0; + return status; +} + int RadioLibWrapper::recvRaw(uint8_t* bytes, int sz) { #if MESH_RADIO_TRACE_ENABLED auto& radio_trace = mesh::radiotrace::trace(); diff --git a/src/helpers/radiolib/RadioLibWrappers.h b/src/helpers/radiolib/RadioLibWrappers.h index d14c82f30..7314c7a94 100644 --- a/src/helpers/radiolib/RadioLibWrappers.h +++ b/src/helpers/radiolib/RadioLibWrappers.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "NoiseFloorEstimator.h" #include "../TelemetryHistoryConfig.h" #include "../CarrierTools.h" @@ -35,6 +37,8 @@ struct PacketMillis { }; class RadioLibWrapper : public mesh::Radio { +public: + static constexpr bool SupportsRxPowerSaving = false; protected: // Mode is owned by this loop, not by the ISR or another wrapper. uint8_t state = 0; @@ -228,6 +232,12 @@ protected: unsigned long _nf_last_calib; // millis of last completed/attempted window unsigned long _nf_calib_deadline; // abort window if the batch can't complete unsigned long _nf_sample_from; // no samples before this (RX entry settle) + // Append diagnostic state so existing radio hot fields retain their offsets. + uint32_t _rx_ps_eff_rx_us = 0; + uint32_t _rx_ps_eff_sleep_us = 0; + int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE; + uint32_t _rx_ps_arm_failures = 0; + RxPowerSavingArmRetryState _rx_ps_arm_retry; // The T096 SX1262 bench test found RSSI valid and stable by 7 ms after RX // entry, including the 500 kHz/SF5 worst-case profile. Retain the // conservative 20 ms path for the other radio families. @@ -318,7 +328,15 @@ protected: int16_t performChannelScanWithTimeout(unsigned long timeout_ms); #endif virtual int startReceiveMode(); + int startReceivePowerSaving(); virtual void stopReceiveDutyCycle(); + virtual int16_t armDutyCycle(RadioLibIrqFlags_t irq_flags, RadioLibIrqFlags_t irq_mask, + uint32_t* effective_rx_us, uint32_t* effective_sleep_us) { + (void)irq_flags; (void)irq_mask; (void)effective_rx_us; (void)effective_sleep_us; + return RADIOLIB_ERR_UNSUPPORTED; + } + virtual int16_t stopDutyCycleHardware() { return _radio->standby(); } + virtual int16_t startContinuousReceive() { return _radio->startReceive(); } virtual bool isPacketReady(); // true while the chip cannot service SPI (RX duty-cycle sleep window, or // briefly while processing a command); radios expose this via the BUSY pin @@ -526,6 +544,13 @@ public: void onSendFinished() override; bool isInRecvMode() const override; bool setRxPowerSaving(bool enabled, uint32_t rx_us, uint32_t sleep_us) override; + bool supportsRxPowerSaving() const override { return SupportsRxPowerSaving; } + RxPowerSavingStatus getRxPowerSavingStatus() const; + virtual uint32_t rxPowerSavingTransitionUs() const { return rxPowerSavingDefaultTransitionUs(); } + void getRxPowerSavingTimings(uint32_t& rx_us, uint32_t& sleep_us) const { + rx_us = _rx_ps_rx_us; + sleep_us = _rx_ps_sleep_us; + } bool isRxPowerSavingContinuousFallback() const override { return _rx_ps_enabled && _rx_ps_continuous_fallback; } @@ -639,7 +664,10 @@ public: bool getReceiveRetryDelay(uint32_t now, uint32_t& delay_ms) const override; bool getNoiseFloorWakeDelay(uint32_t now, uint32_t& delay_ms) const override; bool getProfileIdleDelay(uint32_t& delay_ms) const override; - void resetStats() { n_recv = n_sent = n_recv_errors = 0; } + void resetStats() { + n_recv = n_sent = n_recv_errors = _rx_ps_arm_failures = 0; + _rx_ps_arm_retry.reset(); + } uint8_t getRadioState() const override; unsigned long getLastRecvMillis() const override { return last_recv_millis; } diff --git a/test/fixtures/official_web_console/firmware.cpp b/test/fixtures/official_web_console/firmware.cpp index e3ee7f701..c755caab8 100644 --- a/test/fixtures/official_web_console/firmware.cpp +++ b/test/fixtures/official_web_console/firmware.cpp @@ -64,7 +64,7 @@ struct MyMesh { bool millisHasNowPassed(unsigned deadline) { return millis() > deadline; } } the_mesh; static ESP32Board board; -static constexpr unsigned POWERSAVING_FIRSTSLEEP_SECS = 120; +static constexpr unsigned POWERSAVING_FIRSTSLEEP_SECS = 300; static constexpr size_t LOCAL_SERIAL_COMMAND_MAX = 159; static char command[LOCAL_SERIAL_COMMAND_MAX + 2] = {}; static bool command_overflow = false; diff --git a/test/test_beacon_radio.py b/test/test_beacon_radio.py index 42c2451d8..39a52b475 100644 --- a/test/test_beacon_radio.py +++ b/test/test_beacon_radio.py @@ -94,6 +94,7 @@ struct RadioLibWrapper { uint16_t _rx_retry_delay_ms = 0; bool _rx_retry_pending = false; uint32_t _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000; + uint32_t _rx_ps_eff_rx_us = 50000, _rx_ps_eff_sleep_us = 50000; uint32_t _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0; uint32_t n_wd_soft = 0, n_wd_hard = 0; unsigned long _nf_sample_from = 0; diff --git a/test/test_cli_settings_contract.py b/test/test_cli_settings_contract.py index fda1e74c9..6453005fe 100644 --- a/test/test_cli_settings_contract.py +++ b/test/test_cli_settings_contract.py @@ -60,6 +60,7 @@ COMMON_QUERY_EXCEPTIONS = { 'pwrmgt.source': 'Measured power source', 'pwrmgt.support': 'Hardware power-management capabilities', 'radio.rxps.config': 'Detailed view of set radio.rxps', + 'radio.rxps.status': 'Live radio status for set radio.rxps', 'role': 'Build-selected firmware role', 'rxps.wd': 'Runtime RX power-saving recovery counters', 'wifi.pwd': 'Setter is in the observer or standalone-WiFi delegate', diff --git a/test/test_common_radio_persistence.py b/test/test_common_radio_persistence.py index e6fcd6ddd..1ad5af9df 100644 --- a/test/test_common_radio_persistence.py +++ b/test/test_common_radio_persistence.py @@ -20,6 +20,7 @@ HARNESS = r''' #include #include #include +#include #if defined(ESP32_PLATFORM) #include #endif @@ -28,6 +29,12 @@ HARNESS = r''' #define MAX_LORA_TX_POWER 22 #include struct NodePrefs { @FIELDS@ }; +struct RadioPrefsAdapter { + NodePrefs* _parent; + bool dirty = false; + void markDirty() { dirty = true; } + @RXPS_READ@ +}; void markDirectRetryPrefsValid(NodePrefs*) {} bool isValidLoRaBandwidth(float bw) { return bw==62.5f || bw==125; } namespace mesh { @@ -92,6 +99,28 @@ struct Capture { std::vector bytes; size_t write(const uint8_t* p,size_t n) { bytes.insert(bytes.end(),p,p+n);return n; } }; int main() { + NodePrefs stored{}; + stored.freq = 911.150f; + stored.powersaving_enabled = 1; + stored.rx_powersaving_enabled = 1; + stored.rx_ps_rx_us = 1234; + stored.rx_ps_sleep_us = 5678; + stored.rx_ps_level = 6; + stored.rx_ps_preamble = 32; + RadioPrefsAdapter adapter{&stored}; + auto snapshot = adapter.readRxPowerSavingConfig(); + assert(snapshot.enabled == 1 && snapshot.rx_us == 1234); + assert(snapshot.sleep_us == 5678 && snapshot.level == 6 && snapshot.preamble == 32); + snapshot.enabled = 0; + snapshot.rx_us = 2345; + snapshot.sleep_us = 6789; + snapshot.level = 7; + snapshot.preamble = 16; + assert(stored.rx_ps_sleep_us == 5678 && !adapter.dirty); + assert(!adapter.dirty && stored.rx_powersaving_enabled == 1); + assert(stored.rx_ps_rx_us == 1234 && stored.rx_ps_sleep_us == 5678); + assert(stored.rx_ps_level == 6 && stored.rx_ps_preamble == 32); + assert(stored.freq == 911.150f && stored.powersaving_enabled == 1); CommonCLI cli; assert(cli.saveCommonPrefs()); auto original=cli.fs.files["/com_prefs"]; @@ -151,6 +180,75 @@ int main() { class CommonRadioPersistenceTest(unittest.TestCase): + def test_snapshot_constructors_and_both_real_adapters_support_cpp11(self): + source = r''' +#include +#include +#include +#include +#include +constexpr RxPowerSavingConfig defaults; +static_assert(defaults.enabled == 0, "Disabled default"); +static_assert(defaults.rx_us == RX_POWERSAVING_DEFAULT_RX_US, "Default RX period"); +static_assert(defaults.sleep_us == RX_POWERSAVING_DEFAULT_SLEEP_US, "Default sleep period"); +static_assert(defaults.level == RX_POWERSAVING_BALANCED_LEVEL, "Default level"); +static_assert(defaults.preamble == RX_POWERSAVING_PROFILE_PREAMBLE, "Default preamble"); +constexpr RxPowerSavingConfig explicit_config(1, 1234, 5678, 7, 32); +static_assert(explicit_config.enabled == 1 && explicit_config.rx_us == 1234 + && explicit_config.sleep_us == 5678 && explicit_config.level == 7 + && explicit_config.preamble == 32, "Value constructor"); +static_assert(std::is_standard_layout::value, "Snapshot layout"); +static_assert(std::is_trivially_copyable::value, "Value snapshot"); +struct Fields { + uint8_t rx_powersaving_enabled = 0; + uint32_t rx_ps_rx_us = 0, rx_ps_sleep_us = 0; + uint8_t rx_ps_level = 0, rx_ps_preamble = 0; +}; +struct CommonAdapter { Fields* _parent; @COMMON_READ@ }; +struct CompanionAdapter { Fields* _parent; @COMPANION_READ@ }; +int main() { + for (uint8_t enabled : {0, 1}) { + Fields fields; + fields.rx_powersaving_enabled = enabled; + fields.rx_ps_rx_us = 30000000; + fields.rx_ps_sleep_us = 2345; + fields.rx_ps_level = 10; + fields.rx_ps_preamble = 128; + CommonAdapter common{&fields}; + CompanionAdapter companion{&fields}; + auto a = common.readRxPowerSavingConfig(); + auto b = companion.readRxPowerSavingConfig(); + for (const auto& snapshot : {a, b}) { + assert(snapshot.enabled == enabled && snapshot.rx_us == 30000000 + && snapshot.sleep_us == 2345 && snapshot.level == 10 && snapshot.preamble == 128); + } + a.sleep_us = b.rx_us = 0; + assert(fields.rx_ps_rx_us == 30000000 && fields.rx_ps_sleep_us == 2345); + } +} +''' + for marker, path in ( + ('COMMON_READ', 'src/helpers/CommonCLI.h'), + ('COMPANION_READ', 'examples/companion_radio/NodePrefs.h'), + ): + header = (ROOT / path).read_text(encoding='ascii') + adapter = extract_braced(header, 'RxPowerSavingConfig readRxPowerSavingConfig(') + source = source.replace('@' + marker + '@', adapter.replace(' override', '')) + sanitizers = [] if os.name == 'nt' else [ + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] + with tempfile.TemporaryDirectory(prefix='rxps-snapshot-cpp11-') as directory: + work = Path(directory) + cpp, binary = work / 'snapshot.cpp', work / 'snapshot' + cpp.write_text(source, encoding='ascii') + for standard in ('c++11', 'c++17'): + with self.subTest(standard=standard): + built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=' + standard, + '-Wall', '-Wextra', '-Werror', *sanitizers, '-I' + str(ROOT / 'src'), + str(cpp), '-o', str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + def test_production_transactions(self): source = (ROOT / 'src/helpers/CommonCLI.cpp').read_text(encoding='utf-8') header = (ROOT / 'src/helpers/CommonCLI.h').read_text(encoding='utf-8') @@ -168,6 +266,8 @@ class CommonRadioPersistenceTest(unittest.TestCase): tail = extract_braced(source, 'if (file.available() >= (int)sizeof(_prefs->bridge_format))') code = HARNESS.replace('@FIELDS@',fields).replace('@METHODS@',("\n#include \ntemplate\n" + extract_braced(source, "static void writeCommonRetryPrefs(") + "\n" + methods)) code = code.replace('@SERIALIZER@',serializer).replace('@TAIL@',tail) + adapter = extract_braced(header, 'RxPowerSavingConfig readRxPowerSavingConfig(').replace(' override', '') + code = code.replace('@RXPS_READ@', adapter) sanitizer_flags = [] if os.name == 'nt' else [ '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie'] @@ -183,6 +283,7 @@ class CommonRadioPersistenceTest(unittest.TestCase): built = subprocess.run(['g++','-std=c++17','-DENABLE_OTA=1','-D'+platform+'=1', *sanitizer_flags, '-I'+str(work),'-I'+str(ROOT/'test/fixtures/radio_profiles/mocks'), + '-I'+str(ROOT/'test/mocks'), '-I'+str(ROOT/'src'),'-I'+str(ROOT/'src/helpers'), str(work/'test.cpp'),'-o',str(exe)], capture_output=True,text=True,timeout=60) diff --git a/test/test_companion_bluetooth_persistence.py b/test/test_companion_bluetooth_persistence.py index 779916dc0..8cec5959a 100644 --- a/test/test_companion_bluetooth_persistence.py +++ b/test/test_companion_bluetooth_persistence.py @@ -265,7 +265,8 @@ inline char* utoa(unsigned int value,char* output,int base){ '-I',str(ROOT/'test/mocks'),'-I',str(ROOT/'src'), '-I',str(ROOT/'src/helpers'),'-I',str(ROOT), str(source), *[str(ROOT/'src/helpers'/name) for name in ('ConfigSerializer.cpp', - 'DynamicConfigSerializer.cpp','CommonRadioPrefs.cpp','TxtDataHelpers.cpp')], + 'DynamicConfigSerializer.cpp','CommonRadioPrefs.cpp', + 'radiolib/RXPowerSaving.cpp','radiolib/RXPowerSavingCLI.cpp','TxtDataHelpers.cpp')], '-o',str(binary)],capture_output=True,text=True) self.assertEqual(built.returncode,0,built.stdout+built.stderr) ran = subprocess.run([str(binary)],capture_output=True,text=True) diff --git a/test/test_companion_node_prefs/test_companion_node_prefs.cpp b/test/test_companion_node_prefs/test_companion_node_prefs.cpp index 84d41d75b..850b4edff 100644 --- a/test/test_companion_node_prefs/test_companion_node_prefs.cpp +++ b/test/test_companion_node_prefs/test_companion_node_prefs.cpp @@ -65,6 +65,40 @@ TEST(CompanionNodePrefs, DevicePowerSavingIsIndependentFromRxps) { EXPECT_EQ(1, prefs.powersaving_enabled); } +TEST(CompanionNodePrefs, RxpsAdapterReadsAnIndependentSnapshot) { + CompanionNodePrefs prefs; + prefs.rx_powersaving_enabled = 1; + prefs.rx_ps_rx_us = 1234; + prefs.rx_ps_sleep_us = 5678; + prefs.rx_ps_level = 6; + prefs.rx_ps_preamble = 32; + const CompanionNodePrefs& stored = prefs; + auto config = stored.getRadioPrefs()->readRxPowerSavingConfig(); + EXPECT_EQ(1, config.enabled); + EXPECT_EQ(1234U, config.rx_us); + EXPECT_EQ(5678U, config.sleep_us); + EXPECT_EQ(6, config.level); + EXPECT_EQ(32, config.preamble); + config.sleep_us = 9999; + EXPECT_EQ(5678U, prefs.rx_ps_sleep_us); + EXPECT_FALSE(prefs.isDirty()); +} + +TEST(CompanionNodePrefs, RxpsSnapshotPreservesDisabledManualConfiguration) { + CompanionNodePrefs prefs; + prefs.freq = 911.150f; + prefs.powersaving_enabled = 1; + const auto config = prefs.getRadioPrefs()->readRxPowerSavingConfig(); + EXPECT_EQ(0, config.enabled); + EXPECT_EQ(0U, config.rx_us); + EXPECT_EQ(0U, config.sleep_us); + EXPECT_EQ(0, config.level); + EXPECT_EQ(0, config.preamble); + EXPECT_FLOAT_EQ(911.150f, prefs.freq); + EXPECT_EQ(1, prefs.powersaving_enabled); + EXPECT_FALSE(prefs.isDirty()); +} + TEST(CompanionNodePrefs, WiFiStateIsIndependentFromPowerSaving) { CompanionNodePrefs prefs = {}; prefs.wifi_enabled = 1; diff --git a/test/test_companion_packet_stream.py b/test/test_companion_packet_stream.py index 35332d138..084004402 100644 --- a/test/test_companion_packet_stream.py +++ b/test/test_companion_packet_stream.py @@ -197,7 +197,8 @@ class CompanionPacketStreamTests(unittest.TestCase): ['NRF52_PLATFORM=1', 'MESH_USB_LOGGING_AVAILABLE=1'] + (['MESH_DUAL_CDC_LOGGING=1'] if dual else []), ('ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')) + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')) def test_actual_full_dual_compiled_primary_transport(self): fixture = ROOT / 'test/fixtures/nrf52_usb_console' diff --git a/test/test_companion_preferences_transaction.py b/test/test_companion_preferences_transaction.py index 40fb6f233..ff464b68e 100644 --- a/test/test_companion_preferences_transaction.py +++ b/test/test_companion_preferences_transaction.py @@ -422,6 +422,8 @@ inline char* utoa(unsigned int value,char* output,int base){ str(ROOT / 'src/helpers/ConfigSerializer.cpp'), str(ROOT / 'src/helpers/DynamicConfigSerializer.cpp'), str(ROOT / 'src/helpers/CommonRadioPrefs.cpp'), + str(ROOT / 'src/helpers/radiolib/RXPowerSaving.cpp'), + str(ROOT / 'src/helpers/radiolib/RXPowerSavingCLI.cpp'), str(ROOT / 'src/helpers/TxtDataHelpers.cpp'), '-o', str(binary)], capture_output=True, text=True) self.assertEqual(build.returncode, 0, build.stdout + build.stderr) diff --git a/test/test_companion_prefs_image.py b/test/test_companion_prefs_image.py index 493e7887b..b33a14439 100644 --- a/test/test_companion_prefs_image.py +++ b/test/test_companion_prefs_image.py @@ -654,7 +654,8 @@ inline char* utoa(unsigned int value,char* output,int base){ '-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp), *[str(ROOT / 'src/helpers' / name) for name in ( 'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')], + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')], '-o', str(binary)], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) ran = subprocess.run([str(binary)], capture_output=True, text=True) diff --git a/test/test_companion_prefs_tail.py b/test/test_companion_prefs_tail.py index 2cd696e74..a9a50ab43 100644 --- a/test/test_companion_prefs_tail.py +++ b/test/test_companion_prefs_tail.py @@ -187,7 +187,8 @@ inline char* utoa(unsigned int value,char* output,int base){ '-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp), *[str(ROOT / 'src/helpers' / name) for name in ( 'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')], + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')], '-o', str(binary)], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) ran = subprocess.run([str(binary)], capture_output=True, text=True) diff --git a/test/test_companion_prefs_values.py b/test/test_companion_prefs_values.py index 9a5570df4..0227e5f5b 100644 --- a/test/test_companion_prefs_values.py +++ b/test/test_companion_prefs_values.py @@ -318,7 +318,8 @@ inline char* utoa(unsigned int value,char* output,int base){ '-I', str(ROOT / 'src/helpers'), '-I', str(ROOT), str(cpp), *[str(ROOT / 'src/helpers' / name) for name in ( 'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')], + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')], '-o', str(binary)], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) self.assertNotIn('offsetof', built.stderr) diff --git a/test/test_companion_primary_radio_persistence.py b/test/test_companion_primary_radio_persistence.py index 6729b8555..163c752ca 100644 --- a/test/test_companion_primary_radio_persistence.py +++ b/test/test_companion_primary_radio_persistence.py @@ -332,7 +332,8 @@ public: '-I',str(ROOT/'src/helpers'),'-I',str(ROOT),str(work/'test.cpp'), *[str(ROOT/'src/helpers'/name) for name in ( 'ConfigSerializer.cpp','DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp','TxtDataHelpers.cpp','RadioProfileCLI.cpp')], + 'CommonRadioPrefs.cpp','radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp','TxtDataHelpers.cpp','RadioProfileCLI.cpp')], '-o',str(binary)],capture_output=True,text=True) self.assertEqual(build.returncode,0,build.stdout+build.stderr) run = subprocess.run([str(binary)],capture_output=True,text=True) diff --git a/test/test_companion_retry_settings.py b/test/test_companion_retry_settings.py index 11a0116aa..163ea7ec6 100644 --- a/test/test_companion_retry_settings.py +++ b/test/test_companion_retry_settings.py @@ -101,6 +101,8 @@ namespace mesh { struct Utils { str(ROOT / "src/helpers/ConfigSerializer.cpp"), str(ROOT / "src/helpers/DynamicConfigSerializer.cpp"), str(ROOT / "src/helpers/CommonRadioPrefs.cpp"), + str(ROOT / "src/helpers/radiolib/RXPowerSaving.cpp"), + str(ROOT / "src/helpers/radiolib/RXPowerSavingCLI.cpp"), str(ROOT / "src/helpers/TxtDataHelpers.cpp"), "-o", str(binary), ], capture_output=True, text=True) diff --git a/test/test_companion_rxps_getters.py b/test/test_companion_rxps_getters.py index 621aaf0c8..351dbd629 100644 --- a/test/test_companion_rxps_getters.py +++ b/test/test_companion_rxps_getters.py @@ -3,6 +3,7 @@ from pathlib import Path import os +import shutil import subprocess import tempfile import unittest @@ -14,6 +15,180 @@ ROOT = Path(__file__).resolve().parents[1] class CompanionRxpsGetterTests(unittest.TestCase): + def test_actual_status_formatter_and_prefixed_route_are_bounded(self): + shared = (ROOT / "src/helpers/CommonRadioPrefs.cpp").read_text(encoding="ascii") + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + formatter = extract_braced(shared, "void CommonRadioPrefs::formatRxPowerSavingStatus(") + branch = extract_braced(common, 'if (strcmp(config, "radio.rxps.status") == 0)') + source = r''' +#include +#include +#include +#include +#include +#include +#include +#include +struct Control : RxPowerSavingControl { + RxPowerSavingStatus status; + RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; } +} control; +#define WRAPPER_CLASS NativeRadio +struct NativeRadio { + struct Profiles { struct Params { uint8_t sf = 7; float bw = 125; } primary; } profile; + const Profiles* profiles() const { return &profile; } + void getRxPowerSavingTimings(uint32_t& rx_us, uint32_t& sleep_us) const { + rx_us = sleep_us = 30000000; + } + uint16_t currentPreambleLength() const { return 128; } + RxPowerSavingStatus getRxPowerSavingStatus() const { return control.status; } +} radio_driver; +struct CommonRadioPrefs { + RxPowerSavingConfig desired; + uint8_t getSpreadFactor() const { return 7; } + float getBandwidth() const { return 125; } + RxPowerSavingConfig readRxPowerSavingConfig() const { return desired; } + void formatRxPowerSavingStatus(char*, size_t) const; +} radio; +@FORMATTER@ +struct Prefs { CommonRadioPrefs* getRadioPrefs() { return &radio; } } prefs; +struct CommonCLI { + Prefs* _prefs = &prefs; + void get(const char* config, char* reply) { @STATUS_BRANCH@ } +}; +int main() { + radio.desired.enabled = 1; + radio.desired.rx_us = radio.desired.sleep_us = 30000000; + radio.desired.preamble = 128; + control.status.supported = true; + control.status.armed = true; + control.status.last_error = INT16_MIN; + control.status.arm_failures = UINT32_MAX; + control.status.effective_rx_us = UINT32_MAX; + control.status.effective_sleep_us = UINT32_MAX; + bool saw_truncation = false; + for (bool supported : {false, true}) { + control.status.supported = supported; + for (int16_t error : {int16_t(INT16_MIN), int16_t(0)}) { + control.status.last_error = error; + for (uint8_t level : {0, 6, 9, 10}) { + radio.desired.level = level; + const RxPowerSavingConfig before = radio.desired; + char full[512]; + radio.formatRxPowerSavingStatus(full, sizeof(full)); + const std::string expected = full; + assert(expected.find(error ? "err=-32768,fail=4294967295" : "err=0,fail=4294967295") + != std::string::npos); + if (level == 9) assert(expected.find("level=9(!)") != std::string::npos); + if (level == 10) assert(expected.find("level=10(!)") != std::string::npos); + saw_truncation = saw_truncation || expected.size() > 156; + for (size_t capacity = 0; capacity <= 180; ++capacity) { + std::vector bytes(capacity + 16, 0xA7); + char* reply = reinterpret_cast(bytes.data() + 8); + radio.formatRxPowerSavingStatus(reply, capacity); + if (capacity) { + const size_t count = std::min(expected.size(), capacity - 1); + assert(strlen(reply) == count && !memcmp(reply, expected.data(), count)); + } + for (size_t n = 0; n < 8; ++n) assert(bytes[n] == 0xA7); + for (size_t n = 8 + capacity; n < bytes.size(); ++n) assert(bytes[n] == 0xA7); + } + // Infrastructure replies already reserve three bytes of their 160-byte + // buffer. Execute the production route with those bytes in place. + std::vector bytes(176, 0xA7); + char* reply = reinterpret_cast(bytes.data() + 8); + memcpy(reply, " >", 3); + CommonCLI cli; + cli.get("radio.rxps.status", reply + 3); + assert(!memcmp(reply, " >", 3)); + assert(strlen(reply + 3) == std::min(expected.size(), size_t(156))); + for (size_t n = 0; n < 8; ++n) assert(bytes[n] == 0xA7); + for (size_t n = 168; n < bytes.size(); ++n) assert(bytes[n] == 0xA7); + assert(!memcmp(&radio.desired, &before, sizeof(before))); + } + } + } + assert(saw_truncation && "The 157-byte prefixed route must exercise real truncation"); + radio.formatRxPowerSavingStatus(nullptr, 160); +} +'''.replace("@FORMATTER@", formatter).replace("@STATUS_BRANCH@", branch) + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "RXPS formatter needs a host C++17 compiler") + sanitizers = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + with tempfile.TemporaryDirectory(prefix="meshcore-rxps-status-bounds-") as directory: + work = Path(directory) + cpp, binary = work / "bounds.cpp", work / "bounds" + cpp.write_text(source, encoding="ascii") + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *sanitizers, "-I", str(ROOT / "src"), + str(cpp), str(ROOT / "src/helpers/radiolib/RXPowerSaving.cpp"), + str(ROOT / "src/helpers/radiolib/RXPowerSavingCLI.cpp"), + "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + + def test_status_alias_routes_to_shared_diagnostics_without_changing_legacy_getters(self): + companion = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii") + common = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii") + whitelist = extract_braced(companion, "static bool isCompanionRadioPrefsCommand(") + branch = extract_braced(common, 'if (strcmp(config, "radio.rxps.status") == 0)') + source = r''' +#include +#include +#include +#include +struct RadioPrefs { + unsigned calls = 0; + void formatRxPowerSavingStatus(char* reply, size_t capacity) { + assert(capacity == 157); + ++calls; + snprintf(reply, capacity, "> desired=on,effective=continuous,supported=yes,err=-2,fail=3"); + } +} radio; +struct Prefs { RadioPrefs* getRadioPrefs() { return &radio; } } prefs; +struct CommonCLI { + Prefs* _prefs = &prefs; + void get(const char* config, uint32_t sender_timestamp, char* reply) { + (void)sender_timestamp; + @STATUS_BRANCH@ + else strcpy(reply, "unhandled"); + } +}; +@WHITELIST@ +int main() { + assert(isCompanionRadioPrefsCommand("get radio.rxps.status")); + for (const char* other : {"get radio.rxps.statusx", "get radio.rxps.status extra", + "get radio.rxps.config", "get radio.rxps", "set radio.rxps on"}) { + assert(!isCompanionRadioPrefsCommand(other)); + } + CommonCLI cli; + char reply[160]; + cli.get("radio.rxps.status", 123, reply); + assert(radio.calls == 1); + assert(strstr(reply, "effective=continuous") && strstr(reply, "err=-2,fail=3")); + cli.get("radio.rxps.statusx", 123, reply); + assert(radio.calls == 1 && !strcmp(reply, "unhandled")); +} +'''.replace("@STATUS_BRANCH@", branch).replace("@WHITELIST@", whitelist) + source = "#include \n" + source + compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++") + self.assertIsNotNone(compiler, "RXPS routing needs a host C++17 compiler") + sanitizers = [] if os.name == "nt" else [ + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie"] + with tempfile.TemporaryDirectory(prefix="meshcore-rxps-status-routes-") as directory: + work = Path(directory) + cpp, binary = work / "routes.cpp", work / "routes" + cpp.write_text(source, encoding="ascii") + built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror", + *sanitizers, str(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=20) + self.assertEqual(ran.returncode, 0, ran.stdout + ran.stderr) + def test_real_getters_are_bounded_readonly_and_match_active_primary(self): source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text(encoding="ascii") getter = extract_braced(source, "bool MyMesh::handleRxPowerSavingGetCommand(") diff --git a/test/test_companion_timezone_prefs.py b/test/test_companion_timezone_prefs.py index 69e8afca7..6aff37bff 100644 --- a/test/test_companion_timezone_prefs.py +++ b/test/test_companion_timezone_prefs.py @@ -546,7 +546,8 @@ inline char* utoa(unsigned int value,char* output,int base){ "-I", str(ROOT / "src/helpers"), "-I", str(ROOT), str(cpp), *[str(ROOT / "src/helpers" / name) for name in ( "ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", - "CommonRadioPrefs.cpp", "TxtDataHelpers.cpp")], + "CommonRadioPrefs.cpp", "radiolib/RXPowerSaving.cpp", + "radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp")], "-o", str(binary)], capture_output=True, text=True, timeout=90) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) ran = subprocess.run([str(binary)], capture_output=True, text=True, timeout=30) diff --git a/test/test_companion_usb_logging.py b/test/test_companion_usb_logging.py index 909b2f098..0b50eaf38 100644 --- a/test/test_companion_usb_logging.py +++ b/test/test_companion_usb_logging.py @@ -373,7 +373,8 @@ inline char* utoa(unsigned int value,char* output,int base){ [flag + '=1' for flag in platform.split(',')] + ['MESH_USB_LOGGING_AVAILABLE=1', 'MESH_DEBUG=' + str(debug)], ('ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')) + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')) def test_real_raw_emitter_master_gate_compact_and_other_transports(self): source = (ROOT / 'examples/companion_radio/MyMesh.cpp').read_text() diff --git a/test/test_esp32_cpu_clock.py b/test/test_esp32_cpu_clock.py index a35492aea..229f36da4 100644 --- a/test/test_esp32_cpu_clock.py +++ b/test/test_esp32_cpu_clock.py @@ -197,7 +197,8 @@ inline char* utoa(unsigned value,char* out,int base){ if persistence: command += [str(ROOT / "src/helpers" / name) for name in ( "ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", - "CommonRadioPrefs.cpp", "TxtDataHelpers.cpp")] + "CommonRadioPrefs.cpp", "radiolib/RXPowerSaving.cpp", + "radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp")] built = subprocess.run(command + ["-o", str(binary)], capture_output=True, text=True) self.assertEqual(built.returncode, 0, built.stdout + built.stderr) ran = subprocess.run([str(binary)], capture_output=True, text=True) diff --git a/test/test_esp32_usb_sleep.py b/test/test_esp32_usb_sleep.py index 6ed7a6bd8..e78b19372 100644 --- a/test/test_esp32_usb_sleep.py +++ b/test/test_esp32_usb_sleep.py @@ -141,6 +141,8 @@ struct MainBoard { }; struct ESP32Board : MainBoard { bool inhibit_sleep = false; + bool button_wake_hold_active = false; + uint32_t button_wake_at = 0; uint32_t uart_quiet_restore_at = 0; #if MESH_ESP32_USB_CONSOLE_COOPERATIVE mesh::UsbHostSleepPolicy usb_host_sleep_policy; @@ -233,6 +235,12 @@ int main() { "held button was stranded before the UI could poll it"); button_pressed = false; board.sleep(30); + require(sleep_calls == 2, "released button lost its awake grace"); + mock_millis += 29999U; + board.sleep(30); + require(sleep_calls == 2, "button awake grace expired early"); + ++mock_millis; + board.sleep(30); require(sleep_calls == 3, "released button permanently disabled sleep"); #else board.irq = static_cast(-1); @@ -411,6 +419,8 @@ struct MainBoard { }; struct ESP32Board : MainBoard { bool inhibit_sleep = false; + bool button_wake_hold_active = false; + uint32_t button_wake_at = 0; uint32_t uart_quiet_restore_at = 0; uint32_t getIRQGpio() { return P_LORA_DIO_1; } bool isExternalPowered() const { return false; } @@ -510,10 +520,13 @@ int main() { timing_radio.rx = true; require(mesh::morseTimingActive(activeSensorMesh().getProfileRadio()), "RX ownership was not visible to UI"); board.sleep(30); - require(sleep_calls == 2, "released button failed to allow sleep again"); + require(sleep_calls == 1, "released button lost its awake grace"); g_mock_millis += 15000; ui.loop(); require(!display.isOn(), "button screen failed to expire normally"); + g_mock_millis += 15000; + board.sleep(30); + require(sleep_calls == 2, "expired button awake grace failed to allow sleep again"); timing_radio.rx = false; require(!mesh::morseTimingActive(the_mesh.getProfileRadio()), "Morse ownership failed to release"); } catch (const std::exception& error) { @@ -1018,6 +1031,54 @@ int main() { timeout=10) self.assertEqual(result.returncode, 0, result.stderr) + def test_button_wake_grace_expires_across_millis_rollover(self): + methods = board_sleep_methods() + "\n" + "\n".join(board_method(signature) for signature in ( + "bool isUsbDataConnected() override", + "bool isUsbHostConnected() override", "bool isUsbSleepHeld()")) + harness = HARNESS.split("int main()", 1)[0] + r''' +int main() { + try { + attachHost(false); + Serial.end(); + for (uint32_t start : {0U, UINT32_MAX - 15000U}) { + ESP32Board board; + mock_millis = start; + const unsigned before = sleep_calls; + board.sleep(30); + require(sleep_calls == before + 1, "fresh board gained an awake hold"); + press_during_sleep = true; + board.sleep(30); + require(sleep_calls == before + 2 && button_pressed, "button failed to wake CPU"); + button_pressed = false; + mock_millis = start + 29999U; + board.sleep(30); + require(sleep_calls == before + 2, "button grace ended early across rollover"); + ++mock_millis; + board.sleep(30); + require(sleep_calls == before + 3, "button grace never expired"); + mock_millis = start + 1U; + board.sleep(30); + require(sleep_calls == before + 4, "expired button hold revived across rollover"); + } + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; return 1; + } +} +''' + with tempfile.TemporaryDirectory(prefix="meshcore-button-grace-") as directory: + cpp = Path(directory) / "grace.cpp" + binary = Path(directory) / "grace" + cpp.write_text(harness.replace("@METHODS@", methods), encoding="ascii") + result = subprocess.run([ + os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-DARDUINO_USB_CDC_ON_BOOT=1", "-DMESH_ESP32_USB_CONSOLE_COOPERATIVE=0", + "-DMOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", "-DPIN_USER_BTN=38", + "-DMESH_USB_LOGGING_AVAILABLE=0", "-I", str(ROOT / "src"), + str(cpp), "-o", str(binary)], capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + def test_g3_enables_gesture_polling_after_gpio_wake(self): profile = (ROOT / "variants/station_g3_esp32/platformio.ini").read_text() def section(name): diff --git a/test/test_identity_and_settings_recovery.py b/test/test_identity_and_settings_recovery.py index 56a9af2f4..5a2533c34 100644 --- a/test/test_identity_and_settings_recovery.py +++ b/test/test_identity_and_settings_recovery.py @@ -47,7 +47,8 @@ def compile_run(program, platform, actual_node_prefs=False): (directory / 'Utils.h').write_text('#include \n' + utils) helper_sources = [str(ROOT / 'src/helpers' / name) for name in ( 'ConfigSerializer.cpp', 'DynamicConfigSerializer.cpp', - 'CommonRadioPrefs.cpp', 'TxtDataHelpers.cpp')] + 'CommonRadioPrefs.cpp', 'radiolib/RXPowerSaving.cpp', + 'radiolib/RXPowerSavingCLI.cpp', 'TxtDataHelpers.cpp')] cpp, exe = directory / 'test.cpp', directory / 'test' cpp.write_text(program) compiled = subprocess.run([ diff --git a/test/test_meshtext_primary_preamble.py b/test/test_meshtext_primary_preamble.py index fca852ef1..43e18ffda 100644 --- a/test/test_meshtext_primary_preamble.py +++ b/test/test_meshtext_primary_preamble.py @@ -165,6 +165,7 @@ struct Lr1110DutyCycleProbe { uint16_t preambleLengthLoRa=32; uint32_t _activityAt=0; bool _headerSeen=false; + bool _lfClockRestoreNeeded=false; uint32_t rx_ticks=0,sleep_ticks=0; unsigned standby_calls=0,clock_calls=0,stage_calls=0,command_calls=0; int16_t standby(uint8_t mode) {assert(mode==0);++standby_calls;return 0;} diff --git a/test/test_official_app_compatibility.py b/test/test_official_app_compatibility.py index 7bd0900c2..e506ebb11 100644 --- a/test/test_official_app_compatibility.py +++ b/test/test_official_app_compatibility.py @@ -256,6 +256,7 @@ public: f"-I{ROOT / 'test/mocks'}", f"-I{ROOT / 'src'}", f"-I{ROOT / 'src/helpers'}", f"-I{ROOT}", str(work / "radio.cpp"), *[str(ROOT / "src/helpers" / name) for name in ( "ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp", + "radiolib/RXPowerSaving.cpp", "radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp", "RadioProfileCLI.cpp")], "-o", str(binary)]) output = checked([str(binary)], timeout=20) replies = [line.removeprefix("RADIO:") for line in output.splitlines() if line.startswith("RADIO:")] diff --git a/test/test_official_web_console.py b/test/test_official_web_console.py index bb7cf22c0..e54b6e2ca 100644 --- a/test/test_official_web_console.py +++ b/test/test_official_web_console.py @@ -178,7 +178,7 @@ class OfficialWebConsoleTests(unittest.TestCase): source = source.split("int main() {", 1)[0] + r''' int main() { attachHost(true); Serial.terminal_open = true; - prefs.powersaving_enabled = 1; mock_millis = 300000; + prefs.powersaving_enabled = 1; mock_millis = 300001; const unsigned before = yield_ms; the_mesh.dual_radio_active = true; power_loop(); @@ -219,7 +219,7 @@ int main() { source = firmware_harness().split("int main() {", 1)[0] + r''' int main() { attachHost(false); Serial.terminal_open = false; - prefs.powersaving_enabled = 1; mock_millis = 300000; + prefs.powersaving_enabled = 1; mock_millis = 300001; const unsigned before = yield_ms; radio_driver.tx = true; require(mesh::morseTimingActive(&radio_driver), "Morse TX ownership was lost"); diff --git a/test/test_peripheral_mode_prefs.py b/test/test_peripheral_mode_prefs.py index 04f6b6b3a..38d11ae25 100644 --- a/test/test_peripheral_mode_prefs.py +++ b/test/test_peripheral_mode_prefs.py @@ -47,6 +47,7 @@ inline char* utoa(unsigned int v,char* p,int b){if(b!=10)abort();sprintf(p,"%u", if serializers: command += [str(ROOT / "src/helpers/" / name) for name in ( "ConfigSerializer.cpp", "DynamicConfigSerializer.cpp", "CommonRadioPrefs.cpp", + "radiolib/RXPowerSaving.cpp", "radiolib/RXPowerSavingCLI.cpp", "TxtDataHelpers.cpp")] build = subprocess.run(command + ["-o", str(binary)], capture_output=True, text=True, timeout=90) if build.returncode: diff --git a/test/test_radio_interrupt_recovery.py b/test/test_radio_interrupt_recovery.py index 61b0f41e7..48a1c4fc8 100644 --- a/test/test_radio_interrupt_recovery.py +++ b/test/test_radio_interrupt_recovery.py @@ -140,6 +140,7 @@ struct RadioLibWrapper { bool _loop_event_pending=false, _cw_active=false, _profile_repair_required=false, _profile_refresh_required=false; bool _rx_ps_armed=false, _rx_hold_continuous=false, _rx_ps_enabled=false; bool _rx_ps_continuous_fallback=false, _nf_calib_active=false; + uint32_t _rx_ps_eff_rx_us=0, _rx_ps_eff_sleep_us=0; float _last_snr=0, _last_rssi=0; unsigned n_recv=0,n_recv_errors=0,n_sent=0; uint32_t _rx_retry_at=0; @@ -223,7 +224,9 @@ class RadioInterruptRecoveryTests(unittest.TestCase): #define RADIOLIB_ERR_NONE 0 #define RADIOLIB_ERR_INVALID_FREQUENCY -2 #define RADIOLIB_ERR_UNKNOWN -16 +#define RADIOLIB_ERR_UNSUPPORTED -2 #define RADIOLIB_NC 0xffffffff +using RadioLibIrqFlags_t = uint32_t; class PhysicalLayer { public: int setOutputPower(int8_t){return 0;} diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index b76badd66..a7178efc5 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -63,6 +63,8 @@ struct RadioLibWrapper { return profile == 1 ? _secondary_floor_estimator : _floor_estimator; } uint32_t _rx_ps_rx_us=50000, _rx_ps_sleep_us=50000; + RxPowerSavingArmRetryState _rx_ps_arm_retry; + int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE; uint8_t _active_profile=0, _cur_sf=7, _cur_cr=5; uint32_t _profile_generation=1, _profile_visit_stamp=0, _profile_retry_at=0; uint32_t _profile_scan_generation[2]={}; diff --git a/test/test_radio_receive_contract.py b/test/test_radio_receive_contract.py index 688a4ca9f..e3ed25ae7 100644 --- a/test/test_radio_receive_contract.py +++ b/test/test_radio_receive_contract.py @@ -24,6 +24,7 @@ HARNESS = r''' #include #include #include +#include #include #define STATE_IDLE 0 #define STATE_RX 1 @@ -91,6 +92,9 @@ struct RadioLibWrapper { uint16_t _rx_retry_delay_ms = 0; bool _rx_retry_pending = false; uint32_t _rx_mode_checked_at = 0, _rx_ps_rx_us = 50000, _rx_ps_sleep_us = 50000; + uint32_t _rx_ps_eff_rx_us = 50000, _rx_ps_eff_sleep_us = 50000; + int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE; + RxPowerSavingArmRetryState _rx_ps_arm_retry; unsigned long _nf_last_calib = 0, _nf_calib_deadline = 0, _nf_sample_from = 0; unsigned long _wd_last_transition = 0, _wd_stuck_thresh = 0, _wd_observe_ms = 0; int16_t _threshold = 0, _noise_floor = -105; @@ -129,6 +133,10 @@ struct RadioLibWrapper { ++arms; if (_nf_calib_active) ++calibration_arms; if (!arm_result) _rx_ps_armed = _rx_ps_enabled && !_rx_ps_continuous_fallback && !_nf_calib_active; + if (_rx_ps_armed) { + _rx_ps_eff_rx_us = _rx_ps_rx_us; + _rx_ps_eff_sleep_us = _rx_ps_sleep_us; + } if (inject_arm_irq) events.record(); return arm_result; } @@ -701,8 +709,7 @@ def transition_harness(source=None): ('void', 'requestPeriodicNoiseFloorRefresh'), ('void', 'recalibrateNoiseFloor'), ('bool', 'resetAGCIfIdle'), ('bool', 'tryResetAGC'), ('void', 'resetAGC'), ('bool', 'getNoiseFloorWakeDelay')] - methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n' - methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') + methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind, name in names) constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE)) constants += '\n' + re.search( diff --git a/test/test_radio_receive_retry.py b/test/test_radio_receive_retry.py index 6530769e2..492e0b4df 100644 --- a/test/test_radio_receive_retry.py +++ b/test/test_radio_receive_retry.py @@ -53,8 +53,7 @@ class RadioReceiveRetryTests(unittest.TestCase): ('void', 'onSendFinished'), ('void', 'begin'), ('bool', 'getNoiseFloorWakeDelay'), ('bool', 'getReceiveRetryDelay'), ('void', 'rxPsWatchdogCheck')] - methods = method(rxps, 'inline bool rxPowerSavingUsesContinuousFallback(') + '\n' - methods += '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') + methods = '\n'.join(method(source, f'{kind} RadioLibWrapper::{name}(') for kind, name in names) constants = '\n'.join(re.findall(r'^#define NF_\w+\s+[^\n]+', source, re.MULTILINE)) constants += '\n' + re.search( diff --git a/test/test_radio_regulator_modes.py b/test/test_radio_regulator_modes.py index cda366c5b..dbffe7824 100644 --- a/test/test_radio_regulator_modes.py +++ b/test/test_radio_regulator_modes.py @@ -132,6 +132,23 @@ int main() { class RadioRegulatorTests(unittest.TestCase): + def test_thinknode_m4_recipe_uses_lr1110_capture_cost_before_target_headers(self): + recipe = (ROOT / 'variants/thinknode_m4/platformio.ini').read_text() + family_flags = ['-DUSE_LR1110=1'] if ' -D USE_LR1110' in recipe else [] + compile_run(self, r''' +#include +#include +int main() { + // CommonCLI includes its RXPS helpers before target.h can select a wrapper. + // The board recipe must therefore select the LR11x0 eight-symbol cost. + assert(rxPowerSavingDefaultCaptureCost() == 8.0f); + uint32_t family_rx=0, family_sleep=0, explicit_rx=0, explicit_sleep=0; + assert(calcRxPowerSavingLevel(9,8,62.5f,32,&family_rx,&family_sleep)); + assert(calcRxPowerSavingLevel(9,8,62.5f,32,&explicit_rx,&explicit_sleep,8.0f)); + assert(family_rx==explicit_rx && family_sleep==explicit_sleep); +} +''', flags=family_flags) + def exercise(self, chip, flags=(), dcdc=True): source = (ROOT / f'src/helpers/radiolib/Custom{chip}.h').read_text() if chip == 'LR2021': diff --git a/test/test_radio_rx_activity_epoch.py b/test/test_radio_rx_activity_epoch.py index 2860a395a..a901343e2 100644 --- a/test/test_radio_rx_activity_epoch.py +++ b/test/test_radio_rx_activity_epoch.py @@ -47,6 +47,7 @@ def build_source(family='CustomSX1262',remove_reset=False,include_duty=False): #define RADIOLIB_LR11X0_STANDBY_RC 0 #define RADIOLIB_LR11X0_LF_CLK_RC 1 #define RADIOLIB_LR11X0_LF_BUSY_RELEASE_ENABLED 2 +#define RADIOLIB_LR11X0_LF_BUSY_RELEASE_DISABLED 0 #define RADIOLIB_RADIO_MODE_RX 1 #define RADIOLIB_LR11X0_RX_DUTY_CYCLE_MODE_RX 0 #define RADIOLIB_ASSERT(state) do { if((state)!=0)return(state); } while(0) @@ -74,10 +75,10 @@ struct Chip : LR2021 { int16_t clearIrqState(uint32_t flags){irq &= ~flags;rx_status=0;gpio_levels[module.irq]=false;return clear_status;} int16_t setPacketParamsLoRa(size_t,uint8_t,uint8_t,uint8_t,uint8_t){return packet_failure;} int snapshot_failure=0,packet_failure=0; - bool _rx_ps_rf_rx_disabled=false; + bool _rx_ps_rf_rx_disabled=false,_lfClockRestoreNeeded=false; uint8_t spreadingFactor=7;float bandwidthKhz=62.5;uint32_t tcxoDelay=0; - int lf_failure=0,stage_failure=0; - int16_t configLfClock(int){return lf_failure;} + int lf_failure=0,stage_failure=0;unsigned lf_calls=0; + int16_t configLfClock(int){++lf_calls;return lf_failure;} int16_t stageMode(int,RadioModeConfig_t*){return stage_failure;} int16_t setRxDutyCycle(uint32_t,uint32_t,int){return startDuty();} size_t preambleLengthLoRa=32;uint8_t crcTypeLoRa=1,implicitLen=255,headerType=0,invertIQEnabled=0; @@ -145,6 +146,13 @@ struct Chip : LR2021 { 'void RadioLibWrapper::serviceProfileScan(')) if family=='CustomLR1110': source=source.replace('SX1262ProfileSwitchState _profileSwitch','LR1110ProfileSwitchState _profileSwitch') + clock_methods='\n'.join(method(chip,signature)for signature in ( + 'bool needsLfClockRestore()', 'int16_t restoreLfClock()')) + source=source.replace(' bool _rx_ps_rf_rx_disabled=',clock_methods+'\n bool _rx_ps_rf_rx_disabled=') + wrapper=(ROOT/'src/helpers/radiolib/CustomLR1110Wrapper.h').read_text() + continuous=method(wrapper,'int16_t startContinuousReceive()').replace(' override','') + continuous=continuous.replace('static_cast','static_cast') + source=source.replace(' int startReceiveMode(){return chip.startReceive();}',continuous+'\n int startReceiveMode(){return chip.startReceive();}') if family.startswith('CustomLR'): source+="\n#define ACTIVITY_PREAMBLE 16\n#define ACTIVITY_HEADER 32\n#define ACTIVITY_HEADER_ERROR 64\n" else: @@ -268,7 +276,57 @@ int main(){ } ''' +LR_CLOCK_CHECKS=r''' +static void fastContext(Chip& chip){ + chip._profileSwitch.rxResult(0,true); + chip._profileSwitch.begin(true,true);chip._profileSwitch.standbyResult(0); + assert(chip._profileSwitch.canResumeFast()); +} +int main(){ + auto* wrapper=new CustomLR1110Wrapper; + auto& chip=wrapper->chip; + fastContext(chip); + const unsigned standbys=chip.standbys,clocks=chip.lf_calls; + assert(wrapper->startContinuousReceive()==0); + assert(chip.standbys==standbys&&"ordinary profile RX must not add a standby"); + assert(chip.lf_calls==clocks&&chip._profileFastResumes==1); + assert(!chip.needsLfClockRestore()); + + // Entering duty cycle changes BUSY release policy. Failed restoration must + // stay pending, and must not claim that continuous RX has restarted. + assert(chip.startReceiveDutyCycle(100000,100000)==0); + assert(chip.needsLfClockRestore()); + chip.lf_failure=-88; + const unsigned arms=chip.arms; + assert(wrapper->startContinuousReceive()==-88); + assert(chip.needsLfClockRestore()&&chip.arms==arms); + chip.lf_failure=0; + assert(wrapper->startContinuousReceive()==0); + assert(!chip.needsLfClockRestore()); + + fastContext(chip); + const unsigned next_standbys=chip.standbys,next_clocks=chip.lf_calls; + assert(wrapper->startContinuousReceive()==0); + assert(chip.standbys==next_standbys&&chip.lf_calls==next_clocks); + assert(chip._profileFastResumes==2); + delete wrapper; +} +''' + class RadioRxActivityEpochTests(unittest.TestCase): + def test_lr1110_clock_restore_keeps_ordinary_profile_rx_fast(self): + source=build_source('CustomLR1110',include_duty=True) + compile_run(source+LR_CLOCK_CHECKS,['-DESP32_PLATFORM=1','-DRADIO_CLASS=CustomLR1110']) + + def test_lr1110_unconditional_clock_restore_breaks_profile_fast_path(self): + source=build_source('CustomLR1110',include_duty=True) + bypass=' if (!radio->needsLfClockRestore()) return _radio->startReceive();' + self.assertIn(bypass,source) + with self.assertRaises(AssertionError)as failure: + compile_run(source.replace(bypass,'')+LR_CLOCK_CHECKS, + ['-DESP32_PLATFORM=1','-DRADIO_CLASS=CustomLR1110']) + self.assertIn('ordinary profile RX must not add a standby',str(failure.exception)) + def test_actual_receive_rearm_preserves_new_preamble_at_due_scan(self): for family in FAMILIES: with self.subTest(family=family): diff --git a/test/test_rx_power_saving/test_rx_power_saving.cpp b/test/test_rx_power_saving/test_rx_power_saving.cpp index 969f8b989..3dc72fbea 100644 --- a/test/test_rx_power_saving/test_rx_power_saving.cpp +++ b/test/test_rx_power_saving/test_rx_power_saving.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include TEST(RxPowerSaving, WirePreamblesStayCompatibleRegardlessOfLocalPowerSaving) { @@ -146,6 +147,151 @@ TEST(RxPowerSavingCLI, ParsesNewNamesAndRejectsTrailingJunk) { } } +namespace { +class RxPowerSavingTestControl : public RxPowerSavingControl { +public: + bool accept = true; + bool enabled = false; + bool config_error = false; + uint32_t transition_us = 1000; + float capture_cost = 8; + uint32_t rx_us = 0, sleep_us = 0; + RxPowerSavingStatus status; + RxPowerSavingStatus getRxPowerSavingStatus() const override { return status; } + bool setRxPowerSaving(bool on, uint32_t rx, uint32_t sleep) override { + if (!accept) return false; + enabled = on; rx_us = rx; sleep_us = sleep; + return true; + } + void setRxPowerSavingConfigError(bool invalid) override { config_error = invalid; } + float rxPowerSavingCaptureCostSymbols() const override { return capture_cost; } + uint32_t rxPowerSavingTransitionUs() const override { return transition_us; } +}; +} + +TEST(RxPowerSaving, RepeatedArmFailuresStopUntilResetOrSuccess) { + RxPowerSavingArmRetryState retry; + for (unsigned i = 0; i < 3; ++i) { + EXPECT_TRUE(retry.canAttempt()); + retry.recordFailure(); + } + EXPECT_FALSE(retry.canAttempt()); + retry.recordFailure(); + EXPECT_EQ(3, retry.consecutiveFailures()); + retry.reset(); + EXPECT_TRUE(retry.canAttempt()); + retry.recordFailure(); + retry.recordSuccess(); + EXPECT_EQ(0, retry.consecutiveFailures()); +} + +TEST(RxPowerSaving, ConfigUsesRuntimeRadioTimingAndKeepsWirePreamble) { + RxPowerSavingTestControl control; + RxPowerSavingConfig config; + config.enabled = 1; + ASSERT_TRUE(applyRxPowerSavingConfig(&config, 6, 500, &control)); + EXPECT_TRUE(control.enabled); + EXPECT_FALSE(control.config_error); + EXPECT_FALSE(rxPowerSavingUsesContinuousFallback(control.rx_us, control.sleep_us)); + EXPECT_EQ(16, config.preamble); + uint32_t rx = 0, sleep = 0; + ASSERT_TRUE(recalcRxPowerSavingFromLevel(config.level, 6, 500, 16, &rx, &sleep, + nullptr, nullptr, control.transition_us, control.capture_cost)); + EXPECT_EQ(rx, control.rx_us); + EXPECT_EQ(sleep, control.sleep_us); +} + +TEST(RxPowerSaving, InvalidConfigFallsBackWithoutOverwritingRequestedPolicy) { + RxPowerSavingTestControl control; + RxPowerSavingConfig config; + config.enabled = 1; + config.level = 0; + config.rx_us = 500; + EXPECT_FALSE(applyRxPowerSavingConfig(&config, 8, 62.5f, &control)); + EXPECT_EQ(1, config.enabled); + EXPECT_EQ(500U, config.rx_us); + EXPECT_FALSE(control.enabled); + EXPECT_TRUE(control.config_error); + EXPECT_FALSE(normalizeRxPowerSavingConfig(&config, 8, NAN)); +} + +TEST(RxPowerSavingCLI, RejectedCommandsNeverOverwriteSavedConfig) { + RxPowerSavingTestControl control; + RxPowerSavingConfig config; + char reply[160]; + for (const char* invalid : {"max extra", "level 4294967296", "level 256", "500 1000", + "max preamble 65552", "riskyworkingmax", "level 8 preamble 24"}) { + EXPECT_FALSE(RXPowerSavingCLI::set(invalid, 8, 62.5f, &config, &control, reply, sizeof(reply))) << invalid; + EXPECT_EQ(0, config.enabled); + EXPECT_EQ(RX_POWERSAVING_BALANCED_LEVEL, config.level); + } + control.accept = false; + EXPECT_FALSE(RXPowerSavingCLI::set("max", 8, 62.5f, &config, &control, reply, sizeof(reply))); + EXPECT_EQ(0, config.enabled); +} + +TEST(RxPowerSavingCLI, OffPreservesRequestedLevelAndManualPeriods) { + RxPowerSavingTestControl control; + RxPowerSavingConfig config; + char reply[160]; + ASSERT_TRUE(RXPowerSavingCLI::set("max preamble 64", 5, 500, &config, &control, reply, sizeof(reply))); + EXPECT_EQ(8, config.level); + EXPECT_EQ(64, config.preamble); + ASSERT_TRUE(RXPowerSavingCLI::set("off", 5, 500, &config, &control, reply, sizeof(reply))); + EXPECT_EQ(0, config.enabled); + EXPECT_EQ(8, config.level); + ASSERT_TRUE(RXPowerSavingCLI::set("12000 5000", 8, 62.5f, &config, &control, reply, sizeof(reply))); + EXPECT_EQ(0, config.level); + EXPECT_EQ(12000U, config.rx_us); + EXPECT_EQ(5000U, config.sleep_us); +} + +TEST(RxPowerSavingCLI, StatusUsesLiveWirePreambleAndReportsHardwareClamp) { + RxPowerSavingTestControl control; + control.status.supported = control.status.armed = true; + control.status.effective_rx_us = 78000; + control.status.effective_sleep_us = 60000; + RxPowerSavingConfig config; + config.enabled = 1; + char reply[160]; + RXPowerSavingCLI::get(&config, &control, 8, 62.5f, reply, sizeof(reply), 32); + EXPECT_NE(nullptr, strstr(reply, "effective=armed")); + EXPECT_NE(nullptr, strstr(reply, "catch=17.4")); + EXPECT_NE(nullptr, strstr(reply, "erx=78000,eslp=60000")); + control.status.armed = false; + RXPowerSavingCLI::get(&config, &control, 8, 62.5f, reply, sizeof(reply), 32); + EXPECT_EQ(nullptr, strstr(reply, "catch=")); +} + +TEST(RxPowerSavingCLI, StatusCatchRejectsInvalidBandwidthAndBoundsMaximumPeriods) { + RxPowerSavingTestControl control; + control.status.supported = control.status.armed = true; + RxPowerSavingConfig config; + config.sleep_us = config.rx_us = RX_POWERSAVING_MAX_PERIOD_US; + control.status.effective_rx_us = config.rx_us; + control.status.effective_sleep_us = config.sleep_us; + char reply[160]; + for (float bw : {NAN, INFINITY, 0.0f, -1.0f, 501.0f}) { + RXPowerSavingCLI::get(&config, &control, 5, bw, reply, sizeof(reply), 65535); + EXPECT_EQ(nullptr, strstr(reply, "catch=")); + } + RXPowerSavingCLI::get(&config, &control, 5, 500.0f, reply, sizeof(reply), 65535); + EXPECT_NE(nullptr, strstr(reply, "catch=-403215.0")); + EXPECT_LT(strlen(reply), sizeof(reply)); +} + +TEST(RxPowerSavingCLI, CompactStatusKeepsExperimentalWarningAndFullSetterNames) { + RxPowerSavingTestControl control; + RxPowerSavingConfig config; + char reply[160]; + for (const char* name : {"overdrive", "riskyWorkingMax"}) { + ASSERT_TRUE(RXPowerSavingCLI::set(name, 8, 62.5f, &config, &control, reply, sizeof(reply))); + EXPECT_NE(nullptr, strstr(reply, name)); + RXPowerSavingCLI::get(&config, &control, 8, 62.5f, reply, sizeof(reply)); + EXPECT_NE(nullptr, strstr(reply, config.level == 9 ? "level=9(!)" : "level=10(!)")); + } +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/test/test_rxps_hardware_capability.py b/test/test_rxps_hardware_capability.py new file mode 100644 index 000000000..1c9e4b50e --- /dev/null +++ b/test/test_rxps_hardware_capability.py @@ -0,0 +1,141 @@ +"""Keep unsupported hardware out of RXPS math without changing CLI readback.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +RADIO_STUB = r''' +#pragma once +#include +#define RADIOLIB_ERR_NONE 0 +#define RADIOLIB_ERR_INVALID_FREQUENCY -2 +#define RADIOLIB_ERR_UNKNOWN -16 +#define RADIOLIB_ERR_UNSUPPORTED -2 +#define RADIOLIB_NC 0xffffffff +using RadioLibIrqFlags_t = uint32_t; +class PhysicalLayer { +public: + int setOutputPower(int8_t){return 0;} + int setFrequency(float){return 0;} + int standby(){return 0;} + int startReceive(){return 0;} + int transmitDirect(){return 0;} + int setPreambleLength(uint16_t){return 0;} + uint8_t randomByte(){return 0;} +}; +''' + +HARNESS = r''' +long random(long,long); +#include +#include +#include +#include +#define WRAPPER_CLASS RadioLibWrapper +static_assert(!RadioLibWrapper::SupportsRxPowerSaving, "actual unsupported wrapper capability"); +struct NodePrefs { + uint8_t rx_powersaving_enabled=0,rx_ps_level=5,rx_ps_preamble=16,sf=8; + uint32_t rx_ps_rx_us=12345,rx_ps_sleep_us=67890; + float bw=62.5f; +}; +struct Callbacks { + unsigned calls=0; + 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 bool calculateRxPowerSavingLevel(uint32_t,uint8_t,float,uint32_t,uint32_t*,uint32_t*); + static bool recalculateRxPowerSavingFromLevel(NodePrefs*); + void savePrefs(){++saves;} + void set(const char* config,char* reply){@SETTER@} + void getConfig(const char* config,char* reply){@GETTER@} +}; +@CALCULATOR@ +@RECALCULATOR@ +@ADVICE@ +#define CHECK(value,message) do { if(!(value)){std::fprintf(stderr,"%s\n",message);return 1;} } while(0) +int main(){ + NodePrefs prefs; Callbacks callbacks; CommonCLI cli(prefs,callbacks); char reply[160]={}; + uint32_t rx=1,sleep=2; + CHECK(!cli.calculateRxPowerSavingLevel(8,8,62.5f,16,&rx,&sleep),"unsupported model must not run"); + CHECK(rx==1&&sleep==2,"unsupported model must preserve caller data"); + CHECK(!cli.recalculateRxPowerSavingFromLevel(&prefs),"unsupported retune must not run"); + CHECK(prefs.rx_ps_rx_us==12345&&prefs.rx_ps_sleep_us==67890,"manual periods must survive unsupported retune"); + std::strcpy(reply,"OK");prefs.rx_powersaving_enabled=1; + appendRxPowerSavingAdjustmentNote(reply,&prefs,5,500); + CHECK(!std::strcmp(reply,"OK"),"unsupported hardware must not advise timing adjustments"); + prefs.rx_powersaving_enabled=0; + cli.set("radio.rxps max",reply); + CHECK(!std::strcmp(reply,"ERROR: RX powersaving unsupported"),"enable must report the actual unsupported error"); + CHECK(!callbacks.calls&&!cli.saves,"unsupported enable must not touch hardware or saved state"); + cli.set("radio.rxps off",reply); + CHECK(callbacks.calls==1&&cli.saves==1,"off must remain accepted"); + CHECK(prefs.rx_ps_level==5&&prefs.rx_ps_preamble==16,"off must preserve configured level and preamble"); + cli.getConfig("radio.rxps.config",reply); + CHECK(!std::strcmp(reply,"> off,level=5,preamble=16,rx=12345,sleep=67890"),"legacy config readback must remain exact"); + CHECK(rxPowerSavingPreambleForParams(7,500)==32,"wire preamble must stay unchanged"); + CHECK(rxPowerSavingPreambleForParams(6,500)==64,"fast wire preamble must stay unchanged"); + CHECK(rxPowerSavingPreambleForParams(5,500)==128,"fastest wire preamble must stay unchanged"); +} +''' + + +class RxpsHardwareCapabilityTests(unittest.TestCase): + def probe(self, standard, mutate=False): + 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)') + setter = setter.replace(guard, '', 1) + code = HARNESS.replace('@SETTER@', setter) + code = code.replace('@GETTER@', body(common, 'if (strcmp(config, "radio.rxps.config") == 0)')) + for marker, signature in ( + ('@CALCULATOR@', 'bool CommonCLI::calculateRxPowerSavingLevel('), + ('@RECALCULATOR@', 'bool CommonCLI::recalculateRxPowerSavingFromLevel('), + ('@ADVICE@', 'static void appendRxPowerSavingAdjustmentNote(')): + code = code.replace(marker, body(common, signature)) + # The setter references the following definition, just as production does. + code = code.replace('struct CommonCLI {', + 'static void appendRxPowerSavingAdjustmentNote(char*,const NodePrefs*,uint8_t,float);\nstruct CommonCLI {') + with tempfile.TemporaryDirectory(prefix='rxps-hardware-capability-') as folder: + work = Path(folder) + (work / 'RadioLib.h').write_text(RADIO_STUB) + cpp, binary = work / 'test.cpp', work / 'test' + cpp.write_text(code) + built = subprocess.run([os.environ.get('CXX', 'g++'), '-std=' + standard, + '-pedantic-errors', '-Os', '-ffunction-sections', '-fdata-sections', + '-fsanitize=address,undefined', '-fno-sanitize-recover=all', '-fno-pie', '-no-pie', + '-DLORA_SF=7', '-DLORA_BW=125', '-I' + str(work), + # Existing SDK headers contain inline variables accepted by + # the firmware toolchain. Keep strict diagnostics on the + # extracted production CLI bodies, outside those headers. + '-isystem', str(ROOT / 'src'), '-isystem', str(ROOT / 'test/mocks'), + str(cpp), '-Wl,--gc-sections', '-o', str(binary)], capture_output=True, text=True) + self.assertEqual(built.returncode, 0, built.stdout + built.stderr) + result = subprocess.run([str(binary)], capture_output=True, text=True) + if mutate: + self.assertNotEqual(result.returncode, 0) + self.assertIn('enable must report the actual unsupported error', result.stderr) + else: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + symbols = subprocess.run(['nm', '-C', str(binary)], capture_output=True, text=True, check=True).stdout + self.assertNotIn(' calcRxPowerSavingLevelAtOrAbove(', symbols) + self.assertNotIn(' recalcRxPowerSavingFromLevel(', symbols) + + def test_actual_base_capability_skips_model_and_preserves_commands(self): + for standard in ('c++11', 'c++17'): + with self.subTest(standard=standard): + self.probe(standard) + + def test_missing_early_unsupported_error_is_detected(self): + self.probe('c++11', mutate=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_rxps_runtime.py b/test/test_rxps_runtime.py new file mode 100644 index 000000000..505830e34 --- /dev/null +++ b/test/test_rxps_runtime.py @@ -0,0 +1,259 @@ +"""Execute production RXPS arming, fallback and status against faultable hardware.""" +from pathlib import Path +import os +import subprocess +import tempfile +import unittest + +from cpp_source import body + +ROOT = Path(__file__).resolve().parents[1] + +HARNESS = r''' +#include +#include +#include +#include +#include + +#define MESH_ENABLE_TX_BEACONS 1 +#define MESH_DEBUG_PRINTLN(...) ((void)0) +constexpr int RADIOLIB_ERR_NONE = 0; +using RadioLibIrqFlags_t = uint32_t; +constexpr uint32_t RADIOLIB_IRQ_RX_DEFAULT_FLAGS = 0x100; +constexpr unsigned RADIOLIB_IRQ_PREAMBLE_DETECTED = 1, RADIOLIB_IRQ_RX_DONE = 2; +constexpr unsigned RADIOLIB_IRQ_TIMEOUT = 3, RADIOLIB_IRQ_CRC_ERR = 4; +constexpr unsigned RADIOLIB_IRQ_HEADER_ERR = 5; + +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_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; + int16_t _rx_ps_last_error = RADIOLIB_ERR_NONE; + uint32_t _rx_ps_arm_failures = 0, n_recv = 0, n_sent = 0, n_recv_errors = 0; + RxPowerSavingArmRetryState _rx_ps_arm_retry; + + bool packet_pending = false, supported = true; + int16_t arm_error = 0, continuous_error = 0, stop_error = 0; + uint32_t hardware_rx_us = 48000, hardware_sleep_us = 47000; + unsigned arm_calls = 0, continuous_calls = 0, stop_calls = 0; + unsigned begin_calls = 0, end_calls = 0; + uint8_t reconfigure_result = 1; + std::string events; + + bool supportsRxPowerSaving() const { return supported; } + bool isPacketPendingOrReceiving() const { return packet_pending; } + uint32_t rxPowerSavingTransitionUs() const { return 2600; } + int16_t startContinuousReceive() { + ++continuous_calls; events += 'C'; return continuous_error; + } + int16_t stopDutyCycleHardware() { + ++stop_calls; events += 'S'; return stop_error; + } + int16_t armDutyCycle(RadioLibIrqFlags_t flags, RadioLibIrqFlags_t mask, + uint32_t* rx_us, uint32_t* sleep_us) { + ++arm_calls; events += 'A'; + assert(flags == (RADIOLIB_IRQ_RX_DEFAULT_FLAGS | (1UL << RADIOLIB_IRQ_PREAMBLE_DETECTED))); + assert(mask == ((1UL << RADIOLIB_IRQ_RX_DONE) | (1UL << RADIOLIB_IRQ_TIMEOUT) + | (1UL << RADIOLIB_IRQ_CRC_ERR) | (1UL << RADIOLIB_IRQ_HEADER_ERR))); + assert(*rx_us == _rx_ps_rx_us && *sleep_us == _rx_ps_sleep_us); + if (!arm_error) { *rx_us = hardware_rx_us; *sleep_us = hardware_sleep_us; } + return arm_error; + } + uint8_t beginReconfigure() { ++begin_calls; return reconfigure_result; } + void endReconfigure(bool resume_rx) { + ++end_calls; + if (resume_rx) assert(startReceivePowerSaving() == RADIOLIB_ERR_NONE); + } + + int startReceivePowerSaving(); + void stopReceiveDutyCycle(); + bool setRxPowerSaving(bool, uint32_t, uint32_t); + RxPowerSavingStatus getRxPowerSavingStatus() const; + @RESET_STATS@ +}; + +@METHODS@ + +static void exhaust(RadioLibWrapper& radio) { + radio.arm_error = -708; + for (unsigned attempt = 1; attempt <= 3; ++attempt) { + assert(radio.startReceivePowerSaving() == RADIOLIB_ERR_NONE); + assert(radio.arm_calls == attempt && radio.stop_calls == attempt); + assert(radio.continuous_calls == attempt); + const auto status = radio.getRxPowerSavingStatus(); + assert(status.supported && !status.armed && status.last_error == -708); + assert(status.arm_failures == attempt); + assert(!status.effective_rx_us && !status.effective_sleep_us); + assert(radio._rx_ps_continuous_fallback); + } + assert(radio.events == "ASCASCASC"); + for (unsigned poll = 0; poll < 5; ++poll) + assert(radio.startReceivePowerSaving() == RADIOLIB_ERR_NONE); + assert(radio.arm_calls == 3 && radio.stop_calls == 3 && radio.continuous_calls == 8); + assert(radio.getRxPowerSavingStatus().last_error == -708); +} + +int main() { + { + RadioLibWrapper radio; + exhaust(radio); + } + { + // Successful continuous RX for calibration must retain the arm failure. + RadioLibWrapper radio; + radio.arm_error = -708; + assert(!radio.startReceivePowerSaving()); + radio._nf_calib_active = true; + assert(!radio.startReceivePowerSaving()); + const auto status = radio.getRxPowerSavingStatus(); + assert(status.last_error == -708 && status.arm_failures == 1); + assert(radio.arm_calls == 1 && radio.continuous_calls == 2); + radio._nf_calib_active = false; + assert(!radio.startReceivePowerSaving() && radio.arm_calls == 2); + } + { + // A real arm success replenishes the consecutive-error budget. + RadioLibWrapper radio; + radio.arm_error = -708; + assert(!radio.startReceivePowerSaving()); + assert(!radio.startReceivePowerSaving()); + radio.arm_error = 0; + assert(!radio.startReceivePowerSaving()); + auto status = radio.getRxPowerSavingStatus(); + assert(status.armed && status.last_error == 0 && status.arm_failures == 2); + assert(status.effective_rx_us == 48000 && status.effective_sleep_us == 47000); + assert(status.effective_rx_us != radio._rx_ps_rx_us); + assert(status.effective_sleep_us != radio._rx_ps_sleep_us); + radio.arm_error = -707; + for (unsigned attempt = 1; attempt <= 3; ++attempt) { + assert(!radio.startReceivePowerSaving()); + assert(radio.arm_calls == 3 + attempt); + } + assert(!radio.startReceivePowerSaving() && radio.arm_calls == 6); + assert(radio.getRxPowerSavingStatus().arm_failures == 5); + } + { + RadioLibWrapper radio; + assert(!radio.startReceivePowerSaving()); + radio._nf_calib_active = radio.packet_pending = true; + assert(!radio.startReceivePowerSaving()); + assert(radio.arm_calls == 1 && radio.stop_calls == 0 && radio.continuous_calls == 0); + assert(radio.getRxPowerSavingStatus().armed); + radio.packet_pending = false; + assert(!radio.startReceivePowerSaving()); + auto status = radio.getRxPowerSavingStatus(); + assert(!status.armed && !status.effective_rx_us && !status.effective_sleep_us); + assert(radio.events == "ASC"); + } + { + RadioLibWrapper radio; + assert(!radio.startReceivePowerSaving()); + radio.stop_error = -705; + radio.stopReceiveDutyCycle(); + auto status = radio.getRxPowerSavingStatus(); + assert(!status.armed && status.last_error == -705); + assert(!status.effective_rx_us && !status.effective_sleep_us); + } + { + RadioLibWrapper radio; + radio._rx_ps_sleep_us = 2000; // Below the real 2600 us wake transition. + assert(!radio.startReceivePowerSaving()); + assert(!radio.arm_calls && radio.continuous_calls == 1); + assert(radio.getRxPowerSavingStatus().last_error == RX_POWERSAVING_ERR_TIMING); + } + { + // Hardware fallback failure must be visible to the ordinary RX retry loop. + RadioLibWrapper radio; + radio.arm_error = -708; radio.continuous_error = -705; + assert(radio.startReceivePowerSaving() == -705); + assert(radio.getRxPowerSavingStatus().last_error == -705); + assert(radio.getRxPowerSavingStatus().arm_failures == 1); + } + { + RadioLibWrapper radio; + radio._rx_ps_rx_us = radio._rx_ps_sleep_us = RX_POWERSAVING_CONTINUOUS_FALLBACK_US; + assert(!radio.startReceivePowerSaving()); + assert(!radio.arm_calls && radio.continuous_calls == 1); + assert(radio._rx_ps_continuous_fallback && !radio.getRxPowerSavingStatus().armed); + assert(!radio.getRxPowerSavingStatus().arm_failures); + } + { + RadioLibWrapper radio; + exhaust(radio); + radio.arm_error = 0; + assert(radio.setRxPowerSaving(true, 70000, 90000)); + assert(radio.begin_calls == 1 && radio.end_calls == 1 && radio.arm_calls == 4); + auto status = radio.getRxPowerSavingStatus(); + assert(status.armed && status.last_error == 0 && status.arm_failures == 3); + assert(radio._rx_ps_rx_us == 70000 && radio._rx_ps_sleep_us == 90000); + } + { + RadioLibWrapper radio; + exhaust(radio); + radio.n_recv = 2; radio.n_sent = 3; radio.n_recv_errors = 4; + radio.resetStats(); + assert(!radio.n_recv && !radio.n_sent && !radio.n_recv_errors); + assert(!radio.getRxPowerSavingStatus().arm_failures); + radio.arm_error = 0; + assert(!radio.startReceivePowerSaving() && radio.arm_calls == 4); + assert(radio.getRxPowerSavingStatus().armed); + } + std::puts("PASS: production RXPS fallback, retry, status and recovery"); +} +''' + + +def production_source(): + wrapper = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + header = (ROOT / "src/helpers/radiolib/RadioLibWrappers.h").read_text() + methods = "\n\n".join(body(wrapper, signature) for signature in ( + "int RadioLibWrapper::startReceivePowerSaving()", + "void RadioLibWrapper::stopReceiveDutyCycle()", + "bool RadioLibWrapper::setRxPowerSaving(", + "RxPowerSavingStatus RadioLibWrapper::getRxPowerSavingStatus()")) + return HARNESS.replace("@METHODS@", methods).replace( + "@RESET_STATS@", body(header, "void resetStats()")) + + +class RxPowerSavingRuntimeTests(unittest.TestCase): + def run_harness(self, source, expect_pass=True): + with tempfile.TemporaryDirectory(prefix="meshcore-rxps-runtime-") as directory: + cpp, binary = Path(directory) / "runtime.cpp", Path(directory) / "runtime" + cpp.write_text(source, encoding="ascii") + build = 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(cpp), "-o", str(binary)], + capture_output=True, text=True, timeout=60) + self.assertEqual(build.returncode, 0, build.stdout + build.stderr) + run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10) + if expect_pass: + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + self.assertIn("PASS:", run.stdout) + else: + self.assertNotEqual(run.returncode, 0, "negative control unexpectedly passed") + self.assertIn("Assertion", run.stderr) + + def test_actual_wrapper_rxps_retry_status_and_recovery(self): + self.run_harness(production_source()) + + def test_retry_budget_and_calibration_error_negative_controls(self): + source = production_source() + mutations = ( + ("|| !_rx_ps_arm_retry.canAttempt()", "|| false"), + ("_rx_ps_arm_retry.recordSuccess();", "/* omit successful-arm budget reset */"), + ("if (err != RADIOLIB_ERR_NONE || !_rx_ps_enabled) _rx_ps_last_error = err;", + "_rx_ps_last_error = err;"), + ) + for original, replacement in mutations: + with self.subTest(mutation=original): + self.assertEqual(source.count(original), 1) + self.run_harness(source.replace(original, replacement, 1), expect_pass=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_upstream_power_policy_merge.py b/test/test_upstream_power_policy_merge.py index abd9c472f..6d360827a 100644 --- a/test/test_upstream_power_policy_merge.py +++ b/test/test_upstream_power_policy_merge.py @@ -55,6 +55,20 @@ class Recipes: class UpstreamPowerPolicyMergeTests(unittest.TestCase): + def test_c6_keeps_arduino3_and_esp32_ota_guards(self): + recipes = Recipes() + self.assertIn("powersaving-arduino-lib/releases/download/2.0.17/", + recipes.value("esp32_base", "platform_packages")) + self.assertEqual(recipes.value("esp32c6_base", "platform_packages"), "") + self.assertIn("53.03.13-1", recipes.value("esp32c6_base", "platform")) + for option, expected in ( + ("build_flags", "-D ENABLE_OTA=1"), + ("build_src_filter", "+"), + ("extra_scripts", "pre:scripts/esp32_usb_session_fix.py"), + ("extra_scripts", "post:scripts/check_firmware_ram.py")): + with self.subTest(option=option, expected=expected): + self.assertIn(expected, recipes.value("esp32c6_base", option)) + def test_all_eleven_board_bases_keep_chemistry_power_policy(self): recipes = Recipes() for directory, section in BOARDS.items(): diff --git a/variants/thinknode_m4/platformio.ini b/variants/thinknode_m4/platformio.ini index ec1ca193b..bcfa87d39 100644 --- a/variants/thinknode_m4/platformio.ini +++ b/variants/thinknode_m4/platformio.ini @@ -13,6 +13,7 @@ build_flags = ${nrf52_base.build_flags} -D PIN_STATUS_LED=LED_STATUS -D RADIO_CLASS=CustomLR1110 -D WRAPPER_CLASS=CustomLR1110Wrapper + -D USE_LR1110 -D LORA_TX_POWER=22 -D RF_SWITCH_TABLE -D RX_BOOSTED_GAIN=true