From 8c7d7d88398a3aed84f29edaf3668dc8476d13f7 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 7 Oct 2026 02:04:22 -0700 Subject: [PATCH] Keep ESP32 access points compatible with LR transports Protect standard access points from the shared ESP-IDF LR flag, defer incompatible raw ESP-NOW starts, and select RAM storage before LR writes. Track successful LR owners so setup and OTA access points reject a conflicting running transport before changing WiFi. Run actual production policies, transport lifecycle methods, and WebConfig teardown branches against a shared-LR SDK model with runtime negative controls; include the regression in unit CI. --- .github/workflows/run-unit-tests.yml | 1 + src/helpers/ESP32Board.cpp | 10 + src/helpers/bridges/ESPNowBridge.cpp | 11 +- src/helpers/esp32/ESPNOWRadio.cpp | 12 +- src/helpers/esp32/WebConfigServer.cpp | 10 +- src/helpers/esp32/WiFiAccessPointPolicy.h | 7 + src/helpers/esp32/WiFiRadioPolicy.h | 78 +++++- test/fixtures/espnow_wifi_handoff.cpp | 4 + test/fixtures/webconfig_ap_start.cpp | 11 + test/fixtures/webconfig_station_recovery.cpp | 2 +- test/fixtures/wifi_ap_shared_lr_sdk.h | 119 ++++++++ test/fixtures/wifi_ota_start.cpp | 10 + test/test_espnow_radio_lifecycle.py | 18 +- test/test_webconfig_ap_start.py | 3 + test/test_wifi_ap_lr_safety.py | 273 +++++++++++++++++++ test/test_wifi_ota_lifecycle.py | 1 + test/test_wifi_ota_start.py | 4 +- 17 files changed, 557 insertions(+), 17 deletions(-) create mode 100644 test/fixtures/wifi_ap_shared_lr_sdk.h create mode 100644 test/test_wifi_ap_lr_safety.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index df3f0b4dc..a5e5dc025 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -367,6 +367,7 @@ jobs: python3 -B test/test_serial_ethernet_sessions.py -v python3 -B test/test_companion_wifi_session.py -v python3 -B test/test_espnow_radio_lifecycle.py -v + python3 -B test/test_wifi_ap_lr_safety.py -v python3 -B test/test_espnow_runtime.py -v python3 -B test/test_wifi_ota_lifecycle.py -v diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index aceaab47e..ae5081b32 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -299,6 +299,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (!use_ap) { ip = WiFi.localIP(); } else { + if (!mesh::wifi::accessPointCompatibleWithLongRange()) { + inhibit_sleep = ota_server != nullptr; + strcpy(reply, "ERR: stop ESP-NOW before starting an OTA AP"); + return false; + } ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF; ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap); if (!ota_started_ap) { @@ -397,6 +402,11 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { if (!use_ap) { ip = WiFi.localIP(); } else { + if (!mesh::wifi::accessPointCompatibleWithLongRange()) { + inhibit_sleep = ota_server != nullptr; + strcpy(reply, "ERR: stop ESP-NOW before starting an OTA AP"); + return false; + } ota_started_radio = ota_started_radio || WiFi.getMode() == WIFI_OFF; ota_started_ap = mesh::wifi::startOpenAccessPoint("MeshCore-OTA", ota_started_ap); if (!ota_started_ap) { diff --git a/src/helpers/bridges/ESPNowBridge.cpp b/src/helpers/bridges/ESPNowBridge.cpp index 288026cda..4c5324f27 100644 --- a/src/helpers/bridges/ESPNowBridge.cpp +++ b/src/helpers/bridges/ESPNowBridge.cpp @@ -80,6 +80,11 @@ void ESPNowBridge::begin() { _active_format = mesh::bridge::isValidEspNowFormat(_prefs->bridge_format) ? _prefs->bridge_format : mesh::bridge::ESPNOW_FORMAT_WRAPPED; + const bool raw_format = _active_format == mesh::bridge::ESPNOW_FORMAT_RAW; + if (raw_format && mesh::wifi::checkLongRangeRadioStart() != ESP_OK) { + BRIDGE_DEBUG_PRINTLN("ESP-NOW LR start deferred while a WiFi AP is active\n"); + return; + } portENTER_CRITICAL(&_rx_mux); _rx_head = 0; _rx_tail = 0; @@ -126,14 +131,12 @@ void ESPNowBridge::begin() { return; } - const bool raw_format = - _active_format == mesh::bridge::ESPNOW_FORMAT_RAW; if (raw_format) { // Primary ESPNOWRadio nodes use Espressif's LR PHY. Retain B/G/N receive // support as well, but force this peer's outgoing frames to LR below. const uint8_t protocols = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N | WIFI_PROTOCOL_LR; - if (esp_wifi_set_protocol(WIFI_IF_STA, protocols) != ESP_OK) { + if (mesh::wifi::applyStationProtocolMask(protocols, true) != ESP_OK) { BRIDGE_DEBUG_PRINTLN("Error enabling ESP-NOW LR protocol\n"); stopBridgeWiFiIfUnused(); return; @@ -207,6 +210,7 @@ void ESPNowBridge::begin() { // Update bridge state _initialized = true; + mesh::wifi::setLongRangeOwner(mesh::wifi::kLongRangeBridgeOwner, raw_format); mesh::wifi::bridgeEspNowChannel().store(_prefs->bridge_channel); } @@ -231,6 +235,7 @@ void ESPNowBridge::end() { // Infrastructure WiFi may still own this shared radio. mesh::wifi::bridgeEspNowChannel().store(0); + mesh::wifi::setLongRangeOwner(mesh::wifi::kLongRangeBridgeOwner, false); stopBridgeWiFiIfUnused(); portENTER_CRITICAL(&_rx_mux); diff --git a/src/helpers/esp32/ESPNOWRadio.cpp b/src/helpers/esp32/ESPNOWRadio.cpp index a76dc4c60..37c3417a5 100644 --- a/src/helpers/esp32/ESPNOWRadio.cpp +++ b/src/helpers/esp32/ESPNOWRadio.cpp @@ -81,6 +81,12 @@ static void OnDataRecv(const uint8_t *mac, const uint8_t *data, int len) { void ESPNOWRadio::init() { if (initialized_ || mesh::wireless::control().blocked(mesh::wireless::EspNow)) return; + // A conventional AP and the LR PHY share one enable flag on IDF 4.4. Do not + // hide the AP or claim a successful new LR start while AP requires B/G/N. + if (mesh::wifi::checkLongRangeRadioStart() != ESP_OK) { + ESPNOW_DEBUG_PRINTLN("ESP-NOW LR start deferred while a WiFi AP is active"); + return; + } portENTER_CRITICAL(&rx_mux); rx_head = 0; rx_tail = 0; @@ -112,13 +118,13 @@ void ESPNOWRadio::init() { // access points while retaining LR for ESP-NOW, and pin both transports to // one channel because an ESP32 cannot associate and send ESP-NOW on two // different channels simultaneously. - if (mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) { + if (mesh::wifi::applyStationProtocolMask(mesh::wifi::kProtocolMask, true) != ESP_OK) { ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW/WiFi coexistence"); return; } #else // Dedicated ESP-NOW targets retain the original LR-only behavior. - if (esp_wifi_set_protocol(WIFI_IF_STA, WIFI_PROTOCOL_LR) != ESP_OK) { + if (mesh::wifi::applyStationProtocolMask(WIFI_PROTOCOL_LR, true) != ESP_OK) { ESPNOW_DEBUG_PRINTLN("Error configuring ESP-NOW LR protocol"); return; } @@ -203,6 +209,7 @@ void ESPNOWRadio::init() { #endif #endif accepting_packets.store(true); + mesh::wifi::setLongRangeOwner(mesh::wifi::kLongRangeRadioOwner, true); ESPNOW_DEBUG_PRINTLN("init success"); } else { end(); @@ -212,6 +219,7 @@ void ESPNOWRadio::init() { void ESPNOWRadio::end() { if (send_active_) send_cancelled_ = true; accepting_packets.store(false); + mesh::wifi::setLongRangeOwner(mesh::wifi::kLongRangeRadioOwner, false); if (initialized_) { esp_now_unregister_send_cb(); esp_now_unregister_recv_cb(); diff --git a/src/helpers/esp32/WebConfigServer.cpp b/src/helpers/esp32/WebConfigServer.cpp index a7a219c99..fcc8cd429 100644 --- a/src/helpers/esp32/WebConfigServer.cpp +++ b/src/helpers/esp32/WebConfigServer.cpp @@ -802,6 +802,10 @@ bool WebConfigServer::startSetupMode(char reply[]) { strcpy(reply, "Error: WiFi disabled; use set wifi on or set 2.4ghz on"); return false; } + if (!mesh::wifi::accessPointCompatibleWithLongRange()) { + strcpy(reply, "Error: stop ESP-NOW before starting a setup AP"); + return false; + } const bool promote_lan = _mode == MODE_LAN && !_owns_wifi && !_stopping; if ((_mode != MODE_OFF && !promote_lan) || _stopping) { strcpy(reply, "Err: webconfig busy"); @@ -1208,7 +1212,8 @@ void WebConfigServer::tick(uint32_t now) { _dns = NULL; } WiFi.softAPdisconnect(true); - WiFi.mode(WIFI_STA); + if (!WiFi.mode(WIFI_STA) + || mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) return; mesh::wifi::setStationAutoReconnect(true); _was_setup_ap = false; _initial_setup = false; @@ -1302,7 +1307,8 @@ void WebConfigServer::tick(uint32_t now) { _dns = NULL; } WiFi.softAPdisconnect(true); - WiFi.mode(WIFI_STA); + if (!WiFi.mode(WIFI_STA) + || mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK) return; mesh::wifi::setStationAutoReconnect(true); _was_setup_ap = false; _initial_setup = false; diff --git a/src/helpers/esp32/WiFiAccessPointPolicy.h b/src/helpers/esp32/WiFiAccessPointPolicy.h index f416c5a77..7585ad1d4 100644 --- a/src/helpers/esp32/WiFiAccessPointPolicy.h +++ b/src/helpers/esp32/WiFiAccessPointPolicy.h @@ -59,6 +59,7 @@ inline bool scanBeforeAccessPoint() { } inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) { + if (!accessPointCompatibleWithLongRange()) return false; const int channel = accessPointChannel(); // Repeated OTA commands retain joined clients when the AP is already live. if (reuse_existing && applyAccessPointProtocolMask() == ESP_OK @@ -96,6 +97,12 @@ inline bool startOpenAccessPoint(const char* ssid, bool reuse_existing) { } inline void stopTemporaryAccessPointRadio(bool started_radio) { + // Removing AP releases its shared LR constraint. The station policy checks + // the live SDK mode and cannot re-enable LR while AP is still present. + if (espNowChannelConstrained()) { + applyProtocolMask(WIFI_IF_STA); + return; + } // Only restore OFF when this AP initialized the radio. A station inherited // from WebConfig/MQTT belongs to the caller's service ownership policy. if (started_radio && !espNowChannelConstrained() diff --git a/src/helpers/esp32/WiFiRadioPolicy.h b/src/helpers/esp32/WiFiRadioPolicy.h index 0d5166d13..8a51a90a8 100644 --- a/src/helpers/esp32/WiFiRadioPolicy.h +++ b/src/helpers/esp32/WiFiRadioPolicy.h @@ -41,11 +41,26 @@ static constexpr uint8_t kProtocolMask = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N; #endif -// LR is private to ESP-NOW on the station interface. A setup SoftAP must keep -// an ordinary b/g/n protocol bitmap so phones and laptops can discover it. +// ESP-IDF 4.4 shares the LR enable flag between STA and AP. A later STA LR +// write can therefore change AP beacons even after AP was configured as B/G/N. +// Conventional setup access points take precedence while they are enabled. static constexpr uint8_t kAccessPointProtocolMask = WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N; +static constexpr uint8_t kLongRangeRadioOwner = 1; +static constexpr uint8_t kLongRangeBridgeOwner = 2; +inline std::atomic& longRangeOwners() { + static std::atomic owners{0}; + return owners; +} +inline void setLongRangeOwner(uint8_t owner, bool active) { + if (active) longRangeOwners().fetch_or(owner); + else longRangeOwners().fetch_and(static_cast(~owner)); +} +inline bool accessPointCompatibleWithLongRange() { + return longRangeOwners().load() == 0; +} + // A bridge can start/stop independently of infrastructure WiFi. Its channel // constraint exists only while it runs; primary ESP-NOW keeps its boot policy. inline std::atomic& bridgeEspNowChannel() { @@ -115,14 +130,69 @@ inline bool saveConfiguredEspNowChannel(uint8_t) { } #endif -inline esp_err_t applyProtocolMask(wifi_interface_t interface_id) { - return esp_wifi_set_protocol(interface_id, kProtocolMask); +// Check the SDK rather than Arduino's facade: an ESP-NOW bridge can own a +// driver which Arduino has not initialized. An absent driver is safe to start. +inline esp_err_t checkLongRangeRadioStart() { + wifi_mode_t mode = WIFI_MODE_NULL; + const esp_err_t result = esp_wifi_get_mode(&mode); + if (result == ESP_ERR_WIFI_NOT_INIT) return ESP_OK; + if (result != ESP_OK) return result; + return (mode & WIFI_MODE_AP) ? ESP_ERR_INVALID_STATE : ESP_OK; } inline esp_err_t applyAccessPointProtocolMask() { return esp_wifi_set_protocol(WIFI_IF_AP, kAccessPointProtocolMask); } +inline esp_err_t applyStationProtocolMask(uint8_t protocols, bool require_lr = false) { + wifi_mode_t mode = WIFI_MODE_NULL; + esp_err_t result = esp_wifi_get_mode(&mode); + if (result != ESP_OK) return result; + if ((mode & WIFI_MODE_AP) && require_lr) return ESP_ERR_INVALID_STATE; + if (mode & WIFI_MODE_AP) protocols = kAccessPointProtocolMask; + + // persistent(false) only changes Arduino's next initialization preference. + // Select RAM on this already initialized SDK driver before any LR write. + if (protocols & WIFI_PROTOCOL_LR) { + result = esp_wifi_set_storage(WIFI_STORAGE_RAM); + if (result != ESP_OK) return result; + } + result = esp_wifi_set_protocol(WIFI_IF_STA, protocols); + if (result != ESP_OK) return result; + + // Recheck after the write so an AP enabled by another owner is repaired too. + result = esp_wifi_get_mode(&mode); + if (result != ESP_OK) { + // Unknown mode is not proof that LR is safe. Best-effort B/G/N rollback + // also clears the shared LR flag if an AP appeared during this write. + // An established LR owner already prevents AP startup. Do not silently + // downgrade that owner when a later infrastructure-only mode read fails. + if (accessPointCompatibleWithLongRange()) { + esp_wifi_set_protocol(WIFI_IF_STA, kAccessPointProtocolMask); + applyAccessPointProtocolMask(); + } + return result; + } + if (mode & WIFI_MODE_AP) { + result = applyAccessPointProtocolMask(); + if (result != ESP_OK) { + esp_wifi_set_protocol(WIFI_IF_STA, kAccessPointProtocolMask); + return result; + } + uint8_t actual = 0; + result = esp_wifi_get_protocol(WIFI_IF_AP, &actual); + if (result != ESP_OK) return result; + if (actual != kAccessPointProtocolMask || require_lr) return ESP_ERR_INVALID_STATE; + } + return ESP_OK; +} + +inline esp_err_t applyProtocolMask(wifi_interface_t interface_id) { + return interface_id == WIFI_IF_STA + ? applyStationProtocolMask(kProtocolMask) + : esp_wifi_set_protocol(interface_id, kProtocolMask); +} + // An ESP-NOW radio and an associated station cannot occupy different channels. // Full Companion therefore keeps both its setup AP and any infrastructure-WiFi // association on the mesh channel. Passing zero on ordinary targets preserves diff --git a/test/fixtures/espnow_wifi_handoff.cpp b/test/fixtures/espnow_wifi_handoff.cpp index b456c0b63..7787b37e3 100644 --- a/test/fixtures/espnow_wifi_handoff.cpp +++ b/test/fixtures/espnow_wifi_handoff.cpp @@ -141,6 +141,10 @@ struct Facade { } WiFi; namespace mesh { namespace wifi { +constexpr uint8_t kLongRangeBridgeOwner=2; +void setLongRangeOwner(uint8_t,bool){} +int checkLongRangeRadioStart() { return sdk_initialized && (sdk_mode & WIFI_AP) ? -1 : ESP_OK; } +int applyStationProtocolMask(uint8_t mask, bool) { return esp_wifi_set_protocol(WIFI_IF_STA, mask); } std::atomic& bridgeEspNowChannel() { static std::atomic channel{0}; return channel; } } } struct Backend : mesh::wireless::Backend { diff --git a/test/fixtures/webconfig_ap_start.cpp b/test/fixtures/webconfig_ap_start.cpp index cc74b0c17..9ab27d962 100644 --- a/test/fixtures/webconfig_ap_start.cpp +++ b/test/fixtures/webconfig_ap_start.cpp @@ -1,6 +1,7 @@ // Only peripheral endpoints are doubles. The readiness helper and startup // method are extracted verbatim from production WebConfigServer.cpp. #include +#include #include #include #include @@ -368,6 +369,16 @@ void reset(Fault fault = Fault::None) { DNSServer::starts = 0; mesh::wireless::control().disabled = false; } int main() { + // A live LR transport must remain untouched instead of being silently + // downgraded when a conventional setup access point is requested. + for (uint8_t owner : {mesh::wifi::kLongRangeRadioOwner, mesh::wifi::kLongRangeBridgeOwner}) { + reset(); mesh::wifi::setLongRangeOwner(owner, true); + WebConfigServer portal; char reply[160] = {}; + assert(!portal.startSetupMode(reply)); + assert(strstr(reply, "stop ESP-NOW") && WiFi.live_mode == WIFI_STA); + assert(WiFi.sta_only_calls == 0 && WiFi.ap_calls == 0 && WiFi.scans == 0); + mesh::wifi::setLongRangeOwner(owner, false); + } for (Fault fault : {Fault::StaleSsid, Fault::HiddenSsid, Fault::WrongChannel, Fault::WrongProtocol, Fault::MissingMode, Fault::NotStarted, Fault::ZeroIp, Fault::SetterFailure, Fault::WrongAuth, Fault::StillConnected, diff --git a/test/fixtures/webconfig_station_recovery.cpp b/test/fixtures/webconfig_station_recovery.cpp index 0840aab5f..2f5ca1ff7 100644 --- a/test/fixtures/webconfig_station_recovery.cpp +++ b/test/fixtures/webconfig_station_recovery.cpp @@ -22,7 +22,7 @@ struct FakeWiFi { bool next_begin_connects = false; std::string ssid, password; int status() const { return state; } - void mode(int) {} + bool mode(int) { return true; } void disconnect(bool, bool) { ++disconnects; state = WL_DISCONNECTED; } void softAPdisconnect(bool) {} IP localIP() const { return {}; } diff --git a/test/fixtures/wifi_ap_shared_lr_sdk.h b/test/fixtures/wifi_ap_shared_lr_sdk.h new file mode 100644 index 000000000..593545d69 --- /dev/null +++ b/test/fixtures/wifi_ap_shared_lr_sdk.h @@ -0,0 +1,119 @@ +// Model the pinned ESP-IDF 4.4.7 shared LR flag, not independent STA/AP masks. +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c)) +#define ESPNOW_DEBUG_PRINTLN(...) ((void)0) +#define BRIDGE_DEBUG_PRINTLN(...) ((void)0) +#define portENTER_CRITICAL(x) ((void)0) +#define portEXIT_CRITICAL(x) ((void)0) +#define portMUX_INITIALIZER_UNLOCKED 0 +#define ESP_NOW_ETH_ALEN 6 +#define WIFI_INIT_CONFIG_DEFAULT() 0 +using portMUX_TYPE=int; +using esp_err_t=int; +using wifi_interface_t=int; +using wifi_second_chan_t=int; +using wifi_init_config_t=int; +enum wifi_mode_t {WIFI_OFF=0,WIFI_STA=1,WIFI_AP=2,WIFI_AP_STA=3}; +constexpr wifi_mode_t WIFI_MODE_NULL=WIFI_OFF,WIFI_MODE_STA=WIFI_STA,WIFI_MODE_AP=WIFI_AP; +constexpr int ESP_OK=0,ESP_ERR_INVALID_STATE=-2,ESP_ERR_WIFI_NOT_INIT=-3; +constexpr int WIFI_IF_STA=0,WIFI_IF_AP=1,WIFI_STORAGE_RAM=0,WIFI_SECOND_CHAN_NONE=0; +constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4,WIFI_PROTOCOL_LR=8; +constexpr int WIFI_PHY_MODE_LR=1,WIFI_PHY_RATE_LORA_250K=2,WIFI_SCAN_RUNNING=-1; +enum wl_status_t {WL_IDLE_STATUS,WL_CONNECTED,WL_NO_SSID_AVAIL,WL_CONNECT_FAILED,WL_DISCONNECTED}; +enum esp_now_send_status_t {ESP_NOW_SEND_SUCCESS=0,ESP_NOW_SEND_FAIL=1}; +struct esp_now_send_info_t {}; +struct esp_now_recv_info_t {const uint8_t* src_addr;}; +struct esp_now_peer_info_t {uint8_t peer_addr[6];int channel,ifidx;bool encrypt;}; +struct esp_now_rate_config_t {int phymode,rate;bool ersu,dcm;}; +namespace mesh {enum class RadioParamApplyResult {APPLIED};} +int failure=0,mode_reads=0,station_writes=0,ap_writes=0,storage_writes=0; +int init_calls=0,deinit_calls=0,wake_refs=0,send_calls=0,power=0,rate_calls=0; +bool driver_initialized=true,sdk_active=false,storage_ram=false,lr=false,nvs_lr=false; +bool ap_during_station_write=false; +wifi_mode_t sdk_mode=WIFI_STA; +uint8_t sta_phy=7,ap_phy=7,current_channel=1; +std::vector protocol_order; +uint32_t millis(){return 1234;} +int esp_wifi_get_mode(wifi_mode_t* mode){ + ++mode_reads;if(failure==1 || (failure==2&&mode_reads==2))return -1; + if(!driver_initialized)return ESP_ERR_WIFI_NOT_INIT; + *mode=sdk_mode;return ESP_OK; +} +int esp_wifi_set_storage(int){++storage_writes;if(failure==3)return -1;storage_ram=true;return ESP_OK;} +int esp_wifi_set_protocol(int interface_id,uint8_t mask){ + if((interface_id==WIFI_IF_STA&&failure==4)||(interface_id==WIFI_IF_AP&&failure==5))return -1; + if(interface_id==WIFI_IF_STA){++station_writes;sta_phy=mask&7;}else{++ap_writes;ap_phy=mask&7;} + lr=(mask&WIFI_PROTOCOL_LR)!=0; + if(!storage_ram)nvs_lr=lr; + protocol_order.push_back(interface_id); + if(interface_id==WIFI_IF_STA&&ap_during_station_write){sdk_mode=WIFI_AP_STA;ap_during_station_write=false;} + return ESP_OK; +} +int esp_wifi_get_protocol(int interface_id,uint8_t* mask){ + if(failure==6)return -1; + *mask=(interface_id==WIFI_IF_STA?sta_phy:ap_phy)|(lr?8:0); + if(failure==7)*mask|=8; + return ESP_OK; +} +int esp_wifi_get_channel(uint8_t* c,wifi_second_chan_t*){*c=current_channel;return ESP_OK;} +int esp_wifi_set_channel(uint8_t c,int){current_channel=c;return ESP_OK;} +int esp_wifi_init(wifi_init_config_t*){driver_initialized=true;return ESP_OK;} +int esp_wifi_set_mode(wifi_mode_t mode){sdk_mode=mode;return ESP_OK;} +int esp_wifi_start(){return ESP_OK;} +int esp_wifi_stop(){return ESP_OK;} +int esp_wifi_deinit(){driver_initialized=false;return ESP_OK;} +struct FakeWiFi { + bool facade_started=true,autoreconnect=true; + wl_status_t state=WL_DISCONNECTED;int join_channel=0,scan_channel=0; + void persistent(bool){} // A late Arduino preference does not switch SDK storage. + wifi_mode_t getMode(){return facade_started?sdk_mode:WIFI_OFF;} + int channel(int index=-1){return index<0?current_channel:1;} + void setAutoReconnect(bool value){autoreconnect=value;} + bool mode(wifi_mode_t value){if(failure==8)return false;driver_initialized=true;facade_started=true;sdk_mode=value;return true;} + bool softAPdisconnect(bool){sdk_mode=WIFI_STA;return true;} + struct IP {struct Text {const char* c_str(){return "192.0.2.1";}};Text toString(){return {};}}; + IP localIP(){return {};} + wl_status_t status(){return state;} + wl_status_t begin(const char*,const char*,int channel=0,const uint8_t* =nullptr){join_channel=channel;state=WL_CONNECTED;return state;} + int scanComplete(){return -2;}void scanDelete(){} + int scanNetworks(bool,bool,bool,int,int channel,const char*,const uint8_t*){scan_channel=channel;return 1;} + std::string SSID(int){return "mesh";}int RSSI(int){return -50;} + const uint8_t* BSSID(int){static uint8_t mac[6]={1};return mac;} + void disconnect(bool,bool){state=WL_DISCONNECTED;} +} WiFi; +int esp_now_init(){++init_calls;sdk_active=true;return ESP_OK;} +int esp_now_deinit(){assert(sdk_active);sdk_active=false;++deinit_calls;return ESP_OK;} +int esp_wifi_force_wakeup_acquire(){++wake_refs;return ESP_OK;} +int esp_wifi_force_wakeup_release(){assert(wake_refs==1);--wake_refs;return ESP_OK;} +int esp_wifi_set_max_tx_power(int value){power=value;return ESP_OK;} +template int esp_now_register_send_cb(T){return ESP_OK;} +template int esp_now_register_recv_cb(T){return ESP_OK;} +int esp_now_unregister_send_cb(){return ESP_OK;} +int esp_now_unregister_recv_cb(){return ESP_OK;} +int esp_now_add_peer(esp_now_peer_info_t*){return ESP_OK;} +int esp_now_del_peer(const uint8_t*){return ESP_OK;} +int esp_now_set_peer_rate_config(const uint8_t*,esp_now_rate_config_t*){++rate_calls;return ESP_OK;} +int esp_wifi_config_espnow_rate(int,int){++rate_calls;return ESP_OK;} +int esp_now_send(const uint8_t*,const uint8_t*,size_t){assert(sdk_active);++send_calls;return ESP_OK;} +void esp_efuse_mac_get_default(uint8_t* mac){memset(mac,1,6);} +class Preferences { +public: + bool begin(const char*,bool){return true;}bool isKey(const char*){return false;} + uint8_t getUChar(const char*,uint8_t fallback){return fallback;} + size_t putUChar(const char*,uint8_t){return 1;}void end(){} +}; +struct Backend : mesh::wireless::Backend { + bool wifi=true; + uint8_t available()const override{return mesh::wireless::WiFi|mesh::wireless::EspNow;} + uint8_t enabled()const override{return wifi?mesh::wireless::WiFi:0;} + uint8_t clients()const override{return mesh::wireless::Independent;} + mesh::wireless::Result set(uint8_t,bool)override{return mesh::wireless::Result::Done;} +}; diff --git a/test/fixtures/wifi_ota_start.cpp b/test/fixtures/wifi_ota_start.cpp index 92bfd4758..6cd875f5a 100644 --- a/test/fixtures/wifi_ota_start.cpp +++ b/test/fixtures/wifi_ota_start.cpp @@ -1,6 +1,7 @@ // WiFi and the HTTP server are peripheral fakes; both OTA lifecycle methods // are inserted verbatim from ESP32Board.cpp by test_wifi_ota_start.py. #include +#include #include #include #include @@ -222,6 +223,15 @@ const esp_partition_t* esp_ota_get_next_update_partition(void*) { int main() { ESP32Board board; struct { char text[160] = {}; uint32_t guard = 0x12345678; } reply; + for (uint8_t owner : {mesh::wifi::kLongRangeRadioOwner, mesh::wifi::kLongRangeBridgeOwner}) { + mesh::wifi::setLongRangeOwner(owner, true); + assert(!board.startOTAUpdate("test node", reply.text, true)); + assert(strstr(reply.text, "stop ESP-NOW") && WiFi.starts == 0 && !WiFi.ap); + assert(!board.inhibit_sleep && !board.ota_server); + assert(!mesh::wifi::startOpenAccessPoint("MeshCore-OTA", false)); + assert(WiFi.starts == 0 && !WiFi.ap); + mesh::wifi::setLongRangeOwner(owner, false); + } auto start = [&](bool ap, const char* ip, const char* instruction) { assert(board.startOTAUpdate("test node", reply.text, ap)); assert(board.inhibit_sleep && board.ota_server); diff --git a/test/test_espnow_radio_lifecycle.py b/test/test_espnow_radio_lifecycle.py index 8626dba85..7576f8058 100644 --- a/test/test_espnow_radio_lifecycle.py +++ b/test/test_espnow_radio_lifecycle.py @@ -41,6 +41,10 @@ struct FakeWiFi { namespace mesh { enum class RadioParamApplyResult {APPLIED}; namespace wifi { + constexpr uint8_t kProtocolMask=15,kLongRangeRadioOwner=1,kLongRangeBridgeOwner=2; + void setLongRangeOwner(uint8_t,bool){} + int checkLongRangeRadioStart(){return WiFi.current&WIFI_AP?-1:0;} + int applyStationProtocolMask(uint8_t,bool){return failure==2?-1:0;} int applyProtocolMask(int){return failure==2?-1:0;} int restoreEspNowChannel(){return failure==3?-1:0;} } @@ -94,9 +98,11 @@ int main(){ OnDataSent(nullptr,ESP_NOW_SEND_SUCCESS); // callback arriving while disabled deliver(raw,4);assert(radio.recvRaw(out,sizeof(out))==0); radio.end();assert(wake_refs==0&&!sdk_active); - // Starting alongside a setup AP preserves it and restores the configured TX power. + // LR cannot restart alongside a conventional setup AP on the shared PHY. WiFi.current=WIFI_AP_STA;WiFi.autoreconnect=true;radio.init(); - assert(WiFi.current==WIFI_AP_STA&&WiFi.autoreconnect&&power==52); + assert(!radio.isEnabled()&&!sdk_active&&WiFi.current==WIFI_AP_STA); + WiFi.current=WIFI_STA;radio.init(); + assert(WiFi.current==WIFI_STA&&WiFi.autoreconnect&&power==52); assert(!radio.isSendComplete()); // restarting before Dispatcher's timeout cannot fake TX success radio.onSendFinished(); // Dispatcher acknowledges the cancelled operation assert(radio.recvRaw(out,sizeof(out))==0&&radio.isInRecvMode()); @@ -250,16 +256,20 @@ int main(){ def test_bridge_channel_constraint_tracks_runtime_state(self): with tempfile.TemporaryDirectory() as tmp: folder=Path(tmp) - (folder/'esp_err.h').write_text('#pragma once\nusing esp_err_t=int;\nconstexpr int ESP_OK=0;\n') + (folder/'esp_err.h').write_text('#pragma once\nusing esp_err_t=int;\nconstexpr int ESP_OK=0,ESP_ERR_INVALID_STATE=-2,ESP_ERR_WIFI_NOT_INIT=-3;\n') (folder/'esp_wifi.h').write_text(r''' #pragma once #include #include -using wifi_interface_t=int;using wifi_second_chan_t=int; +using wifi_interface_t=int;using wifi_second_chan_t=int;using wifi_mode_t=int; +constexpr int WIFI_MODE_NULL=0,WIFI_MODE_STA=1,WIFI_MODE_AP=2,WIFI_STORAGE_RAM=0; constexpr int WIFI_IF_STA=0,WIFI_IF_AP=1,WIFI_SECOND_CHAN_NONE=0; constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4,WIFI_PROTOCOL_LR=8; uint8_t current_channel=1,last_protocol=0; int esp_wifi_set_protocol(int,uint8_t p){last_protocol=p;return 0;} +int esp_wifi_get_mode(int* p){*p=WIFI_MODE_STA;return 0;} +int esp_wifi_get_protocol(int,uint8_t* p){*p=7;return 0;} +int esp_wifi_set_storage(int){return 0;} int esp_wifi_get_channel(uint8_t* p,int*){*p=current_channel;return 0;} int esp_wifi_set_channel(uint8_t p,int){current_channel=p;return 0;} ''') diff --git a/test/test_webconfig_ap_start.py b/test/test_webconfig_ap_start.py index 77cb57040..7307594f5 100644 --- a/test/test_webconfig_ap_start.py +++ b/test/test_webconfig_ap_start.py @@ -34,6 +34,9 @@ class WebConfigApStartTest(unittest.TestCase): policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text(encoding="utf-8") mask = policy[policy.index("static constexpr uint8_t kAccessPointProtocolMask ="):] mask = mask[:mask.index(";") + 1] + owner_policy = policy[policy.index("static constexpr uint8_t kLongRangeRadioOwner ="):] + owner_policy = owner_policy[:owner_policy.index("// A bridge can start/stop")] + mask += "\n" + owner_policy fixture = FIXTURE.read_text(encoding="utf-8").replace("@AP_MASK@", mask) ap_policy = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text(encoding="utf-8") scanner = "namespace mesh { namespace wifi {\n" + "\n".join( diff --git a/test/test_wifi_ap_lr_safety.py b/test/test_wifi_ap_lr_safety.py new file mode 100644 index 000000000..3f0c66787 --- /dev/null +++ b/test/test_wifi_ap_lr_safety.py @@ -0,0 +1,273 @@ +"""Execute production policies and ESP-NOW starts with IDF 4.4 shared LR state.""" +from pathlib import Path +import os +import re +import subprocess +import tempfile +import unittest +from test_radio_receive_contract import method + +ROOT = Path(__file__).resolve().parents[1] +BRIDGE_CLASS = r""" +class Bridge { +public: + struct Prefs {int bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW;uint8_t bridge_channel=1;} prefs; + Prefs* _prefs=&prefs;bool _initialized=false; + int _active_format=0,_rx_mux=0,_rx_head=0,_rx_tail=0,_rx_count=0,_rx_dropped=0,_rx_dropped_reported=0; + int _tx_mux=0,_tx_head=0,_tx_tail=0,_tx_count=0,_tx_waiting=0,_tx_callback_done=0; + int _tx_callback_status=0,_tx_dropped=0,_tx_dropped_reported=0; + mesh::espnow::ESPNowRawReassembler _raw_reassembler; + struct QueuedTransmit {bool started=false;int packet=0;}; + static constexpr int TX_QUEUE_DEPTH=2; + QueuedTransmit _tx_queue[TX_QUEUE_DEPTH]; + struct {void clear(const int*){}} _seen_packets; + static void recv_cb(){}static void send_cb(){} + void begin();void end(); +}; +""" +WEB_CLASS = r""" +namespace mesh { +struct DebugPort {template void printf(const char*,T...){}void println(const char*){}}; +DebugPort& usbDebugPort(){static DebugPort port;return port;} +} +class WebTeardown { +public: + enum Mode {MODE_SETUP,MODE_LAN}; + struct DNS {void stop(){}}; + DNS* _dns=new DNS; + Mode _mode=MODE_SETUP; + bool _was_setup_ap=true,_initial_setup=true,_retry_saved_wifi_in_setup=true,_setup_reconnect_in_progress=true; + uint32_t _setup_started_at=1,_setup_reconnect_deadline=1,_last_activity=1; + char _wifi_ssid[5]="mesh"; + struct Tracker {void noteConnected(){}} _wifi_reconnect_tracker; + ~WebTeardown(){delete _dns;} + void promote(){uint32_t now=1234;@PROMOTE@} + void idle(){uint32_t now=1234;@IDLE@} +}; +""" +CHECKS = r""" +void reset_policy(){ + failure=0;mode_reads=station_writes=ap_writes=storage_writes=0; + driver_initialized=true;storage_ram=false;lr=false;nvs_lr=false; + sdk_mode=WIFI_STA;sta_phy=ap_phy=7;ap_during_station_write=false; + WiFi.facade_started=true;protocol_order.clear(); +} +void check_ap(){uint8_t actual=0;assert(esp_wifi_get_protocol(WIFI_IF_AP,&actual)==ESP_OK&&actual==7);} +int main(int argc,char** argv){ + assert(argc==2);std::string test=argv[1];Backend backend;mesh::wireless::control().begin(backend); + if(test=="station"){ + sdk_mode=WIFI_AP_STA;WiFi.facade_started=false; + // Witness the SDK coupling: a direct STA15 write changes AP's live bitmap. + assert(esp_wifi_set_protocol(WIFI_IF_STA,15)==ESP_OK); + uint8_t actual=0;esp_wifi_get_protocol(WIFI_IF_AP,&actual);assert(actual==15); + // Production policy repairs it even if the Arduino facade reports OFF. + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)==ESP_OK);check_ap(); + assert(!lr&&station_writes==2&&ap_writes==1); + assert(mesh::wifi::beginStation("mesh","password")==WL_CONNECTED); + assert(WiFi.scan_channel==1&&WiFi.join_channel==1);check_ap(); + // Same unchanged helper is used by setup saved-SSID retry and credential handoff. + assert(mesh::wifi::beginStation("mesh","password")==WL_CONNECTED);check_ap(); + sdk_mode=WIFI_STA; + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)==ESP_OK&&lr&&storage_ram); + }else if(test=="bridge"){ + Bridge bridge;sdk_mode=WIFI_AP_STA;WiFi.facade_started=false; + assert(mesh::wifi::accessPointCompatibleWithLongRange()); + bridge.begin();assert(!bridge._initialized&&!sdk_active&&init_calls==0); + assert(sdk_mode==WIFI_AP_STA&&station_writes==0&&storage_writes==0);check_ap(); + // Saved raw intent is unchanged; the normal retry can start after AP stops. + sdk_mode=WIFI_STA;bridge.begin(); + assert(bridge._initialized&&sdk_active&&lr&&storage_ram&&!nvs_lr&&rate_calls==1); + assert(!mesh::wifi::accessPointCompatibleWithLongRange()); + bridge.begin();assert(mesh::wifi::longRangeOwners().load()==mesh::wifi::kLongRangeBridgeOwner); + failure=2;mode_reads=0; + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)!=ESP_OK&&lr); + failure=0;mode_reads=0; + assert(mesh::wifi::bridgeEspNowChannel().load()==1); + bridge.end();assert(!sdk_active&&mesh::wifi::bridgeEspNowChannel().load()==0); + bridge.end();assert(mesh::wifi::accessPointCompatibleWithLongRange()); + sdk_mode=WIFI_AP_STA;mesh::wifi::applyAccessPointProtocolMask(); + bridge.begin();assert(!bridge._initialized);check_ap(); + bridge.prefs.bridge_format=mesh::bridge::ESPNOW_FORMAT_WRAPPED; + bridge.begin();assert(bridge._initialized&&mesh::wifi::accessPointCompatibleWithLongRange());check_ap(); + bridge.end();assert(mesh::wifi::accessPointCompatibleWithLongRange()); + }else if(test=="radio"){ + ESPNOWRadio radio;sdk_mode=WIFI_AP_STA;WiFi.facade_started=false; + radio.init();assert(!radio.isEnabled()&&!sdk_active&&init_calls==0); + assert(sdk_mode==WIFI_AP_STA&&station_writes==0&&storage_writes==0);check_ap(); + sdk_mode=WIFI_STA;radio.init(); + assert(radio.isEnabled()&&sdk_active&&lr&&storage_ram&&!nvs_lr); + assert(!mesh::wifi::accessPointCompatibleWithLongRange()); + radio.init();assert(mesh::wifi::longRangeOwners().load()==mesh::wifi::kLongRangeRadioOwner); +#if defined(MESH_ESPNOW_RADIO) && MESH_ESPNOW_RADIO + assert(rate_calls==1); +#endif + radio.end();assert(!sdk_active&&wake_refs==0); + radio.end();assert(mesh::wifi::accessPointCompatibleWithLongRange()); + sdk_mode=WIFI_AP_STA;mesh::wifi::applyAccessPointProtocolMask(); + radio.init();assert(!radio.isEnabled());check_ap(); + }else if(test=="failures"){ + for(int stage:{1,2,4,5,6,7}){ + reset_policy();sdk_mode=WIFI_AP_STA;failure=stage; + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)!=ESP_OK); + assert(!lr); // no failure path writes LR into the live AP + } + reset_policy();failure=3; + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)!=ESP_OK); + assert(station_writes==0&&!lr&&!nvs_lr); + reset_policy();driver_initialized=false; + assert(mesh::wifi::checkLongRangeRadioStart()==ESP_OK); + reset_policy();failure=1; + assert(mesh::wifi::checkLongRangeRadioStart()!=ESP_OK); + for(int stage:{1,3,4}){ + reset_policy();failure=stage;Bridge bridge;bridge.begin(); + assert(!bridge._initialized&&!sdk_active&&!nvs_lr); + assert(mesh::wifi::accessPointCompatibleWithLongRange()); + } + }else if(test=="race"){ + // A second owner enables AP during the STA write. Post-write repair must + // preserve standard beacons and reject a claimed strict-LR success. + ap_during_station_write=true; + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)==ESP_OK);check_ap(); + reset_policy();ap_during_station_write=true; + assert(mesh::wifi::applyStationProtocolMask(15,true)!=ESP_OK);check_ap(); + assert(!nvs_lr); + for(int stage:{2,5}){ + reset_policy();ap_during_station_write=true;failure=stage; + assert(mesh::wifi::applyStationProtocolMask(15,true)!=ESP_OK); + assert(!lr&&!nvs_lr); // uncertain/failed AP repair cannot leave a new LR flag + } + }else if(test=="stop"){ + sdk_mode=WIFI_AP_STA; + assert(mesh::wifi::applyProtocolMask(WIFI_IF_STA)==ESP_OK);check_ap(); + mesh::wifi::stopTemporaryAccessPointRadio(false);check_ap();assert(!lr); + sdk_mode=WIFI_STA; + mesh::wifi::stopTemporaryAccessPointRadio(false); + assert(lr&&storage_ram&&!nvs_lr); + }else if(test=="web_teardown"){ + for(bool idle:{false,true}){ + for(int stage:{3,8}){ + reset_policy();sdk_mode=WIFI_AP_STA;WiFi.state=WL_CONNECTED;failure=stage; + WebTeardown web; + if(idle)web.idle();else web.promote(); + assert(web._mode==WebTeardown::MODE_SETUP&&web._was_setup_ap); + // A failed restore keeps setup ownership flags so a later tick can retry. + failure=0; + if(idle)web.idle();else web.promote(); + assert(web._mode==WebTeardown::MODE_LAN&&!web._was_setup_ap&&lr&&storage_ram); + } + } + }else return 2; +} +""" + + +def build_and_run(scenario, *, mutation=None, full=True): + policy = (ROOT / 'src/helpers/esp32/WiFiRadioPolicy.h').read_text() + radio_header = (ROOT / 'src/helpers/esp32/ESPNOWRadio.h').read_text() + radio_header = radio_header[:radio_header.index('#if ESPNOW_DEBUG_LOGGING')] + radio_header = re.sub(r'^\s*#(?:include|pragma).*$', '', radio_header, flags=re.MULTILINE) + radio_header = radio_header.replace(' : public mesh::Radio', '').replace(' override', '') + radio = (ROOT / 'src/helpers/esp32/ESPNOWRadio.cpp').read_text() + radio = re.sub(r'^\s*#include.*$', '', radio, flags=re.MULTILINE) + bridge_source = (ROOT / 'src/helpers/bridges/ESPNowBridge.cpp').read_text() + bridge = method(bridge_source, 'static void stopBridgeWiFiIfUnused(') + '\n' + bridge += '\n'.join(method(bridge_source, f'void ESPNowBridge::{name}(') + .replace('ESPNowBridge::', 'Bridge::') for name in ('begin', 'end')) + stop = method((ROOT / 'src/helpers/esp32/WiFiAccessPointPolicy.h').read_text(), + 'inline void stopTemporaryAccessPointRadio(') + web_source = (ROOT / 'src/helpers/esp32/WebConfigServer.cpp').read_text() + tick = method(web_source, 'void WebConfigServer::tick(') + setup = method(tick, 'if (_mode == MODE_SETUP && _retry_saved_wifi_in_setup && _wifi_ssid[0])') + promotion = method(setup, 'if (WiFi.status() == WL_CONNECTED)') + idle_outer = method(tick, 'if (_mode == MODE_SETUP && WiFi.softAPgetStationNum() == 0 &&') + idle = method(idle_outer, 'if (_retry_saved_wifi_in_setup && _wifi_ssid[0])') + if mutation == 'unguarded_station': + policy = policy.replace('if (mode & WIFI_MODE_AP) protocols = kAccessPointProtocolMask;', '') + policy = policy.replace('if (mode & WIFI_MODE_AP) {\n result = applyAccessPointProtocolMask();', + 'if (false) {\n result = applyAccessPointProtocolMask();') + elif mutation == 'raw_start': + bridge = bridge.replace('raw_format && mesh::wifi::checkLongRangeRadioStart() != ESP_OK', 'false') + policy = policy.replace('if ((mode & WIFI_MODE_AP) && require_lr)', 'if (false)') + policy = policy.replace('if (mode & WIFI_MODE_AP) protocols = kAccessPointProtocolMask;', '') + policy = policy.replace('if (mode & WIFI_MODE_AP) {\n result = applyAccessPointProtocolMask();', + 'if (false) {\n result = applyAccessPointProtocolMask();') + elif mutation == 'primary_start': + radio = radio.replace('mesh::wifi::checkLongRangeRadioStart() != ESP_OK', 'false') + elif mutation == 'flash_storage': + policy = policy.replace('result = esp_wifi_set_storage(WIFI_STORAGE_RAM);', 'result = ESP_OK;') + elif mutation == 'no_restore': + stop = stop.replace('applyProtocolMask(WIFI_IF_STA);', '') + elif mutation == 'web_no_restore': + promotion = promotion.replace('|| mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK', '') + idle = idle.replace('|| mesh::wifi::applyProtocolMask(WIFI_IF_STA) != ESP_OK', '') + with tempfile.TemporaryDirectory() as temporary: + folder = Path(temporary) + (folder / 'helpers/esp32').mkdir(parents=True) + (folder / 'helpers/esp32/WiFiRadioPolicy.h').write_text(policy) + for name in ('esp_err.h', 'esp_wifi.h', 'WiFi.h', 'Preferences.h'): + (folder / name).write_text('#pragma once\n') + fixture = (ROOT / 'test/fixtures/wifi_ap_shared_lr_sdk.h').read_text() + source = fixture + '\n#include \n' + source += '#include \n' + source += 'namespace mesh {namespace wifi {\n' + stop + '\n}}\n' + web = WEB_CLASS.replace('@PROMOTE@', promotion).replace('@IDLE@', idle) + source += radio_header + radio + BRIDGE_CLASS + bridge + web + CHECKS + (folder / 'test.cpp').write_text(source) + binary = folder / 'test' + flags = ['-DMESH_PRIMARY_ESPNOW=1', '-DESP_ARDUINO_VERSION_MAJOR=2', '-DESP_IDF_VERSION=40407'] + if full: + flags += ['-DMESH_ESPNOW_RADIO=1', '-DWITH_ESPNOW_BRIDGE=1'] + compiled = subprocess.run([os.environ.get('CXX', 'g++'), '-std=c++17', + '-fsanitize=address,undefined', '-fno-omit-frame-pointer', '-fno-pie', '-no-pie', + '-I', str(folder), '-I', str(ROOT/'src'), *flags, str(folder/'test.cpp'), '-o', str(binary)], + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if compiled.returncode: + raise AssertionError(compiled.stderr) + return subprocess.run([str(binary), scenario], text=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +class WifiApLongRangeSafetyTests(unittest.TestCase): + def assert_pass(self, scenario, **kwargs): + result = build_and_run(scenario, **kwargs) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_constrained_setup_retry_and_handoff_preserve_ap(self): + self.assert_pass('station') + source = (ROOT/'src/helpers/esp32/WebConfigServer.cpp').read_text() + tick = method(source, 'void WebConfigServer::tick(') + batch = method(source, 'void WebConfigServer::drainBatch(') + self.assertIn('mesh::wifi::beginStation(_wifi_ssid, _wifi_password);', tick) + self.assertIn('mesh::wifi::beginStation(_wifi_ssid, _wifi_password);', batch) + + def test_raw_bridge_defers_then_resumes_without_persisting_lr(self): + self.assert_pass('bridge') + + def test_primary_restart_defers_for_sdk_ap_even_with_stale_facade(self): + self.assert_pass('radio') + self.assert_pass('radio', full=False) + + def test_protocol_read_write_and_storage_failures_do_not_claim_success(self): + self.assert_pass('failures') + + def test_ap_start_during_station_write_is_repaired(self): + self.assert_pass('race') + + def test_lr_restore_waits_for_actual_ap_stop(self): + self.assert_pass('stop') + + def test_webconfig_teardown_failure_retains_state_for_retry(self): + self.assert_pass('web_teardown') + + def test_negative_controls_fail_at_runtime(self): + for mutation, scenario in [('unguarded_station', 'station'), ('raw_start', 'bridge'), + ('primary_start', 'radio'), ('flash_storage', 'bridge'), + ('no_restore', 'stop'), ('web_no_restore', 'web_teardown')]: + with self.subTest(mutation=mutation): + result = build_and_run(scenario, mutation=mutation) + self.assertNotEqual(result.returncode, 0, 'regression was not detected') + self.assertIn('Assertion', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_wifi_ota_lifecycle.py b/test/test_wifi_ota_lifecycle.py index 943b521e8..c26557896 100644 --- a/test/test_wifi_ota_lifecycle.py +++ b/test/test_wifi_ota_lifecycle.py @@ -123,6 +123,7 @@ struct MockWiFi { void scanDelete() {} } WiFi; namespace mesh { namespace wifi { +bool accessPointCompatibleWithLongRange() { return true; } constexpr uint8_t kAccessPointProtocolMask = 7; int applyAccessPointProtocolMask() { WiFi.ap_protocol = 7; return ESP_OK; } int applyProtocolMask(int) { return ESP_OK; } diff --git a/test/test_wifi_ota_start.py b/test/test_wifi_ota_start.py index eff9c7815..67099d349 100644 --- a/test/test_wifi_ota_start.py +++ b/test/test_wifi_ota_start.py @@ -36,7 +36,9 @@ class WiFiOtaStartTest(unittest.TestCase): policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text() mask = re.search(r"static constexpr uint8_t kAccessPointProtocolMask =.*?;", policy, re.DOTALL).group(0) - fixture = fixture.replace("@AP_PROTOCOL_POLICY@", mask + "\n" + extract_braced( + owner_policy = policy[policy.index("static constexpr uint8_t kLongRangeRadioOwner ="):] + owner_policy = owner_policy[:owner_policy.index("// A bridge can start/stop")] + fixture = fixture.replace("@AP_PROTOCOL_POLICY@", mask + "\n" + owner_policy + extract_braced( policy, "inline esp_err_t applyAccessPointProtocolMask()") + "\n" + extract_braced(policy, "inline int accessPointChannel()")) lifecycle = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text()