diff --git a/docs/cli_commands.md b/docs/cli_commands.md index b6539496c..0d562f54c 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -3024,7 +3024,7 @@ 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. -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, it lowers the CPU clock to 80 MHz, enables idle yielding, and enables the configured GPS duty cycle. 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. +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 diff --git a/docs/companion_radio_full.md b/docs/companion_radio_full.md index 6a6ffbbcf..d812d2136 100644 --- a/docs/companion_radio_full.md +++ b/docs/companion_radio_full.md @@ -496,9 +496,11 @@ turns it on once when upgrading an older Companion preference file, including one written by the short-lived default-off regression. After that one-time migration, an explicit `set powersaving off` selection remains persistent. -On ESP32, enabling it lowers the CPU clock to 80 MHz, enables idle yielding, -and enables the configured GPS duty cycle. Disabling it restores the normal CPU -clock and keeps GPS awake. Active Full Companion transports remain available in +On ESP32, enabling it normally selects 80 MHz when processor speed is Auto, +enables idle yielding, and enables the configured GPS duty cycle. Auto holds +the highest supported CPU speed while a second radio profile is active, except +on SenseCAP Indicator. An explicit processor speed stays selected. Disabling +device power saving restores the normal Auto CPU policy and keeps GPS awake. Active Full Companion transports remain available in both states; on the SenseCAP Indicator this means USB plus the selected BLE or infrastructure-WiFi secondary transport. WiFi modem sleep stays enabled when BLE and the ESP32 WiFi radio are active together because coexistence requires diff --git a/docs/power_saving_diagnostics.md b/docs/power_saving_diagnostics.md index e7a86daf2..369ffffe8 100644 --- a/docs/power_saving_diagnostics.md +++ b/docs/power_saving_diagnostics.md @@ -115,9 +115,12 @@ packet-test failures. SDK wait time is not a current measurement. The WebConfig **Processor speed** selector and `set cpu.freq auto|80|160|240` save the processor clock choice. `get cpu.freq` reports the choice, actual MHz and supported values. The board rejects unsupported clocks. Auto retains the -existing policy: Companion normally selects 80 MHz with device power saving -on and its configured nominal speed with it off. Other roles retain their -nominal speed in Auto. A fixed speed stays fixed when power saving changes. +normal policy: Companion selects 80 MHz with device power saving on and its +configured nominal speed with it off. Other roles retain their nominal speed. +While radio2 or a temporary second profile is active, Auto instead holds the +highest supported CPU speed (240 or 160 MHz) to prioritize switching speed. +SenseCAP Indicator retains its existing policy. A fixed speed stays fixed when +power saving or radio2 changes. The selection applies immediately; a failed apply or save reports an error and attempts to restore the previous selection. Returning to Auto clears the saved override. Existing preferences load as Auto. diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 3a0c1edd8..e85855907 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3196,6 +3196,7 @@ void MyMesh::begin(bool has_display, bool radio_available, if (!mesh::esp32::cpuclock::validSelection(_prefs.cpu_freq_mhz) || (_prefs.cpu_freq_mhz && !board.supportsCpuFrequencyMHz(_prefs.cpu_freq_mhz))) _prefs.cpu_freq_mhz = 0; + board.setCpuFrequencyRequest(_prefs.cpu_freq_mhz, _prefs.powersaving_enabled != 0); #endif _prefs.wifi_enabled = constrain(_prefs.wifi_enabled, 0, 1); _prefs.usb_logging_enabled = constrain(_prefs.usb_logging_enabled, 0, 2); diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index a8cdc0841..ba30e8fce 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -561,16 +561,16 @@ static void applyCompanionBluetoothSleep(bool enabled, static bool applyCompanionPowerSaving(bool enabled) { const uint16_t selected = the_mesh.getNodePrefs()->cpu_freq_mhz; - uint32_t max_mhz = mesh::esp32::cpuclock::resolveMHz(selected, enabled, true); + board.setRadioProfileCpuClockPolicy(the_mesh.isDualRadioActive()); + const uint32_t max_mhz = board.resolveCpuFrequencyMHz(selected, enabled); if (!board.applyCpuFrequencyMHz(selected, enabled)) { mesh::usbLoggingPort().printf("Device power saving failed: CPU clock policy rejected\r\n"); return false; } - if (!selected && max_mhz == 240 && !board.supportsCpuFrequencyMHz(240) - && board.supportsCpuFrequencyMHz(160)) max_mhz = 160; - #if COMPANION_IDF_PM_AVAILABLE - const uint32_t min_mhz = !selected && enabled && max_mhz > 40 ? 40 : max_mhz; + const uint32_t min_mhz = !selected && enabled && max_mhz > 40 + && !mesh::esp32::cpuclock::profilePerformanceEnabled(the_mesh.isDualRadioActive()) + ? 40 : max_mhz; #if (defined(ARDUINO_USB_CDC_ON_BOOT) && ARDUINO_USB_CDC_ON_BOOT) \ || defined(ENABLE_USB_INTERFACE) // Automatic light sleep interrupts native USB CDC and USB companions. @@ -604,7 +604,8 @@ static bool applyCompanionPowerSaving(bool enabled) { } static void serviceCompanionPowerSaving(bool force = false) { - const int8_t requested = the_mesh.getNodePrefs()->powersaving_enabled ? 1 : 0; + const int8_t requested = (the_mesh.getNodePrefs()->powersaving_enabled ? 1 : 0) + | (mesh::esp32::cpuclock::profilePerformanceEnabled(the_mesh.isDualRadioActive()) ? 2 : 0); const uint16_t selected = the_mesh.getNodePrefs()->cpu_freq_mhz; if (!force && applied_power_saving == requested && applied_cpu_frequency == selected) return; @@ -617,7 +618,7 @@ static void serviceCompanionPowerSaving(bool force = false) { attempted_power_saving = requested; attempted_cpu_frequency = selected; - if (applyCompanionPowerSaving(requested != 0)) { + if (applyCompanionPowerSaving((requested & 1) != 0)) { applied_power_saving = requested; applied_cpu_frequency = selected; power_saving_retry_at = 0; diff --git a/examples/simple_repeater/main.cpp b/examples/simple_repeater/main.cpp index 7c2f44969..87aba7036 100644 --- a/examples/simple_repeater/main.cpp +++ b/examples/simple_repeater/main.cpp @@ -658,7 +658,7 @@ void loop() { #if defined(ESP32_PLATFORM) board.servicePowerTestControls(); mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, - the_mesh.getNodePrefs()->powersaving_enabled != 0, millis()); + the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif const auto sleep_guard = [](bool allowed, mesh::powerdiag::Reason reason) { if (!allowed && mesh::powerdiag::enabled()) diff --git a/examples/simple_room_server/main.cpp b/examples/simple_room_server/main.cpp index c40235240..83a5ce226 100644 --- a/examples/simple_room_server/main.cpp +++ b/examples/simple_room_server/main.cpp @@ -433,7 +433,7 @@ void loop() { #if defined(ESP32_PLATFORM) board.servicePowerTestControls(); mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, - the_mesh.getNodePrefs()->powersaving_enabled != 0, millis()); + the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif const auto sleep_guard = [](bool allowed, mesh::powerdiag::Reason reason) { if (!allowed && mesh::powerdiag::enabled()) diff --git a/examples/simple_sensor/main.cpp b/examples/simple_sensor/main.cpp index 0df00336b..5a68639f9 100644 --- a/examples/simple_sensor/main.cpp +++ b/examples/simple_sensor/main.cpp @@ -393,7 +393,7 @@ void loop() { #if defined(ESP32_PLATFORM) board.servicePowerTestControls(); mesh::esp32::serviceCpuFrequencyPolicy(board, the_mesh.getNodePrefs()->cpu_freq_mhz, - the_mesh.getNodePrefs()->powersaving_enabled != 0, millis()); + the_mesh.getNodePrefs()->powersaving_enabled != 0, millis(), false, the_mesh.isDualRadioActive()); #endif const auto sleep_guard = [](bool allowed, mesh::powerdiag::Reason reason) { if (!allowed && mesh::powerdiag::enabled()) diff --git a/src/MeshCore.h b/src/MeshCore.h index 898fba99d..9c96eeed0 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -170,7 +170,12 @@ public: // preference layout and board vtable. Zero selects the role's Auto policy. virtual uint16_t getCpuFrequencyMHz() const { return 0; } virtual bool supportsCpuFrequencyMHz(uint16_t) const { return false; } + virtual void setCpuFrequencyRequest(uint16_t, bool) { } virtual bool applyCpuFrequencyMHz(uint16_t, bool) { return false; } + virtual bool prepareRadioProfileCpuClock(bool, bool& changed) { + changed = false; + return true; + } #endif virtual void reboot() = 0; MESH_REGULATOR_VIRTUAL uint8_t mcuRegulatorModes() const { return 0; } diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index 54402056d..b8aae3878 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -1070,6 +1070,12 @@ void CommonCLI::loadPrefs(FILESYSTEM* fs) { if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults) #endif mesh::hilStartupTrace("prefs_apply_begin"); +#if defined(ESP32_PLATFORM) + if (!mesh::esp32::cpuclock::validSelection(_prefs->cpu_freq_mhz) + || (_prefs->cpu_freq_mhz && !_board->supportsCpuFrequencyMHz(_prefs->cpu_freq_mhz))) + _prefs->cpu_freq_mhz = 0; + _board->setCpuFrequencyRequest(_prefs->cpu_freq_mhz, _prefs->powersaving_enabled != 0); +#endif #if defined(NRF52_POWER_MANAGEMENT) // The startup voltage policy owns the relative calibration on these boards. // Its checked record must not be replaced by the legacy infrastructure field. diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index d6d17938c..60168f000 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -54,6 +54,13 @@ protected: bool ota_started_ap = false; bool ota_started_radio = false; bool companion_cpu_clock_policy = false; + bool radio_profile_cpu_clock_policy = false; + bool applied_profile_cpu_clock_policy = false; + bool applied_cpu_clock_valid = false; + uint16_t requested_cpu_clock_selection = 0; + bool requested_cpu_clock_powersaving = false; + uint16_t sampled_profile_cpu_mhz = 0; + uint32_t profile_cpu_clock_retry_at = 0; uint32_t uart_quiet_start_at = 0; uint32_t uart_quiet_restore_at = 0; uint16_t uart_quiet_seconds = 0; @@ -121,6 +128,9 @@ public: void attachDynamicPrefs(KeyValueStore* prefs) { (void)prefs; } // no-op void setCompanionCpuClockPolicy(bool enabled) { companion_cpu_clock_policy = enabled; } + void setRadioProfileCpuClockPolicy(bool enabled) { + radio_profile_cpu_clock_policy = mesh::esp32::cpuclock::profilePerformanceEnabled(enabled); + } uint16_t getCpuFrequencyMHz() const override { return ::getCpuFrequencyMhz(); } bool supportsCpuFrequencyMHz(uint16_t mhz) const override { #if CONFIG_IDF_TARGET_ESP32H2 @@ -145,15 +155,40 @@ public: #endif } + uint16_t resolveCpuFrequencyMHz(uint16_t selected, bool powersaving) const { + uint16_t maximum = mesh::esp32::cpuclock::resolveMHz( + selected, powersaving, companion_cpu_clock_policy); + if (!selected && radio_profile_cpu_clock_policy) { + const uint16_t candidates[] = {240, 160, 80}; + for (uint16_t candidate : candidates) { + if (supportsCpuFrequencyMHz(candidate)) return candidate; + } + } + if (!selected && maximum == 240 && !supportsCpuFrequencyMHz(240) + && supportsCpuFrequencyMHz(160)) maximum = 160; + return maximum; + } + + // Seed the loaded choice before saved profiles can retune the radio. Keep + // this request even when the SDK cannot apply it on the first attempt. + void setCpuFrequencyRequest(uint16_t selected, bool powersaving) override { + if (requested_cpu_clock_selection != selected || requested_cpu_clock_powersaving != powersaving) { + requested_cpu_clock_selection = selected; + requested_cpu_clock_powersaving = powersaving; + applied_cpu_clock_valid = false; + profile_cpu_clock_retry_at = 0; + } + } + bool applyCpuFrequencyMHz(uint16_t selected, bool powersaving) override { if (!mesh::esp32::cpuclock::validSelection(selected) || (selected && !supportsCpuFrequencyMHz(selected))) return false; - uint16_t maximum = mesh::esp32::cpuclock::resolveMHz( - selected, powersaving, companion_cpu_clock_policy); - if (!selected && maximum == 240 && !supportsCpuFrequencyMHz(240) - && supportsCpuFrequencyMHz(160)) maximum = 160; + setCpuFrequencyRequest(selected, powersaving); + const uint16_t maximum = resolveCpuFrequencyMHz(selected, powersaving); + applied_cpu_clock_valid = false; #if defined(CONFIG_PM_ENABLE) && CONFIG_PM_ENABLE - const uint16_t minimum = !selected && companion_cpu_clock_policy && powersaving && maximum > 40 + const uint16_t minimum = !selected && !radio_profile_cpu_clock_policy + && companion_cpu_clock_policy && powersaving && maximum > 40 ? 40 : maximum; #if CONFIG_IDF_TARGET_ESP32C3 esp_pm_config_esp32c3_t config; @@ -175,7 +210,42 @@ public: #else if (!setCpuFrequencyMhz(maximum)) return false; #endif - return !selected || getCpuFrequencyMHz() == selected; + if ((selected || radio_profile_cpu_clock_policy) && getCpuFrequencyMHz() != maximum) return false; + applied_profile_cpu_clock_policy = radio_profile_cpu_clock_policy; + applied_cpu_clock_valid = true; + profile_cpu_clock_retry_at = 0; + return true; + } + + bool prepareRadioProfileCpuClock(bool enabled, bool& changed) override { + changed = false; + setRadioProfileCpuClockPolicy(enabled); + uint16_t previous = 0; + const bool fixed_clock = requested_cpu_clock_selection || radio_profile_cpu_clock_policy; + const bool clock_drifted = fixed_clock && getCpuFrequencyMHz() + != resolveCpuFrequencyMHz(requested_cpu_clock_selection, requested_cpu_clock_powersaving); + if (!applied_cpu_clock_valid || applied_profile_cpu_clock_policy != radio_profile_cpu_clock_policy + || clock_drifted) { + const uint32_t now = millis(); + if (profile_cpu_clock_retry_at && static_cast(now - profile_cpu_clock_retry_at) < 0) + return false; + previous = getCpuFrequencyMHz(); + if (!applyCpuFrequencyMHz(requested_cpu_clock_selection, requested_cpu_clock_powersaving)) { + profile_cpu_clock_retry_at = now + 5000; + if (!profile_cpu_clock_retry_at) profile_cpu_clock_retry_at = 1; + return false; + } + profile_cpu_clock_retry_at = 0; + } + // Observe explicit clock changes too. Reset only the pair's timing proof, + // once per effective transition; hardware failure counters stay intact. + if (radio_profile_cpu_clock_policy) { + const uint16_t current = getCpuFrequencyMHz(); + changed = (previous && previous != current) + || (sampled_profile_cpu_mhz && sampled_profile_cpu_mhz != current); + sampled_profile_cpu_mhz = current; + } else sampled_profile_cpu_mhz = 0; + return true; } bool handleUartQuiet(mesh::esp32::UartQuietAction action, uint16_t seconds, diff --git a/src/helpers/esp32/CpuClockPolicy.h b/src/helpers/esp32/CpuClockPolicy.h index b4c31d33d..24102a7b5 100644 --- a/src/helpers/esp32/CpuClockPolicy.h +++ b/src/helpers/esp32/CpuClockPolicy.h @@ -34,6 +34,15 @@ inline uint16_t resolveMHz(uint16_t selected, bool powersaving, bool companion) return companion && powersaving && nominal > 80 ? 80 : nominal; } +inline bool profilePerformanceEnabled(bool profiles_enabled) { +#if defined(SENSECAP_INDICATOR_LORA) + (void)profiles_enabled; + return false; +#else + return profiles_enabled; +#endif +} + static constexpr size_t FooterSize = 8; inline uint16_t footerChecksum(const uint8_t* bytes, size_t length) { diff --git a/src/helpers/esp32/PowerTestControls.h b/src/helpers/esp32/PowerTestControls.h index 60b163d0c..086e4a541 100644 --- a/src/helpers/esp32/PowerTestControls.h +++ b/src/helpers/esp32/PowerTestControls.h @@ -4,6 +4,7 @@ #include #include #include +#include "CpuClockPolicy.h" namespace mesh { namespace esp32 { @@ -72,14 +73,18 @@ inline uint32_t profileSleepSettleMillis(uint32_t normal) { // choice at boot and after preference changes without reconfiguring each loop. template bool serviceCpuFrequencyPolicy(Board& board, uint16_t selected, bool powersaving, - uint32_t now_ms, bool force = false) { + uint32_t now_ms, bool force = false, bool profiles_enabled = false) { struct State { uint16_t selected = UINT16_MAX; bool powersaving = false; + bool profiles_enabled = false; uint32_t retry_at = 0; }; static State state; - if (!force && state.selected == selected && state.powersaving == powersaving) return true; + profiles_enabled = cpuclock::profilePerformanceEnabled(profiles_enabled); + board.setRadioProfileCpuClockPolicy(profiles_enabled); + if (!force && state.selected == selected && state.powersaving == powersaving + && state.profiles_enabled == profiles_enabled) return true; if (!force && state.retry_at && static_cast(now_ms - state.retry_at) < 0) return false; if (!board.applyCpuFrequencyMHz(selected, powersaving)) { state.retry_at = now_ms + 5000; @@ -88,6 +93,7 @@ bool serviceCpuFrequencyPolicy(Board& board, uint16_t selected, bool powersaving } state.selected = selected; state.powersaving = powersaving; + state.profiles_enabled = profiles_enabled; state.retry_at = 0; return true; } diff --git a/src/helpers/radiolib/RadioLibWrappers.cpp b/src/helpers/radiolib/RadioLibWrappers.cpp index dc256a7c4..7cc906e4f 100644 --- a/src/helpers/radiolib/RadioLibWrappers.cpp +++ b/src/helpers/radiolib/RadioLibWrappers.cpp @@ -626,6 +626,12 @@ mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(uint8_t profile) { #endif if (_cw_active) return mesh::RadioParamApplyResult::BUSY; if (profile > 1 || (profile == 1 && !_profiles.enabled())) return mesh::RadioParamApplyResult::FAILED; +#if defined(ESP32_PLATFORM) && !defined(SENSECAP_INDICATOR_LORA) + bool clock_changed = false; + if (!_board->prepareRadioProfileCpuClock(_profiles.enabled(), clock_changed)) + return mesh::RadioParamApplyResult::BUSY; + if (clock_changed) _profiles.resetSwitchTest(); +#endif const auto& p = _profiles.params(profile); const uint16_t preamble = profilePreamble(profile); if (!_profile_refresh_required && !_profile_repair_required && _active_profile == profile && _profile_generation == _profiles.generation[profile] diff --git a/test/test_esp32_cpu_clock_board.py b/test/test_esp32_cpu_clock_board.py index 7ff7bf7b6..58096c34e 100644 --- a/test/test_esp32_cpu_clock_board.py +++ b/test/test_esp32_cpu_clock_board.py @@ -42,10 +42,16 @@ int esp_pm_configure(esp_pm_config_t* config){ struct MainBoard { virtual uint16_t getCpuFrequencyMHz()const=0; virtual bool supportsCpuFrequencyMHz(uint16_t)const=0; + virtual void setCpuFrequencyRequest(uint16_t,bool){} virtual bool applyCpuFrequencyMHz(uint16_t,bool)=0; + virtual bool prepareRadioProfileCpuClock(bool,bool& changed){changed=false;return true;} }; struct ESP32Board:MainBoard { bool companion_cpu_clock_policy=false; + bool radio_profile_cpu_clock_policy=false,applied_profile_cpu_clock_policy=false,applied_cpu_clock_valid=false; + uint16_t requested_cpu_clock_selection=0,sampled_profile_cpu_mhz=0; + bool requested_cpu_clock_powersaving=false; + uint32_t profile_cpu_clock_retry_at=0; unsigned quiet_services=0; void servicePowerTestControls(){++quiet_services;} @METHODS@ @@ -55,15 +61,17 @@ struct Console {void printf(const char*,...){}}; Console& usbLoggingPort(){static Console value;return value;} } struct Prefs {uint16_t cpu_freq_mhz=0;bool powersaving_enabled=true;} prefs; -struct Mesh {Prefs* getNodePrefs(){return &prefs;}} the_mesh; +struct Mesh {bool dual=false;Prefs* getNodePrefs(){return &prefs;}bool isDualRadioActive(){return dual;}} the_mesh; ''' class CpuClockBoardTest(unittest.TestCase): def fixture(self): methods="\n".join(board_method(signature) for signature in ( - "void setCompanionCpuClockPolicy(","uint16_t getCpuFrequencyMHz() const override", - "bool supportsCpuFrequencyMHz(","bool applyCpuFrequencyMHz(")) + "void setCompanionCpuClockPolicy(","void setRadioProfileCpuClockPolicy(", + "uint16_t getCpuFrequencyMHz() const override", "bool supportsCpuFrequencyMHz(", + "uint16_t resolveCpuFrequencyMHz(", "void setCpuFrequencyRequest(", "bool applyCpuFrequencyMHz(", + "bool prepareRadioProfileCpuClock(")) return PRELUDE.replace("@METHODS@",methods) def run_fixture(self,main,defines=()): @@ -153,6 +161,14 @@ int main(){ prefs.powersaving_enabled=false;serviceCompanionPowerSaving();assert(actual_mhz==160); prefs.powersaving_enabled=true;serviceCompanionPowerSaving();assert(actual_mhz==160); prefs.cpu_freq_mhz=0;serviceCompanionPowerSaving();assert(actual_mhz==80); + the_mesh.dual=true;serviceCompanionPowerSaving();assert(actual_mhz==240); + writes=clock_writes;serviceCompanionPowerSaving();assert(clock_writes==writes); + // Repeated Auto commands use the same board policy, rather than dropping + // back to80 behind the role's unchanged request cache. + assert(board.applyCpuFrequencyMHz(0,true)&&actual_mhz==240); + prefs.cpu_freq_mhz=80;serviceCompanionPowerSaving();assert(actual_mhz==80); + prefs.cpu_freq_mhz=0;serviceCompanionPowerSaving();assert(actual_mhz==240); + the_mesh.dual=false;serviceCompanionPowerSaving();assert(actual_mhz==80); prefs.powersaving_enabled=false;serviceCompanionPowerSaving();assert(actual_mhz==240); // Failures retain the request and retry after the original bounded delay. prefs.cpu_freq_mhz=160;fail_clock=true;serviceCompanionPowerSaving();writes=clock_writes; @@ -175,11 +191,249 @@ int main(){ unsigned writes=clock_writes;role_service();assert(clock_writes==writes&&board.quiet_services==2); prefs.powersaving_enabled=false;role_service();assert(actual_mhz==160); prefs.cpu_freq_mhz=80;role_service();assert(actual_mhz==80); + prefs.cpu_freq_mhz=0;role_service();assert(actual_mhz==mesh::esp32::cpuclock::nominalMHz()); + // Active profiles prefer the chip maximum over a low nominal or Auto+PS. + prefs.powersaving_enabled=true;the_mesh.dual=true;role_service();assert(actual_mhz==240); + prefs.cpu_freq_mhz=80;role_service();assert(actual_mhz==80); prefs.cpu_freq_mhz=0;role_service();assert(actual_mhz==240); + the_mesh.dual=false;role_service();assert(actual_mhz==mesh::esp32::cpuclock::nominalMHz()); } ''' with self.subTest(role=role): self.run_fixture(tests,["-DCONFIG_IDF_TARGET_ESP32S3=1","-DCONFIG_PM_ENABLE=0"]) + self.run_fixture(tests,["-DCONFIG_IDF_TARGET_ESP32S3=1","-DCONFIG_PM_ENABLE=0", + "-DESP32_CPU_FREQ=80"]) + + def test_profile_auto_clock_limits_dials_timing_changes_and_failed_boost_retry(self): + tests=r''' +int main(){ + board.setCompanionCpuClockPolicy(true); + assert(board.applyCpuFrequencyMHz(0,true)); + bool changed=true; + assert(board.prepareRadioProfileCpuClock(true,changed)); + const unsigned fast=board.supportsCpuFrequencyMHz(240)?240:160; + assert(actual_mhz==fast); +#if CONFIG_PM_ENABLE + assert(unsigned(applied_pm.min_freq_mhz)==fast&&unsigned(applied_pm.max_freq_mhz)==fast); + // Fixing the clock does not disable the existing CPU sleep permission. + assert(applied_pm.light_sleep_enable); +#endif + unsigned writes=clock_writes; + assert(board.prepareRadioProfileCpuClock(true,changed)&&!changed&&clock_writes==writes); + for(uint16_t dial : {uint16_t(80),uint16_t(160)}){ + assert(board.applyCpuFrequencyMHz(dial,true)&&actual_mhz==dial); + assert(board.prepareRadioProfileCpuClock(true,changed)); + assert(changed); + assert(actual_mhz==dial); + assert(board.prepareRadioProfileCpuClock(true,changed)&&!changed); + } + assert(board.applyCpuFrequencyMHz(0,true)&&actual_mhz==fast); + assert(board.prepareRadioProfileCpuClock(true,changed)); + assert(changed==(fast!=160)); + assert(board.prepareRadioProfileCpuClock(false,changed)&&!changed); +#if CONFIG_PM_ENABLE + assert(actual_mhz==40&&applied_pm.max_freq_mhz==80); +#else + assert(actual_mhz==80); +#endif + fail_clock=true;writes=clock_writes; + assert(!board.prepareRadioProfileCpuClock(true,changed)&&!changed); + assert(clock_writes==writes+1); + writes=clock_writes; + assert(!board.prepareRadioProfileCpuClock(true,changed)&&clock_writes==writes); + now_ms+=5000;fail_clock=false; + assert(board.prepareRadioProfileCpuClock(true,changed)&&actual_mhz==fast); + assert(board.prepareRadioProfileCpuClock(true,changed)&&!changed); +} +''' + for target in ("ESP32S3","ESP32C3","ESP32C6"): + for pm in (0,1): + with self.subTest(target=target,pm=pm): + self.run_fixture("#include \n"+tests,[ + f"-DCONFIG_IDF_TARGET_{target}=1",f"-DCONFIG_PM_ENABLE={pm}"]) + + def test_indicator_keeps_existing_auto_clock_policy_with_active_profiles(self): + tests=r''' +int main(){ + board.setCompanionCpuClockPolicy(true); + board.setRadioProfileCpuClockPolicy(true); + assert(board.applyCpuFrequencyMHz(0,true)); + assert(!board.radio_profile_cpu_clock_policy); +#if CONFIG_PM_ENABLE + assert(applied_pm.min_freq_mhz==40&&applied_pm.max_freq_mhz==80); +#else + assert(actual_mhz==80); +#endif + bool changed=true; + assert(board.prepareRadioProfileCpuClock(true,changed)&&!changed); + assert(board.applyCpuFrequencyMHz(160,true)&&actual_mhz==160); +} +''' + for pm in (0,1): + self.run_fixture(tests,["-DSENSECAP_INDICATOR_LORA=1", + "-DCONFIG_IDF_TARGET_ESP32S3=1",f"-DCONFIG_PM_ENABLE={pm}"]) + + def test_retune_entry_boosts_before_preamble_and_resets_only_pair_timing(self): + source=(ROOT/"src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + entry=body(source,"mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(") + # Execute the production admission prefix through tuple validation. + # Stop before the measured hardware transaction; its SPI lifecycle has + # separate coverage. Clock application must precede even preamble math. + entry=entry[:entry.index(" const bool dual_profile =")] + entry=entry.replace("RadioLibWrapper::tuneProfile", "tuneProfile") + code=r''' +#include +namespace mesh {enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};} +struct RadioLibWrapper { + ESP32Board* _board=&board; + mesh::RadioProfiles _profiles; + bool _cw_active=false,_profile_refresh_required=true,_profile_repair_required=false; + uint8_t _active_profile=0; + uint32_t _profile_generation=0; + uint16_t _physical_preamble=32; + unsigned preamble_reads=0,retunes=0; + uint16_t expected=240; + uint16_t profilePreamble(uint8_t){++preamble_reads;assert(actual_mhz==expected);return 32;} + bool validateProfile(const mesh::RadioProfileParams&)const{return true;} + @ENTRY@ + ++retunes;return mesh::RadioParamApplyResult::APPLIED; + } +}; +int main(){ + board.setCompanionCpuClockPolicy(true); + assert(board.applyCpuFrequencyMHz(0,true)&&actual_mhz==80); + RadioLibWrapper radio; + mesh::RadioProfileConfig config;config.mode=mesh::RadioProfileMode::RxTx; + radio._profiles.setSecondary(config,false); + radio._profiles.switch_test_max_us[0]=99999; + radio._profiles.switch_test_samples[0]=4; + radio._profiles.switches=19;radio._profiles.switch_failures=3; + assert(radio.tuneProfile(1)==mesh::RadioParamApplyResult::APPLIED&&actual_mhz==240); + assert(!radio._profiles.switch_test_max_us[0]&&!radio._profiles.switch_test_samples[0]); + assert(radio._profiles.switches==19&&radio._profiles.switch_failures==3); + radio._profiles.switch_test_max_us[0]=1200;radio._profiles.switch_test_samples[0]=1; + unsigned writes=clock_writes; + assert(radio.tuneProfile(1)==mesh::RadioParamApplyResult::APPLIED&&clock_writes==writes); + assert(radio._profiles.switch_test_max_us[0]==1200&&radio._profiles.switch_test_samples[0]==1); + assert(board.applyCpuFrequencyMHz(160,true));radio.expected=160; + assert(radio.tuneProfile(1)==mesh::RadioParamApplyResult::APPLIED); + assert(!radio._profiles.switch_test_max_us[0]&&!radio._profiles.switch_test_samples[0]); + radio._profiles.switch_test_max_us[0]=1700; + assert(radio.tuneProfile(1)==mesh::RadioParamApplyResult::APPLIED); + assert(radio._profiles.switch_test_max_us[0]==1700); + // A failed boost defers before preamble math or hardware retuning. + bool changed=false;assert(board.prepareRadioProfileCpuClock(false,changed)); + assert(board.applyCpuFrequencyMHz(0,true));fail_clock=true; + const unsigned previous=radio.retunes,reads=radio.preamble_reads; + assert(radio.tuneProfile(1)==mesh::RadioParamApplyResult::BUSY); + assert(radio.retunes==previous&&radio.preamble_reads==reads); +} +'''.replace("@ENTRY@",entry) + self.run_fixture(code,["-DESP32_PLATFORM=1","-DCONFIG_IDF_TARGET_ESP32S3=1","-DCONFIG_PM_ENABLE=0"]) + + def test_loaded_clock_choice_is_seeded_before_saved_radio_and_survives_apply_failure(self): + common=(ROOT/"src/helpers/CommonCLI.cpp").read_text() + load=body(common,"void CommonCLI::loadPrefs(") + seed=load[load.index("#if defined(ESP32_PLATFORM)",load.index('mesh::hilStartupTrace("prefs_apply_begin")')):] + seed=seed[:seed.index("#endif")] + seed=seed[seed.index("\n")+1:] + seed=seed.replace("_board->","board.").replace("_prefs->","prefs.") + companion=(ROOT/"examples/companion_radio/MyMesh.cpp").read_text() + begin=body(companion,"void MyMesh::begin(bool has_display,") + companion_seed=begin[begin.index(" if (!mesh::esp32::cpuclock::validSelection("):] + companion_seed=companion_seed[:companion_seed.index("#endif")] + companion_seed=companion_seed.replace("_prefs.","prefs.") + self.assertLess(begin.index("_store->loadPrefs("),begin.index("board.setCpuFrequencyRequest(")) + self.assertLess(begin.index("board.setCpuFrequencyRequest("),begin.index("configureRadioFromPrefs();")) + self.assertLess(load.index("loadPrefsInt("),load.index("_board->setCpuFrequencyRequest(")) + self.assertLess(load.index("_board->setCpuFrequencyRequest("),load.index("_radio_profiles.stagePrimary(")) + for path,signature in (("simple_repeater/MyMesh.cpp","void MyMesh::begin(FILESYSTEM"), + ("simple_room_server/MyMesh.cpp","void MyMesh::begin(FILESYSTEM"), + ("simple_sensor/SensorMesh.cpp","void SensorMesh::begin(FILESYSTEM")): + role=body((ROOT/"examples"/path).read_text(),signature) + self.assertLess(role.index("_cli.loadPrefs("),role.index("applySavedRadioParams()")) + tests=r''' +int main(){ + board.setCompanionCpuClockPolicy(true); + prefs.cpu_freq_mhz=80;prefs.powersaving_enabled=true; + seed_request(); + assert(clock_writes==0); // Seeding must not use unconfigured peripherals/SDK. + bool changed=false; + assert(board.prepareRadioProfileCpuClock(true,changed)&&actual_mhz==80); + assert(board.requested_cpu_clock_selection==80&&board.requested_cpu_clock_powersaving); + prefs.cpu_freq_mhz=160;prefs.powersaving_enabled=false; + seed_request(); + fail_clock=true;const unsigned writes=clock_writes; + assert(!board.prepareRadioProfileCpuClock(true,changed)); + assert(clock_writes==writes+1); + assert(board.requested_cpu_clock_selection==160&&!board.requested_cpu_clock_powersaving); + assert(!board.prepareRadioProfileCpuClock(true,changed)&&clock_writes==writes+1); + now_ms+=5000;fail_clock=false; + assert(board.prepareRadioProfileCpuClock(true,changed)&&actual_mhz==160); + // A direct CLI/role apply which fails also retains its requested clock; + // the retune gate must not retry the last successful160MHz selection. + fail_clock=true;assert(!board.applyCpuFrequencyMHz(80,true));fail_clock=false; + assert(board.prepareRadioProfileCpuClock(true,changed)&&actual_mhz==80); + assert(board.requested_cpu_clock_selection==80&&board.requested_cpu_clock_powersaving); +} +''' + for role,seed_code in (("infrastructure",seed),("companion",companion_seed)): + for pm in (0,1): + with self.subTest(role=role,pm=pm): + self.run_fixture("void seed_request(){\n"+seed_code+"\n}\n"+tests,[ + "-DESP32_PLATFORM=1","-DCONFIG_IDF_TARGET_ESP32S3=1",f"-DCONFIG_PM_ENABLE={pm}"]) + + def test_rc32_post_boot_override_is_repaired_before_retune_with_cached_role_policy(self): + source=(ROOT/"src/helpers/radiolib/RadioLibWrappers.cpp").read_text() + entry=body(source,"mesh::RadioParamApplyResult RadioLibWrapper::tuneProfile(") + entry=entry[:entry.index(" const bool dual_profile =")] + entry=entry.replace("RadioLibWrapper::tuneProfile", "tuneProfile") + for role in ("simple_repeater","simple_room_server","simple_sensor"): + main=(ROOT/"examples"/role/"main.cpp").read_text() + setup=body(main,"void setup()") + # The role must not cache the loaded request before board-specific + # onBootComplete can select its post-startup clock (RC32:160MHz). + self.assertNotIn("serviceCpuFrequencyPolicy(",setup[:setup.index("board.onBootComplete();")]) + start=main.index(" board.servicePowerTestControls();",main.index("void loop()")) + service=main[start:main.index("#endif",start)] + code=r''' +#include +namespace mesh {enum class RadioParamApplyResult {APPLIED,BUSY,FAILED};} +void role_service(){@SERVICE@} +struct RadioLibWrapper { + ESP32Board* _board=&board; + mesh::RadioProfiles _profiles; + bool _cw_active=false,_profile_refresh_required=true,_profile_repair_required=false; + uint8_t _active_profile=0;uint32_t _profile_generation=0; + uint16_t _physical_preamble=32,expected=240; + uint16_t profilePreamble(uint8_t){assert(actual_mhz==expected);return 32;} + bool validateProfile(const mesh::RadioProfileParams&)const{return true;} + @ENTRY@ + return mesh::RadioParamApplyResult::APPLIED; + } +}; +int main(){ + RadioLibWrapper radio;mesh::RadioProfileConfig config;config.mode=mesh::RadioProfileMode::RxTx; + radio._profiles.setSecondary(config,false);the_mesh.dual=true; + // Loaded profiles can apply before onBootComplete. A fixed requested clock + // must be respected both there and after the board's post-boot override. + for(uint16_t selected : {uint16_t(0),uint16_t(80),uint16_t(160),uint16_t(240)}){ + prefs.cpu_freq_mhz=selected;prefs.powersaving_enabled=true; + board.setCpuFrequencyRequest(selected,true); + radio.expected=selected?selected:240; + assert(radio.tuneProfile(0)==mesh::RadioParamApplyResult::APPLIED); + role_service();assert(actual_mhz==radio.expected); + actual_mhz=160; // RC32::onBootComplete hardware change, outside role cache. + const unsigned writes=clock_writes; + role_service();assert(clock_writes==writes); // Same cached role request. + assert(radio.tuneProfile(1)==mesh::RadioParamApplyResult::APPLIED); + assert(actual_mhz==radio.expected); + assert(clock_writes==writes+(radio.expected!=160)); + } +} +'''.replace("@SERVICE@",service).replace("@ENTRY@",entry) + self.run_fixture("#include \n"+code,["-DESP32_PLATFORM=1", + "-DCONFIG_IDF_TARGET_ESP32S3=1","-DCONFIG_PM_ENABLE=0","-DESP32_POST_BOOT_CPU_FREQ=160"]) def test_rc32_post_boot_nominal_survives_shared_policy_and_explicit_selection(self): main=(ROOT/"examples/companion_radio/main.cpp").read_text() diff --git a/test/test_radio_profile_scan.py b/test/test_radio_profile_scan.py index af796f1b3..b76badd66 100644 --- a/test/test_radio_profile_scan.py +++ b/test/test_radio_profile_scan.py @@ -31,7 +31,15 @@ uint32_t profileElapsedUs(uint32_t since, uint32_t now, bool) { return now - sin uint32_t millis() { return (uint32_t)(elapsed_us / 1000); } struct Chip { bool standbyXOSC=false; int standby() { return 0; } }; using CustomSX1262 = Chip; +#if defined(ESP32_PLATFORM) +struct Board { + bool prepareRadioProfileCpuClock(bool, bool& changed) { changed=false;return true; } +}; +#endif struct RadioLibWrapper { +#if defined(ESP32_PLATFORM) + Board board; Board* _board=&board; +#endif uint8_t state = STATE_RX; RadioInterruptEvents events; bool hasPendingRadioInterrupt() const { return events.pending(); }