diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index a5e5dc02..359c01a6 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -485,6 +485,8 @@ jobs: python3 -B test/test_webconfig_secret_masking.py 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 diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 4b256a87..36462187 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -2245,6 +2245,8 @@ void halt() { #ifdef WITH_WEBCONFIG if (the_mesh.isWebConfigActiveOrStopping()) return; #endif + // Retain the pending reload until the operator's OTA/setup AP closes. + if (!mesh::wifi::stationMutationAllowed()) return; companion_wifi_credential_reload_pending = false; companion_wifi_credential_reload_at = 0; @@ -2268,6 +2270,7 @@ void halt() { static void startCompanionWiFi() { if (mesh::wireless::control().blocked(mesh::wireless::WiFi)) return; if (companion_wifi_active) return; + if (!mesh::wifi::stationMutationAllowed()) return; board.setInhibitSleep(true); mesh::wifi::setStationAutoReconnect(true); @@ -3803,6 +3806,20 @@ void loop() { ota_console_loop(); // service the local text console (port 5002) #endif const unsigned long wifi_now = millis(); + // The owner of a recovery setup AP may deliberately retry its saved SSID + // in AP+STA mode. Other APs, including browser OTA, must remain untouched by + // this background station maintenance and its five-minute reconnect timer. + const bool setup_station_handoff = +#ifdef COMPANION_RADIO_FULL + !board.isOTAUpdateRunning() && +#endif +#ifdef WITH_WEBCONFIG + the_mesh.isWebConfigSetupActive() + && (wifi_setup_recovery_mode || the_mesh.isWebConfigWiFiRecoveryActive()); +#else + wifiSetupPortal().isActive(); +#endif + if (mesh::wifi::stationMutationAllowed(setup_station_handoff)) { const bool station_channel_ok = mesh::wifi::enforceStationChannel(); if (station_channel_ok && WiFi.status() == WL_CONNECTED) { wifi_reconnect_tracker.noteConnected(); @@ -3896,6 +3913,7 @@ void loop() { mesh::wifi::beginStation( configured_wifi_ssid, configured_wifi_password); } + } #ifdef WITH_MQTT_BRIDGE the_mesh.serviceMQTT(configured_wifi_ssid, configured_wifi_password); serviceCompanionWiFiNtp(); diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 74707076..e00270af 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -153,7 +153,12 @@ const char* MQTTBridge::repeatStatus() const { } #ifdef ESP_PLATFORM +static bool mqttStationWiFiMutationAllowed() { + return mesh::wifi::stationMutationAllowed(); +} + void MQTTBridge::beginWiFiStation() { + if (!mqttStationWiFiMutationAllowed()) return; mesh::wifi::beginStation(_wifi_ssid, _wifi_password); } @@ -1408,11 +1413,19 @@ void MQTTBridge::mqttTask(void* parameter) { vTaskDelete(nullptr); } -void MQTTBridge::initializeWiFiInTask() { - MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); +bool MQTTBridge::initializeWiFiInTask() { + // OTA and setup APs own the shared driver. Starting/restarting MQTT must + // wait for their teardown instead of changing AP+STA to STA and dropping + // the operator's connection. Consult the SDK even if Arduino reports OFF. + if (!mqttStationWiFiMutationAllowed()) return false; - // Initialize WiFi - WiFi.mode(WIFI_STA); + // Use Arduino's additive STA API instead of deliberately removing AP. + // Its mode snapshot is not atomic; recheck before reconnect/event/NTP work. + if (!WiFi.enableSTA(true)) return false; + wifi_mode_t mode = WIFI_MODE_NULL; + if (esp_wifi_get_mode(&mode) != ESP_OK || (mode & WIFI_MODE_AP)) return false; + + MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task..."); // A primary ESP-NOW radio must reconnect through the fixed-channel policy; // other ESP32 targets retain the driver's normal automatic reconnect. @@ -1480,6 +1493,7 @@ void MQTTBridge::initializeWiFiInTask() { // before NTP sync just wastes heap on TLS handshakes that will be rejected. MQTT_DEBUG_PRINTLN("WiFi initialization started in task"); + return true; } // --------------------------------------------------------------------------- @@ -1503,8 +1517,11 @@ bool MQTTBridge::waitUnlessStopping(uint32_t delay_ms) { // mqttTaskLoop() - main loop running on Core 0 // --------------------------------------------------------------------------- void MQTTBridge::mqttTaskLoop() { - // Initialize WiFi first - initializeWiFiInTask(); + // An active OTA/setup AP can outlive MQTT's start request. Stay stoppable + // while waiting, and leave slot/TLS work until station initialization ran. + while (!_stop_requested && !initializeWiFiInTask()) { + if (!waitUnlessStopping(100)) break; + } // Wait a bit for WiFi to start connecting waitUnlessStopping(1000); @@ -3069,6 +3086,11 @@ void MQTTBridge::checkConfigurationMismatch() { } bool MQTTBridge::handleWiFiConnection(unsigned long now) { +#ifdef ESP_PLATFORM + // An AP can start after this worker initialized. Defer channel enforcement, + // reconnect/disconnect and radio power changes until that owner releases it. + if (!mqttStationWiFiMutationAllowed()) return false; +#endif wl_status_t current_wifi_status = WiFi.status(); #ifdef ESP_PLATFORM if (current_wifi_status == WL_CONNECTED diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index d79866e1..0ff5a9d2 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -517,7 +517,7 @@ private: static void mqttTask(void* parameter); void mqttTaskLoop(); // Main loop for MQTT task bool waitUnlessStopping(uint32_t delay_ms); - void initializeWiFiInTask(); // WiFi initialization moved to task + bool initializeWiFiInTask(); // False while an AP owns the shared driver. #endif bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target, const uint8_t* raw_data = nullptr, int raw_len = 0, diff --git a/src/helpers/esp32/WiFiRadioPolicy.h b/src/helpers/esp32/WiFiRadioPolicy.h index 8a51a90a..18c0f26f 100644 --- a/src/helpers/esp32/WiFiRadioPolicy.h +++ b/src/helpers/esp32/WiFiRadioPolicy.h @@ -140,6 +140,15 @@ inline esp_err_t checkLongRangeRadioStart() { return (mode & WIFI_MODE_AP) ? ESP_ERR_INVALID_STATE : ESP_OK; } +// Background station owners must defer while an OTA/setup AP owns WiFi. +// Only the setup owner's deliberate AP+STA credential handoff may opt in. +inline bool stationMutationAllowed(bool setup_handoff = false) { + wifi_mode_t mode = WIFI_MODE_NULL; + const esp_err_t result = esp_wifi_get_mode(&mode); + return result == ESP_ERR_WIFI_NOT_INIT + || (result == ESP_OK && (!(mode & WIFI_MODE_AP) || setup_handoff)); +} + inline esp_err_t applyAccessPointProtocolMask() { return esp_wifi_set_protocol(WIFI_IF_AP, kAccessPointProtocolMask); } diff --git a/test/fixtures/companion_ap_ownership.cpp b/test/fixtures/companion_ap_ownership.cpp new file mode 100644 index 00000000..7472808c --- /dev/null +++ b/test/fixtures/companion_ap_ownership.cpp @@ -0,0 +1,133 @@ +#include +#include +#include +#include +#include "helpers/WiFiReconnectPolicy.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; +constexpr int WIFI_OFF=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3,WIFI_MODE_NULL=0,WIFI_MODE_AP=2; +constexpr int WL_CONNECTED=3,WL_DISCONNECTED=0,WIFI_SETUP_FALLBACK_MS=120000; +constexpr const char* COMPANION_WIFI_SETUP_AP="mesh-test"; +int sdk_mode=WIFI_STA,query_error=0,mode_writes=0,disconnects=0; +int joins=0,channel_checks=0,reconnect_writes=0,credential_loads=0; +int power_writes=0,socket_starts=0,console_starts=0; +uint32_t now=300001; +uint32_t millis(){return now;} +int esp_wifi_get_mode(wifi_mode_t* mode){if(query_error)return query_error;*mode=sdk_mode;return ESP_OK;} +struct WiFiClass{ + int state=WL_DISCONNECTED; + bool mode(int value){++mode_writes;sdk_mode=value;return true;} + int status(){return state;} + void setAutoReconnect(bool){++reconnect_writes;} + void disconnect(bool=false,bool=false){++disconnects;state=WL_DISCONNECTED;} +}WiFi; +namespace mesh{namespace wifi{ +@STATION_GUARD@ + void setStationAutoReconnect(bool){++reconnect_writes;} + bool enforceStationChannel(){++channel_checks;return true;} + void applyProtocolMask(int){} + void beginStation(const char*,const char*){++joins;} +}} +namespace mesh{namespace wireless{ + constexpr int WiFi=1; + struct Control{bool blocked(int){return false;}}; + Control& control(){static Control instance;return instance;} +}} +constexpr int WIFI_IF_STA=0; +struct Board{ + bool ota=true,inhibit_sleep=false; + bool isOTAUpdateRunning(){return ota;} + void setInhibitSleep(bool enabled){inhibit_sleep=enabled;} +}board; +struct Mesh{ + bool setup=false,recovery=false,active=false; + bool isWebConfigSetupActive(){return setup;} + bool isWebConfigWiFiRecoveryActive(){return recovery;} + bool isWebConfigActiveOrStopping(){return active;} + bool startWebConfig(bool,char*){return false;} + void stopWebConfig(){setup=false;} +}the_mesh; +struct WebConfigServer{static bool loadEnabled(bool){return false;}}; +struct Portal{ + bool active=false;bool isActive(){return active;} + templatebool begin(const char*,T,void*){return false;} + void configureRecovery(const char*,const char*,uint32_t,uint32_t){} +}portal; +Portal& wifiSetupPortal(){return portal;} +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_credential_reload_pending=true; +unsigned long companion_wifi_credential_reload_at=1; +bool wifi_setup_recovery_mode=false,wifi_setup_attempted=true; +unsigned long last_wifi_setup_attempt=1; +WiFiReconnectPolicy::Tracker wifi_reconnect_tracker; +void loadCompanionWiFiCredentials(){++credential_loads;} +void resetCompanionWiFiRecoveryState(){wifi_reconnect_tracker=WiFiReconnectPolicy::Tracker();} +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;} +@RELOAD@ +@START_WIFI@ +void serviceStation(){ +@STATION_LOOP@ +} +void assert_untouched(){ + assert(sdk_mode&WIFI_AP);assert(mode_writes==0&&disconnects==0&&joins==0); + assert(channel_checks==0&&reconnect_writes==0); +} +int main(int argc,char** argv){ + assert(argc==2);const std::string scenario=argv[1]; + wifi_reconnect_tracker.noteDisconnected(1);wifi_reconnect_tracker.noteAttempt(1); + if(scenario=="retry"){ + for(int mode:{WIFI_AP,WIFI_AP_STA}){ + sdk_mode=mode;serviceStation();assert_untouched(); + assert(wifi_reconnect_tracker.retryDue(now)); + } + query_error=ESP_FAIL;serviceStation();assert_untouched();query_error=0; + sdk_mode=WIFI_STA;board.ota=false;serviceStation(); + assert(sdk_mode==WIFI_STA&&mode_writes==1&&disconnects==1&&joins==1); + assert(!wifi_reconnect_tracker.retryDue(now)); + }else if(scenario=="reload"){ + for(int mode:{WIFI_AP,WIFI_AP_STA}){ + sdk_mode=mode;serviceCompanionWiFiCredentialReload();assert_untouched(); + assert(companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==1); + assert(credential_loads==0); + } + query_error=ESP_FAIL;serviceCompanionWiFiCredentialReload();assert_untouched();query_error=0; + sdk_mode=WIFI_STA;board.ota=false;serviceCompanionWiFiCredentialReload(); + assert(!companion_wifi_credential_reload_pending&&companion_wifi_credential_reload_at==0); + assert(credential_loads==1&&mode_writes==1&&disconnects==1&&joins==1); + }else if(scenario=="setup_handoff"){ +#ifdef WITH_WEBCONFIG + sdk_mode=WIFI_AP_STA;board.ota=false;the_mesh.setup=the_mesh.recovery=true; + serviceStation();assert(sdk_mode==WIFI_AP_STA&&mode_writes==0&&joins==1&&disconnects==1); + // Even a stale WebConfig setup flag must not authorize changes to an OTA AP. + board.ota=true;mode_writes=joins=disconnects=channel_checks=reconnect_writes=0; + wifi_reconnect_tracker=WiFiReconnectPolicy::Tracker(); + wifi_reconnect_tracker.noteDisconnected(1);wifi_reconnect_tracker.noteAttempt(1); + serviceStation();assert_untouched(); +#else + sdk_mode=WIFI_AP_STA;board.ota=false;portal.active=true;serviceStation(); + assert(sdk_mode==WIFI_AP_STA&&mode_writes==0&&joins==0&&disconnects==0); +#endif + }else if(scenario=="unrelated_setup"){ + sdk_mode=WIFI_AP_STA;board.ota=false;the_mesh.setup=true;the_mesh.recovery=false; + serviceStation();assert_untouched(); + }else if(scenario=="start_wifi"){ + companion_wifi_active=false; + for(int mode:{WIFI_AP,WIFI_AP_STA}){ + sdk_mode=mode;startCompanionWiFi();assert_untouched(); + assert(!companion_wifi_active&&companion_wifi_requested&&companion_wifi_services_stopped); + assert(socket_starts==0&&console_starts==0&&power_writes==0&&!board.inhibit_sleep); + } + query_error=ESP_FAIL;startCompanionWiFi();assert_untouched();query_error=0; + sdk_mode=WIFI_STA;board.ota=false;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); + startCompanionWiFi();assert(mode_writes==1&&socket_starts==1); + }else assert(false); +} diff --git a/test/fixtures/mqtt_ap_ownership.cpp b/test/fixtures/mqtt_ap_ownership.cpp new file mode 100644 index 00000000..bb4aed59 --- /dev/null +++ b/test/fixtures/mqtt_ap_ownership.cpp @@ -0,0 +1,175 @@ +#include +#include +#include +#include +#include +#include +#include "helpers/AlertFaultPolicy.h" +#include "helpers/MQTTConnectionPolicy.h" +#include "helpers/WiFiPowerSave.h" +#define MQTT_DEBUG_PRINTLN(...) ((void)0) +#define pdMS_TO_TICKS(value) (value) +using TickType_t=uint32_t; +using wifi_mode_t=int; +using esp_err_t=int; +using wl_status_t=int; +using wifi_ps_type_t=int; +constexpr int ESP_OK=0,ESP_FAIL=-1,ESP_ERR_WIFI_NOT_INIT=-2; +constexpr int WIFI_MODE_NULL=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3,WIFI_MODE_AP=2; +constexpr int WL_CONNECTED=3,WL_DISCONNECTED=0,RUNTIME_MQTT_SLOTS=2; +constexpr int WIFI_PS_NONE=0,WIFI_PS_MIN_MODEM=1,WIFI_PS_MAX_MODEM=2,WIFI_POWER_11dBm=44; +int sdk_mode=WIFI_STA,mode_reads=0,enable_calls=0,mode_writes=0; +int reconnect_writes=0,autoconnect_writes=0,event_registers=0,station_begins=0; +int station_disconnects=0,channel_checks=0,power_writes=0,tx_power_writes=0; +int read_failure_at=0; +bool driver_initialized=true,facade_started=true,enable_failure=false,race_ap=false; +uint32_t now_ms=0;std::function tick; +int esp_wifi_get_mode(wifi_mode_t* mode){ + ++mode_reads;if(mode_reads==read_failure_at)return ESP_FAIL; + if(!driver_initialized)return ESP_ERR_WIFI_NOT_INIT; + *mode=sdk_mode;return ESP_OK; +} +uint32_t millis(){return now_ms;} +TickType_t xTaskGetTickCount(){return now_ms;} +void vTaskDelay(TickType_t value){now_ms+=value;if(tick)tick();} +int esp_wifi_set_ps(int){++power_writes;return ESP_OK;} +using WiFiEvent_t=int; +constexpr int ARDUINO_EVENT_WIFI_STA_GOT_IP=1,ARDUINO_EVENT_WIFI_STA_DISCONNECTED=2; +struct WiFiEventInfo_t{ + struct{struct{struct{uint32_t addr=1;}ip;}ip_info;}got_ip; + struct{uint8_t reason=1;}wifi_sta_disconnected; +}; +struct WiFiClass{ + int state=WL_CONNECTED; + // Match Arduino-ESP32 2.0.17 enableSTA's getMode snapshot plus mode call. + // This is not an atomic SDK OR. An AP observed before the snapshot survives; + // stable AP deferral must use the SDK guard even with a stale OFF facade. + int getMode(){if(race_ap)sdk_mode|=WIFI_AP;return facade_started?sdk_mode:WIFI_MODE_NULL;} + bool enableSTA(bool enable){ + assert(enable);++enable_calls;if(enable_failure)return false; + const wifi_mode_t currentMode=getMode(); + const bool isEnabled=(currentMode&WIFI_STA)!=0; + if(isEnabled!=enable)return mode(enable?(currentMode|WIFI_STA):(currentMode&~WIFI_STA)); + return true; + } + bool mode(int value){++mode_writes;driver_initialized=facade_started=true;sdk_mode=value;return true;} + void setAutoConnect(bool){++autoconnect_writes;} + templatevoid onEvent(T){++event_registers;} + int status(){return state;} + void disconnect(){++station_disconnects;state=WL_DISCONNECTED;} + void setTxPower(int){++tx_power_writes;} +}WiFi; +namespace mesh{namespace wifi{ + @STATION_GUARD@ + constexpr bool kPrimaryEspNowRadio=false; + void setStationAutoReconnect(bool){++reconnect_writes;} + void beginStation(const char*,const char*){++station_begins;} + bool enforceStationChannel(){++channel_checks;return true;} +}} +int s_wifi_disconnect_reason=0;uint32_t s_wifi_disconnect_time=0; +unsigned long s_wifi_connected_at=0; +struct MQTTBridge{ + bool _wifi_event_registered=false,_manage_wifi=true,_ntp_sync_pending=false; + int _wifi_reconnect_backoff_attempt=0; + std::atomic_ntp_synced{true},_stop_requested{false},_stop_acked{false}; + struct{void noteGotIp(){}}_ntp_reconnect_latch; + AlertFaultPolicy::OutageSnapshot outage{true,1,0}; + AlertFaultPolicy::OutageSnapshot wifiOutage(){return outage;} + void setWifiOutage(AlertFaultPolicy::OutageSnapshot value){outage=value;} + char _wifi_ssid[8]="mesh",_wifi_password[8]="secret"; + void beginWiFiStation(); + bool _wifi_status_initialized=true; + int _last_wifi_status=WL_DISCONNECTED; + unsigned long _last_wifi_check=0,_last_wifi_reconnect_attempt=0; + uint8_t _wifi_power_save=1; + struct{bool canonical_wifi=true;}_node_info; + struct{uint8_t wifi_power_save=1;}prefs,*_obs=&prefs; + struct Client{void disconnect(){}}; + struct Slot{Client* client=nullptr;bool connected=false;}_slots[RUNTIME_MQTT_SLOTS]; + bool _slot_attempt_pending[RUNTIME_MQTT_SLOTS]={}; + std::vector cleanup; + void cancelNtpRefresh(){cleanup.push_back("ntp");} + void teardownSlot(int index){cleanup.push_back("slot"+std::to_string(index));} + void destroySlotClients(){cleanup.push_back("clients");} + int slot_work=0; + bool initializeWiFiInTask();bool waitUnlessStopping(uint32_t delay_ms); + bool handleWiFiConnection(unsigned long now); + void mqttTaskLoop(){ +@TASK_START@ +@TASK_STOP@ + ++slot_work; + } +}; +@INITIALIZE@ +@WAIT@ +void assert_ap_untouched(){ + assert(sdk_mode&WIFI_AP); + assert(enable_calls==0&&mode_writes==0&&reconnect_writes==0&&autoconnect_writes==0); + assert(event_registers==0&&station_begins==0); +} +int main(int argc,char** argv){ + assert(argc==2);const std::string scenario=argv[1];MQTTBridge bridge; + if(scenario=="ap"){ + for(int mode:{WIFI_AP,WIFI_AP_STA})for(bool manage:{false,true}){ + sdk_mode=mode;bridge._manage_wifi=manage;facade_started=false; + for(int attempt=0;attempt<3;++attempt)assert(!bridge.initializeWiFiInTask()); + assert_ap_untouched();assert(!bridge._wifi_event_registered&&!bridge._ntp_sync_pending); + } + }else if(scenario=="stop_waiting"){ + sdk_mode=WIFI_AP_STA; + tick=[&](){if(now_ms>=65)bridge._stop_requested=true;}; + bridge.mqttTaskLoop();assert_ap_untouched(); + assert(now_ms==80&&bridge._stop_acked&&bridge.slot_work==0); + assert((bridge.cleanup==std::vector{"ntp","slot0","slot1","clients"})); + }else if(scenario=="stop_before_start"){ + sdk_mode=WIFI_AP;bridge._stop_requested=true; + bridge.mqttTaskLoop();assert_ap_untouched(); + assert(now_ms==0&&mode_reads==0&&bridge._stop_acked&&bridge.slot_work==0); + }else if(scenario=="handoff"||scenario=="companion_handoff"){ + sdk_mode=WIFI_AP_STA;WiFi.state=WL_DISCONNECTED; + bridge._manage_wifi=scenario!="companion_handoff"; + tick=[&](){if(now_ms<100){assert_ap_untouched();assert(!bridge._wifi_event_registered);} + else sdk_mode=WIFI_STA;}; + bridge.mqttTaskLoop(); + assert(now_ms==1100&&sdk_mode==WIFI_STA&&bridge.slot_work==1&&!bridge._stop_acked); + assert(enable_calls==1&&event_registers==1&&reconnect_writes==1); + assert(station_begins==(bridge._manage_wifi?1:0)); + }else if(scenario=="cold_and_restart"){ + driver_initialized=false;sdk_mode=WIFI_MODE_NULL;bridge._ntp_synced=false; + assert(bridge.initializeWiFiInTask()); + assert(driver_initialized&&sdk_mode==WIFI_STA&&event_registers==1&&bridge._ntp_sync_pending); + sdk_mode=WIFI_AP_STA;const int writes=mode_writes; + assert(!bridge.initializeWiFiInTask()&&mode_writes==writes&&event_registers==1); + sdk_mode=WIFI_STA;assert(bridge.initializeWiFiInTask());assert(event_registers==1); + }else if(scenario=="failures"){ + read_failure_at=1;assert(!bridge.initializeWiFiInTask());assert(enable_calls==0); + read_failure_at=0;enable_failure=true;assert(!bridge.initializeWiFiInTask()); + assert(mode_writes==0&&event_registers==0&&reconnect_writes==0); + enable_failure=false;read_failure_at=mode_reads+2; + assert(!bridge.initializeWiFiInTask());assert(event_registers==0&&reconnect_writes==0); + read_failure_at=0;assert(bridge.initializeWiFiInTask());assert(event_registers==1); + }else if(scenario=="race"){ + race_ap=true;assert(!bridge.initializeWiFiInTask()); + assert(sdk_mode==WIFI_AP_STA&&event_registers==0&&reconnect_writes==0&&station_begins==0); + race_ap=false;sdk_mode=WIFI_STA;assert(bridge.initializeWiFiInTask());assert(event_registers==1); + }else if(scenario=="recovery"||scenario=="companion_recovery"){ + bridge._manage_wifi=scenario!="companion_recovery"; + for(int mode:{WIFI_AP,WIFI_AP_STA}){ + sdk_mode=mode;WiFi.state=WL_DISCONNECTED; + bridge.beginWiFiStation();assert(station_begins==0); + assert(!bridge.handleWiFiConnection(20000)); + assert(station_disconnects==0&&station_begins==0&&bridge._last_wifi_reconnect_attempt==0); + assert(bridge._wifi_reconnect_backoff_attempt==0&&bridge._last_wifi_check==0); + WiFi.state=WL_CONNECTED;assert(!bridge.handleWiFiConnection(40000)); + assert(channel_checks==0&&power_writes==0&&tx_power_writes==0); + } + // Unknown SDK mode also defers, without consuming the due reconnect rung. + sdk_mode=WIFI_STA;WiFi.state=WL_DISCONNECTED;read_failure_at=mode_reads+1; + assert(!bridge.handleWiFiConnection(20000)&&station_disconnects==0); + read_failure_at=0;assert(!bridge.handleWiFiConnection(20000)); + assert(station_begins==(bridge._manage_wifi?1:0)); + assert(station_disconnects==(bridge._manage_wifi?1:0)); + WiFi.state=WL_CONNECTED;assert(bridge.handleWiFiConnection(40000)); + assert(power_writes==1&&tx_power_writes==1&&channel_checks==1); + }else assert(false); +} diff --git a/test/fixtures/mqtt_canonical_wifi.cpp b/test/fixtures/mqtt_canonical_wifi.cpp index 30236283..1a608596 100644 --- a/test/fixtures/mqtt_canonical_wifi.cpp +++ b/test/fixtures/mqtt_canonical_wifi.cpp @@ -19,6 +19,11 @@ uint8_t effectiveWiFiPowerSave(uint8_t value) { return value; } enum { WIFI_OFF, WIFI_STA, WIFI_PS_NONE, WIFI_PS_MAX_MODEM, WIFI_PS_MIN_MODEM }; struct { int getMode() { return WIFI_STA; } } WiFi; using wifi_ps_type_t = int; +using wifi_mode_t = int; +using esp_err_t = int; +constexpr int ESP_OK = 0, ESP_ERR_WIFI_NOT_INIT = -1; +constexpr int WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_AP = 2; +int esp_wifi_get_mode(wifi_mode_t* mode) { *mode = WIFI_STA; return ESP_OK; } int esp_wifi_set_ps(int) { return 0; } size_t strlcpy(char* to, const char* from, size_t size) { diff --git a/test/test_companion_ap_ownership.py b/test/test_companion_ap_ownership.py new file mode 100644 index 00000000..6f26e94a --- /dev/null +++ b/test/test_companion_ap_ownership.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Execute Companion's actual pending reload and five-minute station recovery.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +class FixtureFailure(AssertionError): + pass + + +class CompanionAccessPointOwnershipTest(unittest.TestCase): + def fixture(self): + main = (ROOT / "examples/companion_radio/main.cpp").read_text(encoding="ascii") + reload = method(main, " static void serviceCompanionWiFiCredentialReload(") + start_wifi = method(main, " static void startCompanionWiFi(") + start = main.index(" const unsigned long wifi_now = millis();") + end = main.index("#ifdef WITH_MQTT_BRIDGE", start) + station_loop = main[start:end] + policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text(encoding="ascii") + 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("@START_WIFI@", start_wifi).replace("@STATION_LOOP@", station_loop)) + + def run_fixture(self, scenario, source=None, webconfig=True): + compiler = os.environ.get("CXX") or shutil.which("g++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="companion-ap-ownership-") as directory: + path = Path(directory) + (path / "test.cpp").write_text(source or self.fixture(), encoding="ascii") + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-DCOMPANION_RADIO_FULL=1", "-DENABLE_OTA=1", + *(["-DWITH_WEBCONFIG=1"] if webconfig else []), + "-I", str(ROOT / "src"), str(path / "test.cpp"), "-o", str(path / "test")], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(path / "test"), scenario], + capture_output=True, text=True, timeout=15) + if result.returncode: + raise FixtureFailure(result.stdout + result.stderr) + + def test_due_saved_ssid_retry_waits_for_sdk_ap_teardown(self): + self.run_fixture("retry") + self.run_fixture("retry", webconfig=False) + + def test_pending_credential_reload_is_retained_until_ap_teardown(self): + self.run_fixture("reload") + self.run_fixture("reload", webconfig=False) + + def test_starting_wifi_services_defers_without_consuming_requested_intent(self): + self.run_fixture("start_wifi") + self.run_fixture("start_wifi", webconfig=False) + + def test_deliberate_recovery_setup_handoff_remains_available(self): + self.run_fixture("setup_handoff") + self.run_fixture("setup_handoff", webconfig=False) + self.run_fixture("unrelated_setup") + + def test_reverted_retry_guard_removes_the_ota_ap(self): + source = self.fixture().replace( + "if (mesh::wifi::stationMutationAllowed(setup_station_handoff)) {", "if (true) {") + with self.assertRaises(FixtureFailure): + self.run_fixture("retry", source) + + def test_reverted_reload_guard_consumes_pending_intent_and_removes_ap(self): + source = self.fixture().replace( + " if (!mesh::wifi::stationMutationAllowed()) return;", "") + with self.assertRaises(FixtureFailure): + self.run_fixture("reload", source) + + def test_ota_cannot_inherit_setup_handoff_permission(self): + source = self.fixture().replace(" !board.isOTAUpdateRunning() &&", "") + with self.assertRaises(FixtureFailure): + self.run_fixture("setup_handoff", source) + + def test_reverted_wifi_start_guard_removes_existing_ota_ap(self): + source = self.fixture() + start = source.index(" static void startCompanionWiFi(") + before, startup = source[:start], source[start:] + reverted = before + startup.replace( + " if (!mesh::wifi::stationMutationAllowed()) return;", "", 1) + with self.assertRaises(FixtureFailure): + self.run_fixture("start_wifi", reverted) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_companion_wifi_ntp.py b/test/test_companion_wifi_ntp.py index ada1b6a8..dc298f67 100644 --- a/test/test_companion_wifi_ntp.py +++ b/test/test_companion_wifi_ntp.py @@ -104,7 +104,7 @@ class CompanionWiFiNtpIntegrationTests(unittest.TestCase): def test_mqtt_boot_paths_and_fresh_success_begin_daily_cadence(self): mqtt = source(MQTT) initialize = mqtt[ - mqtt.index("void MQTTBridge::initializeWiFiInTask()") : + mqtt.index("bool MQTTBridge::initializeWiFiInTask()") : mqtt.index("void MQTTBridge::mqttTaskLoop()") ] task_loop = mqtt[ diff --git a/test/test_mqtt_ap_ownership.py b/test/test_mqtt_ap_ownership.py new file mode 100644 index 00000000..319876d6 --- /dev/null +++ b/test/test_mqtt_ap_ownership.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Execute the actual MQTT initializer, startup wait and stop acknowledgement. + +The SDK/facade model covers AP ownership without network, hardware or PIO. +""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] + + +class FixtureFailure(AssertionError): + pass + + +class MQTTAccessPointOwnershipTest(unittest.TestCase): + def fixture(self): + source = (ROOT / "src/helpers/bridges/MQTTBridge.cpp").read_text(encoding="ascii") + initialize = "\n".join(method(source, signature) for signature in ( + "static bool mqttStationWiFiMutationAllowed(", + "void MQTTBridge::beginWiFiStation(", + "bool MQTTBridge::initializeWiFiInTask(", + "bool MQTTBridge::handleWiFiConnection(", + )) + wait = method(source, "bool MQTTBridge::waitUnlessStopping(") + task = method(source, "void MQTTBridge::mqttTaskLoop(") + startup = task[task.index("{") + 1:task.index(" // Main task loop")] + stop = method(task, " if (_stop_requested)") + fixture = (ROOT / "test/fixtures/mqtt_ap_ownership.cpp").read_text(encoding="ascii") + policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text(encoding="ascii") + guard = method(policy, "inline bool stationMutationAllowed(") + return (fixture.replace("@STATION_GUARD@", guard) + .replace("@INITIALIZE@", initialize).replace("@WAIT@", wait) + .replace("@TASK_START@", startup).replace("@TASK_STOP@", stop)) + + def run_fixture(self, scenario, source=None, *flags): + compiler = os.environ.get("CXX") or shutil.which("g++") + self.assertIsNotNone(compiler) + with tempfile.TemporaryDirectory(prefix="mqtt-ap-ownership-") as directory: + path = Path(directory) + (path / "test.cpp").write_text(source or self.fixture(), encoding="ascii") + result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", + "-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie", + "-DESP_PLATFORM=1", "-I", str(ROOT / "src"), + *flags, str(path / "test.cpp"), "-o", str(path / "test")], + capture_output=True, text=True, timeout=60) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(path / "test"), scenario], + capture_output=True, text=True, timeout=15) + if result.returncode: + raise FixtureFailure(result.stdout + result.stderr) + + def test_sdk_ap_and_apsta_defer_all_wifi_mutations_for_shared_roles(self): + # Both an infrastructure manager and Companion's external WiFi owner + # run the same production initializer; Arduino 3 removed auto-connect. + self.run_fixture("ap") + self.run_fixture("ap", None, "-DESP_ARDUINO_VERSION_MAJOR=3") + + def test_stop_while_ap_waiting_acknowledges_within_one_sleep_quantum(self): + self.run_fixture("stop_waiting") + self.run_fixture("stop_before_start") + + def test_ap_teardown_admits_station_initialization_and_slot_work(self): + self.run_fixture("handoff") + self.run_fixture("companion_handoff") + + def test_cold_start_and_repeated_restart_register_events_once(self): + self.run_fixture("cold_and_restart") + + def test_sdk_read_and_station_enable_failures_defer_and_retry(self): + self.run_fixture("failures") + + def test_ap_seen_by_additive_sta_api_is_preserved_and_deferred(self): + self.run_fixture("race") + + def test_initialized_worker_defers_recovery_and_power_changes_during_ap(self): + self.run_fixture("recovery") + self.run_fixture("companion_recovery") + + def test_reverted_ap_guard_drops_the_owned_access_point(self): + source = self.fixture() + begin = source.index(" if (!mqttStationWiFiMutationAllowed()) return false;", + source.index("bool MQTTBridge::initializeWiFiInTask(")) + end = source.index(' MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task...");', begin) + reverted = source[:begin] + " WiFi.mode(WIFI_STA);\n" + source[end:] + with self.assertRaises(FixtureFailure): + self.run_fixture("ap", reverted) + + def test_reverted_task_wait_runs_slots_before_ap_teardown(self): + source = self.fixture() + start = source.index(" while (!_stop_requested && !initializeWiFiInTask())") + end = source.index(" // Wait a bit for WiFi to start connecting", start) + reverted = source[:start] + " initializeWiFiInTask();\n" + source[end:] + with self.assertRaises(FixtureFailure): + self.run_fixture("handoff", reverted) + + def test_reverted_sta_only_change_drops_a_concurrently_started_ap(self): + source = self.fixture().replace("if (!WiFi.enableSTA(true)) return false;", + "if (race_ap) sdk_mode |= WIFI_AP;\n if (!WiFi.mode(WIFI_STA)) return false;") + with self.assertRaises(FixtureFailure): + self.run_fixture("race", source) + + def test_reverted_stop_aware_wait_delays_shutdown_acknowledgement(self): + source = self.fixture().replace(" while (!_stop_requested) {", " while (true) {") + with self.assertRaises(FixtureFailure): + self.run_fixture("stop_waiting", source) + + def test_reverted_runtime_guard_reconnects_while_ap_owns_the_driver(self): + source = self.fixture() + start = source.index("bool MQTTBridge::handleWiFiConnection(") + before, recovery = source[:start], source[start:] + reverted = before + recovery.replace( + " if (!mqttStationWiFiMutationAllowed()) return false;", "", 1) + with self.assertRaises(FixtureFailure): + self.run_fixture("recovery", reverted) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_mqtt_canonical_wifi.py b/test/test_mqtt_canonical_wifi.py index ff54b5c9..4e0fb13c 100644 --- a/test/test_mqtt_canonical_wifi.py +++ b/test/test_mqtt_canonical_wifi.py @@ -43,6 +43,7 @@ class MQTTCanonicalWiFiTest(unittest.TestCase): cli_get = extract_braced(observer[observer.index("bool CommonCLI::handleObserverGetCmd"):], " if (_callbacks->usesCanonicalWiFi())") mqtt_methods = "\n".join(extract_braced(mqtt, signature) for signature in ( + "static bool mqttStationWiFiMutationAllowed(", "bool MQTTBridge::prepareWiFiCredentials(", "void MQTTBridge::beginWiFiStation(", "static bool isWiFiConfigValid(", @@ -50,6 +51,9 @@ class MQTTCanonicalWiFiTest(unittest.TestCase): "bool MQTTBridge::isConfigValid(", "bool MQTTBridge::isReady(", )) + policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text(encoding="ascii") + mqtt_methods = ("namespace mesh { namespace wifi {\n" + extract_braced( + policy, "inline bool stationMutationAllowed(") + "\n}}\n" + mqtt_methods) begin = mqtt.index(" // Check if WiFi credentials are configured first") begin_end = mqtt.index(" // These are begin()/end()-scoped", begin) # These are the exact production early gate and the reconnect call; diff --git a/test/test_mqtt_transport_results.py b/test/test_mqtt_transport_results.py index b445ca3d..77e8048f 100644 --- a/test/test_mqtt_transport_results.py +++ b/test/test_mqtt_transport_results.py @@ -232,7 +232,7 @@ struct MQTTBridge { if (_stop_requested) ++late_work; assert(!_stop_acked); } - void initializeWiFiInTask() {} + bool initializeWiFiInTask() { return true; } bool handleWiFiConnection(unsigned long) { work(); return false; } bool syncTimeWithNTP(bool = false, bool = false) { work();