diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index 296bfe57a..c06b9c16d 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -217,7 +217,7 @@ jobs: - name: Verify transactional telemetry preferences and legacy route recovery run: python3 -B test/test_telemetry_tx_persistence.py -v - - name: Verify MQTT start results and connection-attempt accounting + - name: Verify MQTT and WiFi ownership, recovery and power policy run: | python3 -B test/test_mqtt_transport_results.py -v python3 -B test/test_mqtt_tls_security.py -v @@ -231,6 +231,8 @@ jobs: python3 -B test/test_mqtt_wifi_network_power.py -v python3 -B test/test_companion_wifi_network_power.py -v python3 -B test/test_webconfig_network_power.py -v + python3 -B test/test_companion_ap_ownership.py -v + python3 -B test/test_heltec_v4_wifi_setup_page.py -v - name: Verify DNS lease restoration and bounded diagnostics run: python3 -B test/test_network_dns.py -v @@ -616,8 +618,6 @@ jobs: python3 -B test/test_webconfig_station_ownership.py -v python3 -B test/test_mqtt_canonical_wifi.py -v python3 -B test/test_mqtt_ap_ownership.py -v - python3 -B test/test_companion_ap_ownership.py -v - python3 -B test/test_heltec_v4_wifi_setup_page.py -v python3 -B test/test_sensor_webconfig_capabilities.py -v python3 -B test/test_webconfig_ui_runtime.py python3 -B test/test_webconfig_lost_reply.py -v diff --git a/test/fixtures/companion_ap_ownership.cpp b/test/fixtures/companion_ap_ownership.cpp index 7472808c6..026928817 100644 --- a/test/fixtures/companion_ap_ownership.cpp +++ b/test/fixtures/companion_ap_ownership.cpp @@ -3,6 +3,7 @@ #include #include #include "helpers/WiFiReconnectPolicy.h" +#include "helpers/WiFiNetworkPowerPolicy.h" #define WIFI_DEBUG_PRINTLN(...) ((void)0) using wifi_mode_t=int;using esp_err_t=int; constexpr int ESP_OK=0,ESP_FAIL=-1,ESP_ERR_WIFI_NOT_INIT=-2; @@ -59,6 +60,8 @@ void saveCompanionWiFi(){} char configured_wifi_ssid[8]="mesh",configured_wifi_password[8]="secret"; bool companion_wifi_has_credentials=true,companion_wifi_requested=true,companion_wifi_active=true; bool companion_wifi_services_stopped=true; +bool companion_wifi_background_stopped=false,companion_wifi_espnow_restore_pending=true; +bool companion_wifi_webconfig_start_pending=false; bool companion_wifi_credential_reload_pending=true; unsigned long companion_wifi_credential_reload_at=1; bool wifi_setup_recovery_mode=false,wifi_setup_attempted=true; @@ -70,6 +73,10 @@ void applyCompanionWiFiPowerSave(){++power_writes;} constexpr int TCP_PORT=1234; struct Interface{void begin(int){++socket_starts;}void enable(){}}wifi_interface; void ota_console_start(){++console_starts;} +@POWER_GUARDS@ +#ifdef WITH_WEBCONFIG +@AUTO_WEBCONFIG@ +#endif @RELOAD@ @START_WIFI@ void serviceStation(){ @@ -129,5 +136,45 @@ int main(int argc,char** argv){ assert(companion_wifi_active&&!companion_wifi_services_stopped&&board.inhibit_sleep); assert(mode_writes==1&&joins==1&&socket_starts==1&&console_starts==1&&power_writes==1); startCompanionWiFi();assert(mode_writes==1&&socket_starts==1); + }else if(scenario=="suspended"){ + auto& power=mesh::wifi::networkPower(); + power.setManaged(true);power.setSuspended(true); + board.ota=false;companion_wifi_active=false;sdk_mode=WIFI_STA; + serviceCompanionWiFiCredentialReload();startCompanionWiFi(); + assert(mode_writes==0&&disconnects==0&&joins==0&&credential_loads==0); + assert(companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==1); + assert(!companion_wifi_active&&companion_wifi_requested&&companion_wifi_services_stopped); + assert(companion_wifi_espnow_restore_pending); + assert(socket_starts==0&&console_starts==0&&power_writes==0&&!board.inhibit_sleep); + assert(!companionWiFiAutomaticRecoveryAllowed()); + // A fresh foreground hold admits startup, but only after AP teardown. + power.setHold(mesh::wifi::NetworkPowerHold::CompanionClient,true); + startCompanionWiFi(); + assert(companion_wifi_active&&!companion_wifi_services_stopped&&board.inhibit_sleep); + assert(mode_writes==1&&joins==1&&socket_starts==1&&console_starts==1&&power_writes==1); + assert(!companion_wifi_espnow_restore_pending); + serviceCompanionWiFiCredentialReload(); + assert(!companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==0); + assert(credential_loads==1&&mode_writes==2&&disconnects==1&&joins==2); + power.setHold(mesh::wifi::NetworkPowerHold::CompanionClient,false); + power.setManaged(false); + assert(companionWiFiAutomaticRecoveryAllowed()&&!companionWiFiBackgroundSuspended()); + }else if(scenario=="suspended_ap"){ + auto& power=mesh::wifi::networkPower(); + power.setManaged(true);power.setSuspended(true); + companion_wifi_active=false; + for(bool ota:{false,true}){ + board.ota=ota; + power.setHold(mesh::wifi::NetworkPowerHold::WebConfig,!ota); + assert(!companionWiFiBackgroundSuspended()); + for(int mode:{WIFI_AP,WIFI_AP_STA}){ + sdk_mode=mode;serviceCompanionWiFiCredentialReload();startCompanionWiFi(); + assert_untouched(); + assert(companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==1); + assert(companion_wifi_espnow_restore_pending&&credential_loads==0); + assert(!companion_wifi_active&&companion_wifi_requested&&companion_wifi_services_stopped); + assert(socket_starts==0&&console_starts==0&&power_writes==0&&!board.inhibit_sleep); + } + } }else assert(false); } diff --git a/test/test_companion_ap_ownership.py b/test/test_companion_ap_ownership.py index 6f26e94a3..7e2fbd618 100644 --- a/test/test_companion_ap_ownership.py +++ b/test/test_companion_ap_ownership.py @@ -20,6 +20,11 @@ class CompanionAccessPointOwnershipTest(unittest.TestCase): main = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="ascii") reload = method(main, " static void serviceCompanionWiFiCredentialReload(") start_wifi = method(main, " static void startCompanionWiFi(") + power_guards = "\n".join(method(main, signature) for signature in ( + " static bool companionWiFiBackgroundSuspended(", + " static bool companionWiFiAutomaticRecoveryAllowed(", + )) + auto_webconfig = method(main, " static void startCompanionWiFiAutoWebConfig(") start = main.index(" const unsigned long wifi_now = millis();") end = main.index("#ifdef WITH_MQTT_BRIDGE", start) station_loop = main[start:end] @@ -27,6 +32,8 @@ class CompanionAccessPointOwnershipTest(unittest.TestCase): guard = method(policy, "inline bool stationMutationAllowed(") fixture = (ROOT / "test/fixtures/companion_ap_ownership.cpp").read_text(encoding="ascii") return (fixture.replace("@STATION_GUARD@", guard).replace("@RELOAD@", reload) + .replace("@POWER_GUARDS@", power_guards) + .replace("@AUTO_WEBCONFIG@", auto_webconfig) .replace("@START_WIFI@", start_wifi).replace("@STATION_LOOP@", station_loop)) def run_fixture(self, scenario, source=None, webconfig=True): @@ -64,6 +71,14 @@ class CompanionAccessPointOwnershipTest(unittest.TestCase): self.run_fixture("setup_handoff", webconfig=False) self.run_fixture("unrelated_setup") + def test_managed_suspension_retains_reload_and_wifi_start_intent(self): + self.run_fixture("suspended") + self.run_fixture("suspended", webconfig=False) + + def test_foreground_and_ota_holds_do_not_bypass_ap_ownership(self): + self.run_fixture("suspended_ap") + self.run_fixture("suspended_ap", webconfig=False) + def test_reverted_retry_guard_removes_the_ota_ap(self): source = self.fixture().replace( "if (mesh::wifi::stationMutationAllowed(setup_station_handoff)) {", "if (true) {") diff --git a/test/test_heltec_v4_wifi_setup_page.py b/test/test_heltec_v4_wifi_setup_page.py index 41ed9253a..059a06211 100644 --- a/test/test_heltec_v4_wifi_setup_page.py +++ b/test/test_heltec_v4_wifi_setup_page.py @@ -104,6 +104,7 @@ class HeltecV4WiFiSetupPageTest(unittest.TestCase): #include #include #include +#include #define UI_WIFI_SETUP_HOME_PAGE 1 #define UI_NO_DISCOVER_SCREEN 1 #define UI_NO_HIBERNATE 1 @@ -140,22 +141,37 @@ struct HomeScreen : UIScreen { }; int main() { UITask task; HomeScreen home(&task); + auto& network_power = mesh::wifi::networkPower(); + network_power.setManaged(true); + network_power.setSuspended(true); + assert(network_power.backgroundSuspended()); + assert(!network_power.consumeProbeRequest()); assert(home.handleInput(static_cast(KEY_NEXT))); assert(home._page == HomeScreen::FIRST); assert(!companion_wifi_setup_requested && !companion_wifi_setup_stop_requested); + assert(!network_power.consumeProbeRequest()); assert(home.handleInput(static_cast(KEY_PREV))); assert(home._page == HomeScreen::WIFI_SETUP); + assert(!network_power.consumeProbeRequest()); assert(home.handleInput(KEY_ENTER)); assert(companion_wifi_setup_requested && !companion_wifi_setup_stop_requested); + assert(network_power.consumeProbeRequest()); + assert(!network_power.consumeProbeRequest()); + // The UI hands off a probe; only the network owner may resume transports. + assert(network_power.backgroundSuspended()); WebConfigServer::active = true; assert(home.handleInput(KEY_ENTER)); assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested); + assert(!network_power.consumeProbeRequest()); assert(home.handleInput(static_cast(KEY_RIGHT))); assert(home._page == HomeScreen::FIRST); assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested); + assert(!network_power.consumeProbeRequest()); assert(home.handleInput(static_cast(KEY_LEFT))); assert(home._page == HomeScreen::WIFI_SETUP); + assert(!network_power.consumeProbeRequest()); requestCompanionWiFiSetupStop(); + assert(!network_power.consumeProbeRequest()); struct Backend : mesh::wireless::Backend { uint8_t available() const override { return mesh::wireless::WiFi; } uint8_t enabled() const override { return mesh::wireless::WiFi; } @@ -168,6 +184,8 @@ int main() { mesh::wireless::control().service(250); requestCompanionWiFiSetup(); assert(!companion_wifi_setup_requested && companion_wifi_setup_stop_requested); + assert(!network_power.consumeProbeRequest()); + assert(network_power.backgroundSuspended()); } ''' source = (source.replace("@REQUESTS@", requests).replace("@PAGES@", page_enum)