fix(esp32): defer station maintenance while an AP owns WiFi
Starting or restarting MQTT during browser OTA could remove the active AP by forcing STA mode. Defer shared MQTT startup and recovery, and Companion WiFi startup, pending credential reloads and saved-SSID retries, until the live SDK AP releases the driver. Preserve the setup owner's deliberate credential handoff and cooperative stop acknowledgement. Validated 91 source host tests, including 20 new AP ownership cases and nine reverted controls under ASan/UBSan. Hardware and firmware build qualification remain separate; the Arduino mode snapshot is not atomic.
This commit is contained in:
1 parent
bd4cbf281f
commit
8c4e09275e
13 files changed
+596
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#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;}
|
||||
template<class T>bool 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);
|
||||
}
|
||||
Vendored
+175
@@ -0,0 +1,175 @@
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#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<void()> 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;}
|
||||
template<class T>void 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<bool>_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<std::string> 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<std::string>{"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);
|
||||
}
|
||||
+5
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
@@ -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[
|
||||
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user