654 lines
33 KiB
Python
654 lines
33 KiB
Python
#!/usr/bin/env python3
|
|
"""Execute infrastructure startup, bridge retries, OTA resume, and idle teardown.
|
|
|
|
The role methods, preference defaults, automatic setup block, and deferred OTA
|
|
block are extracted from production. Only hardware/server endpoints are mocked.
|
|
"""
|
|
from pathlib import Path
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
import test_wifi_ota_start as wifi_start
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
FLAGS = ["-DESP32_PLATFORM=1", "-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1",
|
|
"-DWITH_BRIDGE=1", "-DWITH_WEBCONFIG=1", "-DWITH_MQTT_BRIDGE=1",
|
|
"-DWITH_ESPNOW_BRIDGE=1", "-DLIGHTWEIGHT_WIFI_OTA=1",
|
|
"-DOTA_MANIFEST_BASE=1", "-DMESHCORE_EXPANDED_PARTITION_PROFILE=1",
|
|
"-DWEBCONFIG_UNCONFIGURED_SETUP_TIMEOUT_MS=1800000"]
|
|
|
|
FIXTURE = r'''
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <new>
|
|
#include <helpers/WebConfigBatch.h>
|
|
#include <helpers/PowerManagementUtils.h>
|
|
#include <helpers/WiFiNetworkPowerPolicy.h>
|
|
#include <helpers/WirelessControl.h>
|
|
uint32_t now = 0;
|
|
uint32_t millis() { return now; }
|
|
bool millisHasNowPassed(uint32_t deadline) { return int32_t(now - deadline) >= 0; }
|
|
void delay(unsigned) {}
|
|
using wifi_mode_t = int;
|
|
constexpr int WIFI_OFF = 0, WIFI_MODE_NULL = 0, WIFI_STA = 1, WIFI_AP_STA = 3, WIFI_IF_STA = 0;
|
|
constexpr int ESP_OK = 0;
|
|
constexpr int ESP_ERR_WIFI_NOT_INIT = -1;
|
|
bool sdk_initialized = false, sdk_started = false;
|
|
bool fail_sdk_stop = false, fail_sdk_deinit = false;
|
|
unsigned espnow_owners = 0;
|
|
int sdk_mode = WIFI_OFF, sdk_channel = 1;
|
|
unsigned sdk_stops = 0, sdk_deinits = 0;
|
|
int esp_wifi_get_mode(wifi_mode_t* mode) {
|
|
if (!sdk_initialized) return -1;
|
|
*mode = sdk_mode; return ESP_OK;
|
|
}
|
|
int esp_wifi_stop() {
|
|
++sdk_stops; if (fail_sdk_stop) return -2;
|
|
sdk_started = false; return ESP_OK;
|
|
}
|
|
int esp_wifi_deinit() {
|
|
++sdk_deinits; if (fail_sdk_deinit || sdk_started) return -2;
|
|
sdk_initialized = sdk_started = false; sdk_mode = WIFI_OFF;
|
|
return ESP_OK;
|
|
}
|
|
struct WiFiMock {
|
|
bool initialized_cache = false, started_cache = false, connected = false;
|
|
bool fail_sta = false, fail_off = false, auto_reconnect = false;
|
|
unsigned disconnects = 0, sta_modes = 0, off_modes = 0;
|
|
int getMode() const { return initialized_cache && started_cache ? sdk_mode : WIFI_OFF; }
|
|
int channel() const { return sdk_initialized ? sdk_channel : 0; }
|
|
bool isConnected() const { return connected; }
|
|
void setAutoReconnect(bool enabled) { auto_reconnect = enabled; }
|
|
void disconnect(bool off, bool erase = false) {
|
|
assert(!erase); ++disconnects; connected = false;
|
|
if (off) mode(WIFI_OFF);
|
|
}
|
|
bool mode(int mode) {
|
|
if (mode == WIFI_OFF) ++off_modes;
|
|
if (mode == WIFI_STA) ++sta_modes;
|
|
if ((mode == WIFI_STA && fail_sta) || (mode == WIFI_OFF && fail_off)) return false;
|
|
if (mode == getMode()) return true; // Arduino OFF is a no-op for a raw IDF driver.
|
|
if (mode == WIFI_OFF) {
|
|
initialized_cache = started_cache = false;
|
|
esp_wifi_stop(); esp_wifi_deinit();
|
|
} else {
|
|
initialized_cache = started_cache = true;
|
|
sdk_initialized = sdk_started = true; sdk_mode = mode;
|
|
}
|
|
return true;
|
|
}
|
|
void reset() { *this = {}; sdk_initialized = sdk_started = false; sdk_mode = WIFI_OFF;
|
|
sdk_channel = 1; sdk_stops = sdk_deinits = 0;
|
|
fail_sdk_stop = fail_sdk_deinit = false; }
|
|
} WiFi;
|
|
namespace mesh {
|
|
namespace bridge { constexpr int ESPNOW_FORMAT_WRAPPED = 1; }
|
|
namespace wifi {
|
|
void applyProtocolMask(int) {} void restoreEspNowChannel() {}
|
|
bool espNowChannelConstrained() { return espnow_owners != 0; }
|
|
}
|
|
struct LocalIdentity { unsigned char pub_key[32] = {}; };
|
|
struct Utils { static void toHex(char* out, const unsigned char*, int) { strcpy(out, "test"); } };
|
|
struct Log { template<class... Args> void printf(const char*, Args...) {} };
|
|
Log& usbConsolePort() { static Log log; return log; }
|
|
Log& usbDebugPort() { return usbConsolePort(); }
|
|
}
|
|
constexpr int PUB_KEY_SIZE = 32;
|
|
struct Prefs {
|
|
int bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0;
|
|
bool battery_alert_enabled = false;
|
|
const char* node_name = "test";
|
|
float freq = 915, bw = 125; uint8_t sf = 7, cr = 5, disable_fwd = 0;
|
|
};
|
|
struct ObserverPrefs { char wifi_ssid[33] = {}; };
|
|
struct Board {
|
|
bool ota = false, allow_start = true, allow_stop = true;
|
|
unsigned manifest_calls = 0;
|
|
bool isOTAUpdateRunning() const { return ota; }
|
|
const char* getManufacturerName() const { return "test"; }
|
|
bool startOTAUpdate(const char*, char* reply, bool) {
|
|
if (!allow_start) { strcpy(reply, "ERR: OTA WiFi failed"); return false; }
|
|
WiFi.mode(WIFI_AP_STA); ota = true; strcpy(reply, "Started: http://192.168.4.1/update");
|
|
return true;
|
|
}
|
|
bool stopOTAUpdate(char* reply) {
|
|
if (!allow_stop) { strcpy(reply, "ERR: OTA upload active"); return false; }
|
|
ota = false; WiFi.mode(WIFI_STA); strcpy(reply, "OK - OTA stopped"); return true;
|
|
}
|
|
bool otaFromManifest(const char*, const char*, bool, char* reply) {
|
|
++manifest_calls; strcpy(reply, "ERR: not connected"); return false;
|
|
}
|
|
};
|
|
struct RoleCLI {
|
|
Board board; ObserverPrefs prefs;
|
|
Board* getBoard() { return &board; }
|
|
ObserverPrefs* getObserverPrefs() { return &prefs; }
|
|
bool hasActiveUserGpioTimer() const { return false; }
|
|
};
|
|
struct AbstractBridge {
|
|
bool running = false, stopping = false, allow_flash = true;
|
|
unsigned starts = 0, stops = 0;
|
|
bool isRunning() const { return running; }
|
|
bool isStopping() const { return stopping; }
|
|
bool canFlashAfterStop() const { return allow_flash; }
|
|
void begin() { ++starts; running = true; WiFi.mode(WIFI_STA); }
|
|
void end() { ++stops; running = false; } // MQTT and OTA may retain STA.
|
|
};
|
|
struct ESPNowBridge : AbstractBridge {
|
|
void begin() { if (!running) ++espnow_owners; AbstractBridge::begin(); }
|
|
void end() { if (running) --espnow_owners; AbstractBridge::end(); }
|
|
};
|
|
struct MQTTNodeInfo {
|
|
const char* node_name; float* freq; float* bw;
|
|
uint8_t* sf; uint8_t* cr; uint8_t* repeat_flag; bool repeat_when_nonzero; bool canonical_wifi = false;
|
|
};
|
|
struct MQTTBridge : AbstractBridge {
|
|
bool network_suspended = false;
|
|
bool isNetworkSuspended() const { return network_suspended; }
|
|
template<class... Args> explicit MQTTBridge(Args...) {}
|
|
void setDeviceID(const char*) {} void setFirmwareVersion(const char*) {}
|
|
void setBoardModel(const char*) {} void setBuildDate(const char*) {}
|
|
template<class... Args> void setStatsSources(Args...) {}
|
|
};
|
|
@STOP_OWNED_WIFI@
|
|
struct WebConfigServer {
|
|
static bool enabled;
|
|
static bool allow_start;
|
|
bool running = false, stopping = false, owns;
|
|
uint32_t _setup_started_at = 0;
|
|
int _mode = 0;
|
|
static constexpr int MODE_SETUP = 1;
|
|
static constexpr int MODE_LAN = 2;
|
|
uint32_t foreground_until = 0;
|
|
char _wifi_ssid[33] = {};
|
|
template<class... Args> WebConfigServer(void*, void* prefs, bool owner, Args...) : owns(owner) {
|
|
if (prefs) strcpy(_wifi_ssid, static_cast<ObserverPrefs*>(prefs)->wifi_ssid);
|
|
}
|
|
static bool loadEnabled(bool) { return enabled; }
|
|
static bool hasConfiguredWiFi(const void* prefs) { return static_cast<const ObserverPrefs*>(prefs)->wifi_ssid[0] != 0; }
|
|
bool isRunning() const { return running; }
|
|
bool isStopping() const { return stopping; }
|
|
void updateWiFiOwnership(bool owner) { owns = owner; }
|
|
void requestNetworkAccess() {
|
|
foreground_until = millis() + 120000;
|
|
mesh::wifi::networkPower().requestProbe();
|
|
}
|
|
bool isNetworkForegroundActive(uint32_t tick) const {
|
|
return _mode == MODE_SETUP || stopping
|
|
|| (foreground_until && int32_t(tick - foreground_until) < 0);
|
|
}
|
|
bool startSetupMode(char* reply) {
|
|
if (!allow_start) {
|
|
if (owns) stopOwnedWiFiRadio();
|
|
strcpy(reply, "Err: failed to start AP"); return false;
|
|
}
|
|
_mode = MODE_SETUP; _setup_started_at = millis(); running = true;
|
|
WiFi.mode(WIFI_AP_STA); strcpy(reply, "WebConfig AP started");
|
|
return true;
|
|
}
|
|
bool startAutoMode(char* reply) { return startSetupMode(reply); }
|
|
void requestStop() { running = false; stopping = true; }
|
|
bool stopForOTA(char*) { running = stopping = false; WiFi.mode(WIFI_STA); return true; }
|
|
void tick(uint32_t now) {
|
|
if (stopping) { stopping = false; if (owns) stopOwnedWiFiRadio(); return; }
|
|
@SETUP_WINDOW@
|
|
}
|
|
};
|
|
bool WebConfigServer::enabled = false;
|
|
bool WebConfigServer::allow_start = true;
|
|
constexpr unsigned OTA_TX_DRAIN_TIMEOUT_MS = 1, OTA_MQTT_STOP_SETTLE_MS = 1;
|
|
const char* ota_resolve_base(int) { return "test"; }
|
|
struct MyMesh {
|
|
Prefs _prefs; RoleCLI _cli;
|
|
MQTTBridge* @WORKER@ = nullptr;
|
|
ESPNowBridge espnow_bridge;
|
|
WebConfigServer* _webconfig = nullptr;
|
|
void* _web_terminal = nullptr;
|
|
bool _unconfigured_setup_espnow_suspended = false;
|
|
uint32_t shared_espnow_retry_at = 0, _ota_update_at = 0;
|
|
int _ota_update_channel = 0;
|
|
bool _wc_batch_active = false, _wc_restart_pending = false;
|
|
mesh::LocalIdentity self_id;
|
|
void* _radio = nullptr; void* _ms = nullptr;
|
|
struct { void setBridge(MQTTBridge*) {} } _alerter;
|
|
struct { bool busy() const { return false; } } _local_cli_output;
|
|
struct { bool pending = false; } deferred_cli_command;
|
|
struct { bool isWatchdogObserving() const { return false; }
|
|
bool isCalibratingNoiseFloor() const { return false; } } radio_driver;
|
|
struct { int getNumClients() const { return 0; } } acl;
|
|
bool pending_self_advert = false, saved_radio_apply_pending = false, temp_radio_applied = false;
|
|
unsigned long next_flood_advert = 0, next_local_advert = 0, dirty_contacts_expiry = 0;
|
|
unsigned long next_recent_repeater_sweep = 0, next_battery_alert_check = 0, next_push = 0;
|
|
unsigned long radio_apply_retry_at = 0, set_radio_at = 0, revert_radio_at = 0;
|
|
bool hasPendingOtaApply() const { return false; } bool hasQueuedWorkDue() const { return false; }
|
|
bool hasRetryWorkDue() const { return false; } bool hasScheduledRadioWorkDue() const { return false; }
|
|
bool isMillisTimerDue(unsigned long) const { return false; }
|
|
bool getNextQueueWakeDelay(uint32_t&) const { return false; }
|
|
bool getNextRetryWakeDelay(uint32_t&) const { return false; }
|
|
bool isDualRadioActive() const { return false; }
|
|
uint32_t limitSleepToMillisTimer(unsigned long, uint32_t secs) const { return secs; }
|
|
uint32_t limitSleepToScheduledRadioWork(uint32_t secs) const { return secs; }
|
|
const AbstractBridge* activeBridge() const { return @WORKER@; }
|
|
const char* getFirmwareVer() const { return "test"; } const char* getBuildDate() const { return "test"; }
|
|
const char* getRole() const { return "test"; }
|
|
mesh::LocalIdentity getSelfId() { return self_id; } void* getRTCClock() { return nullptr; }
|
|
void configureBridgeFilter(AbstractBridge*) {} void drainOutbound(unsigned) {} void otaAlert(const char*) {}
|
|
void suspendUnconfiguredSetupBridges(); void serviceIdleWiFi();
|
|
bool startWebConfigImpl(bool force_ap, char* reply, bool automatic_setup);
|
|
bool startWebConfig(bool force_ap, char* reply); bool stopWebConfig(char* reply);
|
|
bool hasPendingWork() const; uint32_t getPowerSaveSleepSeconds(uint32_t) const;
|
|
@INLINE_METHODS@
|
|
void defaults() { @DEFAULTS@ }
|
|
void startupSetup() { @STARTUP@ }
|
|
void servicePortal() { @PORTAL_LOOP@ }
|
|
void manifestAttempt() { @MANIFEST@ }
|
|
~MyMesh() { delete _webconfig; delete @WORKER@; }
|
|
};
|
|
@METHODS@
|
|
@INFRA_BACKEND@
|
|
struct BrowserCLI {
|
|
Board* _board; Prefs* _prefs; MyMesh* _callbacks;
|
|
@OTA_STATE@
|
|
explicit BrowserCLI(MyMesh& node) : _board(node._cli.getBoard()), _prefs(&node._prefs), _callbacks(&node) {}
|
|
void run(const char* command, char* reply) { if (false) { @OTA_CLI@ } }
|
|
};
|
|
@CHECKS@
|
|
'''
|
|
|
|
CHECKS = r'''
|
|
int main() {
|
|
char reply[160] = {};
|
|
MyMesh node; node.defaults();
|
|
assert(node._prefs.bridge_enabled == 1 && node._prefs.espnow_bridge_enabled == 1);
|
|
assert(node.setBridgeState(true));
|
|
assert(node.isMqttBridgeRunning());
|
|
WiFi.connected = true;
|
|
node.startSharedEspNowBridgeIfReady();
|
|
assert(node.espnow_bridge.isRunning());
|
|
node.startupSetup(); // Execute the actual first-boot setup decision after saved defaults.
|
|
assert(node._unconfigured_setup_espnow_suspended && node.isWebConfigActive());
|
|
assert(!node.isMqttBridgeRunning() && !node.espnow_bridge.isRunning());
|
|
assert(node._prefs.bridge_enabled == 1 && node._prefs.espnow_bridge_enabled == 1);
|
|
const auto starts = node.espnow_bridge.starts;
|
|
for (now = 0; now < 1800000; now += 10000) {
|
|
assert(!node.startSharedEspNowBridgeIfReady());
|
|
node.servicePortal(); node.serviceIdleWiFi();
|
|
assert(sdk_started && node.getPowerSaveSleepSeconds(30) == 0);
|
|
}
|
|
now = 1800000;
|
|
node.servicePortal(); // Absolute window requests a stop, even with activity.
|
|
assert(node.isWebConfigActive());
|
|
node.serviceIdleWiFi(); assert(sdk_started); // Still stopping: do not tear down its task.
|
|
node.servicePortal(); node.serviceIdleWiFi();
|
|
assert(!node.isWebConfigActive() && !sdk_initialized && !sdk_started);
|
|
assert(node.getPowerSaveSleepSeconds(30) == 30);
|
|
now += 10000;
|
|
assert(!node.startSharedEspNowBridgeIfReady() && node.espnow_bridge.starts == starts);
|
|
// Manifest failure must not turn preserved defaults into actual running services.
|
|
node._ota_update_at = now;
|
|
node.manifestAttempt(); node.serviceIdleWiFi();
|
|
assert(node._unconfigured_setup_espnow_suspended && !node.espnow_bridge.isRunning());
|
|
assert(!node.isMqttBridgeRunning() && !sdk_started);
|
|
// Explicit setup restart, stop webconfig, browser OTA, stop OTA: remain suspended.
|
|
assert(node.startWebConfig(true, reply)); assert(node.isWebConfigActive());
|
|
assert(node.stopWebConfig(reply));
|
|
node.servicePortal(); node.serviceIdleWiFi();
|
|
BrowserCLI cli(node);
|
|
cli.run("start ota ap", reply); assert(node._cli.board.ota && sdk_started);
|
|
cli.run("start ota ap", reply); // Repeated start does not invent a bridge snapshot.
|
|
cli.run("stop ota", reply); node.serviceIdleWiFi();
|
|
assert(node._unconfigured_setup_espnow_suspended && !sdk_started);
|
|
assert(!node.isMqttBridgeRunning() && !node.espnow_bridge.isRunning());
|
|
// An explicit ESP-NOW start ends the suspension and owns the shared radio.
|
|
assert(node.setEspNowBridgeState(true));
|
|
assert(!node._unconfigured_setup_espnow_suspended && node.espnow_bridge.isRunning());
|
|
node.serviceIdleWiFi(); assert(sdk_started && node.hasPendingWork());
|
|
// Real active ESP-NOW is restored on failed startup and successful shutdown.
|
|
node._cli.board.allow_start = false;
|
|
cli.run("start ota ap", reply); assert(node.espnow_bridge.isRunning());
|
|
node._cli.board.allow_start = true;
|
|
cli.run("start ota ap", reply); assert(!node.espnow_bridge.isRunning());
|
|
node._cli.board.allow_stop = false;
|
|
cli.run("stop ota", reply); assert(!node.espnow_bridge.isRunning() && node._cli.board.ota);
|
|
node._cli.board.allow_stop = true;
|
|
cli.run("stop ota", reply); assert(node.espnow_bridge.isRunning());
|
|
assert(node.setEspNowBridgeState(false));
|
|
node.serviceIdleWiFi(); assert(!sdk_started);
|
|
// Closing a normal configured WebUI must not create a setup suspension.
|
|
strcpy(node._cli.prefs.wifi_ssid, "saved");
|
|
assert(node.startWebConfig(false, reply));
|
|
assert(!node._unconfigured_setup_espnow_suspended);
|
|
node.stopWebConfig(reply); node.servicePortal(); now += 10000;
|
|
assert(node.startSharedEspNowBridgeIfReady());
|
|
node.serviceIdleWiFi(); assert(sdk_started && node.espnow_bridge.isRunning());
|
|
node.setEspNowBridgeState(false); node.serviceIdleWiFi();
|
|
// A stopping MQTT task still owns the driver and prevents manual light sleep.
|
|
node.@WORKER@->stopping = true;
|
|
assert(!sdk_initialized && node.getPowerSaveSleepSeconds(30) == 0);
|
|
WiFi.mode(WIFI_STA);
|
|
node.serviceIdleWiFi(); assert(sdk_started && node.hasPendingWork());
|
|
node.@WORKER@->stopping = false;
|
|
node.serviceIdleWiFi(); assert(!sdk_started && node.getPowerSaveSleepSeconds(30) == 30);
|
|
// The raw-IDF and Arduino facade states differ. Adopt stale OFF before teardown.
|
|
WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA;
|
|
node.serviceIdleWiFi();
|
|
assert(WiFi.sta_modes == 1 && !WiFi.initialized_cache && !sdk_initialized);
|
|
const auto stops = sdk_stops, deinits = sdk_deinits, disconnects = WiFi.disconnects;
|
|
for (int i = 0; i < 10; ++i) node.serviceIdleWiFi();
|
|
assert(sdk_stops == stops && sdk_deinits == deinits && WiFi.disconnects == disconnects);
|
|
// If channel() is unavailable, the SDK-only fallback still reclaims the driver.
|
|
WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA; sdk_channel = 0;
|
|
node.serviceIdleWiFi(); assert(!sdk_initialized && sdk_deinits == 1);
|
|
// A failed Arduino adoption/shutdown is retried, with no raw deinit under stale caches.
|
|
WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA;
|
|
WiFi.fail_sta = true; node.serviceIdleWiFi(); assert(sdk_started && sdk_deinits == 0);
|
|
assert(node.getPowerSaveSleepSeconds(30) == 0);
|
|
WiFi.fail_sta = false; WiFi.fail_off = true;
|
|
node.serviceIdleWiFi(); assert(sdk_started && WiFi.initialized_cache && sdk_deinits == 0);
|
|
assert(node.getPowerSaveSleepSeconds(30) == 0);
|
|
WiFi.fail_off = false; node.serviceIdleWiFi(); assert(!sdk_initialized);
|
|
assert(node.getPowerSaveSleepSeconds(30) == 30);
|
|
WiFi.reset(); sdk_initialized = sdk_started = true; sdk_mode = WIFI_STA; sdk_channel = 0;
|
|
fail_sdk_stop = true; node.serviceIdleWiFi();
|
|
assert(sdk_started && node.getPowerSaveSleepSeconds(30) == 0);
|
|
fail_sdk_stop = false; fail_sdk_deinit = true; node.serviceIdleWiFi();
|
|
assert(sdk_initialized && !sdk_started && node.getPowerSaveSleepSeconds(30) == 0);
|
|
fail_sdk_deinit = false; node.serviceIdleWiFi();
|
|
assert(!sdk_initialized && node.getPowerSaveSleepSeconds(30) == 30);
|
|
// Every live owner blocks cleanup, including a scheduled manifest operation.
|
|
WiFi.mode(WIFI_STA); node._ota_update_at = now + 1000;
|
|
node.serviceIdleWiFi(); assert(sdk_started);
|
|
node._ota_update_at = 0; node.@WORKER@->running = true;
|
|
node.serviceIdleWiFi(); assert(sdk_started);
|
|
node.@WORKER@->running = false; node._cli.board.ota = true;
|
|
node.serviceIdleWiFi(); assert(sdk_started);
|
|
node._cli.board.ota = false; node.serviceIdleWiFi(); assert(!sdk_started);
|
|
}
|
|
'''
|
|
|
|
|
|
class ESP32FullPowerLifecycleTest(unittest.TestCase):
|
|
compile_and_run = wifi_start.WiFiOtaStartTest.compile_and_run
|
|
|
|
def fixture(self, role, checks=CHECKS, infra_backend=""):
|
|
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text()
|
|
header = (ROOT / "examples" / role / "MyMesh.h").read_text()
|
|
worker = "mqtt_bridge" if role == "simple_repeater" else "bridge"
|
|
inline = "\n".join(extract_braced(header, signature) for signature in (
|
|
"bool startSharedEspNowBridgeIfReady()", "bool setEspNowBridgeState(",
|
|
"bool setMqttBridgeState(", "bool setBridgeState(",
|
|
"bool isEspNowBridgeRunning()", "bool isMqttBridgeRunning()",
|
|
"bool isMqttBridgeStopping()",
|
|
"bool isWebConfigActive() const", "bool isWebConfigStopping() const",
|
|
"bool hasWirelessNetworkClient() const", "bool stopWebConfigForOTA("))
|
|
methods = "\n".join(extract_braced(source, signature) for signature in (
|
|
"void MyMesh::suspendUnconfiguredSetupBridges()", "void MyMesh::serviceIdleWiFi()",
|
|
"bool MyMesh::startWebConfig(", "bool MyMesh::startWebConfigImpl(",
|
|
"bool MyMesh::stopWebConfig(",
|
|
"bool MyMesh::hasPendingWork() const", "uint32_t MyMesh::getPowerSaveSleepSeconds("))
|
|
defaults_start = source.index(" // bridge defaults")
|
|
defaults = source[defaults_start:source.index(" _prefs.bridge_delay", defaults_start)]
|
|
startup = source[source.index(" bool start_webui = WebConfigServer::loadEnabled(false);"):]
|
|
startup = startup[:startup.index("#endif\n\n", startup.index(" if (start_webui)"))]
|
|
tick = source.index("_webconfig->tick(millis());")
|
|
portal_start = source.rfind(" if (_webconfig) {", 0, tick)
|
|
portal_loop = extract_braced(source[portal_start:], " if (_webconfig) {")
|
|
manifest = extract_braced(source, " if (_ota_update_at && millisHasNowPassed(_ota_update_at))")
|
|
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
cli_header = (ROOT / "src/helpers/CommonCLI.h").read_text()
|
|
state_begin = cli_header.index(" bool _wifi_ota_resume_mqtt = false;")
|
|
state = cli_header[state_begin:cli_header.index("#endif", state_begin)]
|
|
begin = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
|
end = cli.index(' } else if (memcmp(command, "clock", 5)', begin)
|
|
web_source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
|
window = extract_braced(web_source, " if (WebConfigBatch::unconfiguredSetupWindowExpired(")
|
|
stop_owned = extract_braced(web_source, "static void stopOwnedWiFiRadio()")
|
|
return (FIXTURE.replace("@INLINE_METHODS@", inline.replace(" override", ""))
|
|
.replace("@METHODS@", methods).replace("@DEFAULTS@", defaults)
|
|
.replace("@STARTUP@", startup).replace("@PORTAL_LOOP@", portal_loop)
|
|
.replace("@MANIFEST@", manifest).replace("@OTA_CLI@", cli[begin:end])
|
|
.replace("@OTA_STATE@", state)
|
|
.replace("@INFRA_BACKEND@", infra_backend)
|
|
.replace("@STOP_OWNED_WIFI@", stop_owned)
|
|
.replace("@SETUP_WINDOW@", window).replace("@CHECKS@", checks)
|
|
.replace("@WORKER@", worker))
|
|
|
|
def test_actual_full_startup_timeout_retry_ota_and_teardown(self):
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
with self.subTest(role=role):
|
|
self.compile_and_run(self.fixture(role), *FLAGS)
|
|
|
|
def test_suspended_mqtt_idle_portal_and_foreground_ownership(self):
|
|
checks = r'''
|
|
int main() {
|
|
MyMesh node; char reply[160] = {};
|
|
strcpy(node._cli.prefs.wifi_ssid, "working-network");
|
|
auto& power = mesh::wifi::networkPower();
|
|
power.setManaged(true);
|
|
assert(node.setMqttBridgeState(true));
|
|
assert(node.startWebConfig(false, reply));
|
|
node._webconfig->_mode = WebConfigServer::MODE_LAN;
|
|
now += 120001; // Explicit-start lease has elapsed; listener is idle.
|
|
power.consumeProbeRequest();
|
|
node.@WORKER@->network_suspended = true;
|
|
power.setSuspended(true);
|
|
unsigned off_before = WiFi.off_modes;
|
|
node.serviceIdleWiFi();
|
|
assert(WiFi.off_modes == off_before && sdk_started);
|
|
assert(node.hasPendingWork()); // Cannot sleep before worker shutdown completes.
|
|
WiFi.mode(WIFI_OFF); // Only the Core 0 network owner performs this shutdown.
|
|
node.serviceIdleWiFi();
|
|
assert(!sdk_initialized && !node.hasPendingWork());
|
|
assert(node.getPowerSaveSleepSeconds(30) == 30);
|
|
// An operator can wake the existing listener without creating a second
|
|
// server, taking station ownership, or deinitializing the worker's driver.
|
|
assert(node.startWebConfig(false, reply));
|
|
assert(power.consumeProbeRequest());
|
|
assert(node._webconfig->isNetworkForegroundActive(now));
|
|
assert(node.hasPendingWork());
|
|
now += 120001;
|
|
assert(!node.hasPendingWork());
|
|
power.setHold(mesh::wifi::NetworkPowerHold::OtaSeeder, true);
|
|
assert(node.hasPendingWork());
|
|
power.setHold(mesh::wifi::NetworkPowerHold::OtaSeeder, false);
|
|
assert(!node.hasPendingWork());
|
|
power.setSuspended(false);
|
|
node.@WORKER@->network_suspended = false;
|
|
node.serviceIdleWiFi();
|
|
assert(node.hasPendingWork()); // Resume is owned by the MQTT worker too.
|
|
power.setManaged(false);
|
|
}
|
|
'''
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
with self.subTest(role=role):
|
|
self.compile_and_run(self.fixture(role, checks), *FLAGS)
|
|
|
|
def test_primary_radios_and_wifi_disabled_images_do_not_reclaim_wifi(self):
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text()
|
|
cleanup = extract_braced(source, "void MyMesh::serviceIdleWiFi()")
|
|
fixture = "struct MyMesh { void serviceIdleWiFi(); };\n" + cleanup
|
|
fixture += "\nint main() { MyMesh node; node.serviceIdleWiFi(); }\n"
|
|
# No WiFi declarations at all: these disabled bodies cannot reference it.
|
|
for flags in ([], ["-DMESH_PRIMARY_ESPNOW=1", "-DWITH_ESPNOW_BRIDGE=1"],
|
|
["-DMESH_ESPNOW_RADIO=1", "-DWITH_ESPNOW_BRIDGE=1"]):
|
|
with self.subTest(role=role, flags=flags):
|
|
self.compile_and_run(fixture, *flags)
|
|
|
|
def test_non_full_setup_retains_legacy_bridge_retry_policy(self):
|
|
checks = r'''
|
|
int main() {
|
|
MyMesh node; node.defaults();
|
|
node.setBridgeState(true); WiFi.connected = true;
|
|
node.startSharedEspNowBridgeIfReady(); node.startupSetup();
|
|
assert(node.isWebConfigActive() && !node._unconfigured_setup_espnow_suspended);
|
|
now += 10000;
|
|
assert(node.startSharedEspNowBridgeIfReady());
|
|
assert(node.espnow_bridge.isRunning());
|
|
}
|
|
'''
|
|
flags = [flag for flag in FLAGS if not flag.startswith("-DMESHCORE_EXPANDED_PARTITION_PROFILE=")]
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
with self.subTest(role=role):
|
|
self.compile_and_run(self.fixture(role, checks), *flags)
|
|
|
|
def test_manual_wifi_and_master_restore_preserve_actual_requested_services(self):
|
|
backend = extract_braced((ROOT / "examples/InfrastructureWireless.h").read_text(),
|
|
"class InfrastructureWirelessBackend") + ";"
|
|
backend = "MyMesh the_mesh; Board& board = the_mesh._cli.board;\n" + backend
|
|
checks = r'''
|
|
void command(const char* text) {
|
|
char reply[160] = {};
|
|
auto& control = mesh::wireless::control();
|
|
assert(control.handle(text, reply, sizeof(reply), now, mesh::wireless::Independent));
|
|
assert(strncmp(reply, "OK", 2) == 0);
|
|
for (unsigned attempt = 0; attempt < 5 && control.pending(); ++attempt) {
|
|
now += 300; the_mesh.servicePortal(); control.service(now); the_mesh.serviceIdleWiFi();
|
|
}
|
|
assert(!control.pending());
|
|
control.handle("get 2.4ghz", reply, sizeof(reply), now, mesh::wireless::Independent);
|
|
assert(!strstr(reply, "failed"));
|
|
}
|
|
int main() {
|
|
using namespace mesh::wireless;
|
|
InfrastructureWirelessBackend backend;
|
|
auto& control = mesh::wireless::control(); control.begin(backend);
|
|
the_mesh.defaults(); assert(the_mesh._cli.prefs.wifi_ssid[0] == 0);
|
|
the_mesh.startupSetup();
|
|
assert(the_mesh._unconfigured_setup_espnow_suspended && !the_mesh.isEspNowBridgeRunning());
|
|
assert(the_mesh._prefs.bridge_enabled && the_mesh._prefs.espnow_bridge_enabled);
|
|
char reply[160] = {};
|
|
the_mesh.stopWebConfig(reply); the_mesh.servicePortal(); the_mesh.serviceIdleWiFi();
|
|
assert(backend.enabled() == 0 && !sdk_initialized);
|
|
constexpr uint8_t requested = @REQUESTED@;
|
|
if (requested & EspNow) command("set espnow on");
|
|
const auto espnow_stops = the_mesh.espnow_bridge.stops;
|
|
if (requested & mesh::wireless::WiFi) command("set wifi on");
|
|
// Exact hardware regression: explicit ESP-NOW, then manual WiFi on, no SSID.
|
|
assert(backend.enabled() == requested);
|
|
assert(the_mesh.espnow_bridge.stops == espnow_stops);
|
|
assert(!the_mesh.isMqttBridgeRunning());
|
|
for (unsigned repeat = 0; repeat < 2; ++repeat) {
|
|
command("set 2.4ghz off");
|
|
assert(control.masterOff() && backend.enabled() == 0 && !sdk_initialized);
|
|
command("set 2.4ghz on");
|
|
assert(!control.masterOff() && backend.enabled() == requested);
|
|
assert(the_mesh._prefs.bridge_enabled && the_mesh._prefs.espnow_bridge_enabled);
|
|
// Saved default intent must not add ESP-NOW to a WiFi-only restore.
|
|
if (!(requested & EspNow)) {
|
|
assert(the_mesh._unconfigured_setup_espnow_suspended);
|
|
assert(!the_mesh.startSharedEspNowBridgeIfReady());
|
|
}
|
|
}
|
|
if (requested == (mesh::wireless::WiFi | EspNow)) {
|
|
// The direct forced manual portal route preserves the same live bridge.
|
|
the_mesh.stopWebConfig(reply); the_mesh.servicePortal(); the_mesh.serviceIdleWiFi();
|
|
assert(the_mesh.isEspNowBridgeRunning() && sdk_started);
|
|
const auto stops = the_mesh.espnow_bridge.stops;
|
|
the_mesh.startWebConfig(true, reply);
|
|
assert(the_mesh.isWebConfigActive() && the_mesh.isEspNowBridgeRunning());
|
|
assert(the_mesh.espnow_bridge.stops == stops && !the_mesh._unconfigured_setup_espnow_suspended);
|
|
}
|
|
}
|
|
'''
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
for mask in ("EspNow", "mesh::wireless::WiFi", "(mesh::wireless::WiFi | EspNow)"):
|
|
with self.subTest(role=role, requested=mask):
|
|
body = self.fixture(role, checks.replace("@REQUESTED@", mask), backend)
|
|
self.compile_and_run(body, *FLAGS, "-DESP32=1")
|
|
if mask.startswith("("):
|
|
# Reintroducing the manual suspension must break the
|
|
# exact command/replay regression, not just a source check.
|
|
old = body.replace("return startWebConfigImpl(force_ap, reply, false);",
|
|
"return startWebConfigImpl(force_ap, reply, true);")
|
|
self.assertNotEqual(old, body)
|
|
with self.assertRaises(AssertionError):
|
|
self.compile_and_run(old, *FLAGS, "-DESP32=1")
|
|
|
|
def test_failed_portal_preserves_explicit_bridge_and_prior_setup_suspension(self):
|
|
checks = r'''
|
|
struct WirelessBackend : mesh::wireless::Backend {
|
|
uint8_t state = mesh::wireless::WiFi;
|
|
uint8_t available() const override { return mesh::wireless::WiFi; }
|
|
uint8_t enabled() const override { return state; }
|
|
uint8_t clients() const override { return mesh::wireless::Independent; }
|
|
mesh::wireless::Result set(uint8_t service, bool on) override {
|
|
state = on ? state | service : state & ~service; return mesh::wireless::Result::Done;
|
|
}
|
|
};
|
|
int main() {
|
|
MyMesh node; node.defaults(); char reply[160] = {};
|
|
assert(node.setEspNowBridgeState(true));
|
|
WebConfigServer::allow_start = false;
|
|
for (bool force_ap : {false, true}) {
|
|
assert(node.startWebConfig(force_ap, reply));
|
|
assert(strstr(reply, "failed to start AP"));
|
|
assert(node.espnow_bridge.isRunning() && !node._unconfigured_setup_espnow_suspended);
|
|
assert(node._prefs.espnow_bridge_enabled == 1 && node._prefs.bridge_enabled == 1);
|
|
node.servicePortal(); node.serviceIdleWiFi();
|
|
assert(!node._webconfig && sdk_started && node.espnow_bridge.isRunning());
|
|
}
|
|
WirelessBackend backend;
|
|
mesh::wireless::control().begin(backend);
|
|
assert(mesh::wireless::control().handle("set wifi off force", reply, sizeof(reply), now,
|
|
mesh::wireless::Independent));
|
|
now += 300; mesh::wireless::control().service(now);
|
|
assert(mesh::wireless::control().blocked(mesh::wireless::WiFi));
|
|
const auto espnow_stops = node.espnow_bridge.stops;
|
|
node.startWebConfig(true, reply);
|
|
assert(strstr(reply, "WiFi disabled") && !node._webconfig);
|
|
assert(node.espnow_bridge.stops == espnow_stops && node.espnow_bridge.isRunning());
|
|
assert(!node._unconfigured_setup_espnow_suspended);
|
|
assert(mesh::wireless::control().allowService(mesh::wireless::WiFi));
|
|
// Do not interrupt an active MQTT task that cannot be synchronously restarted.
|
|
node.setBridgeState(true); WiFi.connected = true; now += 10000;
|
|
node.startSharedEspNowBridgeIfReady();
|
|
const auto mqtt_stops = node.@WORKER@->stops, second_espnow_stops = node.espnow_bridge.stops;
|
|
node.startWebConfig(false, reply);
|
|
assert(strstr(reply, "MQTT bridge is running") && !node._webconfig);
|
|
assert(node.isMqttBridgeRunning() && node.espnow_bridge.isRunning());
|
|
assert(node.@WORKER@->stops == mqtt_stops && node.espnow_bridge.stops == second_espnow_stops);
|
|
node.@WORKER@->running = false; node.@WORKER@->stopping = true;
|
|
node.startWebConfig(true, reply);
|
|
assert(strstr(reply, "MQTT bridge is stopping") && !node._webconfig);
|
|
assert(node.@WORKER@->stops == mqtt_stops && node.espnow_bridge.stops == second_espnow_stops);
|
|
// A previous successful setup suspension must survive a later AP error.
|
|
MyMesh cold; cold.defaults(); WebConfigServer::allow_start = true;
|
|
cold.startupSetup(); cold.stopWebConfig(reply); cold.servicePortal(); cold.serviceIdleWiFi();
|
|
assert(cold._unconfigured_setup_espnow_suspended && !cold.espnow_bridge.isRunning());
|
|
WebConfigServer::allow_start = false;
|
|
cold.startWebConfig(true, reply); cold.servicePortal();
|
|
assert(cold._unconfigured_setup_espnow_suspended && !cold.espnow_bridge.isRunning());
|
|
assert(!cold.isMqttBridgeRunning() && cold._prefs.espnow_bridge_enabled == 1);
|
|
}
|
|
'''
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
with self.subTest(role=role):
|
|
self.compile_and_run("#include <initializer_list>\n" + self.fixture(role, checks), *FLAGS)
|
|
|
|
def test_first_boot_lifecycle_rejects_unsuspended_background_retry(self):
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
with self.subTest(role=role):
|
|
body = self.fixture(role)
|
|
old = body.replace("if (_unconfigured_setup_espnow_suspended) return false;", "")
|
|
self.assertNotEqual(old, body)
|
|
# The pre-fix periodic retry revives ESP-NOW during setup and
|
|
# keeps the radio running after its absolute AP window.
|
|
with self.assertRaises(AssertionError):
|
|
self.compile_and_run(old, *FLAGS)
|
|
|
|
def test_role_loop_calls_cleanup_after_portal_service_and_before_sleep(self):
|
|
for role in ("simple_repeater", "simple_room_server"):
|
|
source = (ROOT / "examples" / role / "MyMesh.cpp").read_text()
|
|
signature = ("void __attribute__((noinline)) MyMesh::servicePostMeshLoop()"
|
|
if role == "simple_repeater" else "void MyMesh::loop()")
|
|
loop = extract_braced(source, signature)
|
|
self.assertEqual(loop.count("serviceIdleWiFi();"), 1)
|
|
self.assertLess(loop.index("_webconfig->tick(millis());"), loop.index("serviceIdleWiFi();"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|