Files
MeshCore/test/test_wifi_ota_lifecycle.py
mikecarper bd4cbf281f 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.
2026-10-07 02:07:06 -07:00

450 lines
20 KiB
Python

"""Execute production WiFi ownership, ESP-NOW cleanup and ElegantOTA startup.
All lifecycle regressions are required to pass. Compilation, every fixture
execution and observation parsing happen in setUpClass.
OTA startup coverage deliberately selects ElegantOTA, not LightweightOTA.
"""
from pathlib import Path
import json
import os
import subprocess
import tempfile
import unittest
from test_radio_receive_contract import method
ROOT = Path(__file__).resolve().parents[1]
SCENARIOS = (
"owners_none", "owners_web", "owners_mqtt", "owners_espnow", "owners_httpota",
"owners_httpota_sta", "cleanup_none", "cleanup_web", "cleanup_mqtt", "cleanup_espnow",
"cleanup_httpota", "cleanup_httpota_sta",
"ota_ap_fresh", "ota_ap_repeat", "ota_sta_repeat", "ota_stale_ap", "ota_ap_failure",
)
STUBS = r'''
#include <helpers/WirelessControl.h>
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>
#define ESP32 1
#define WITH_WEBCONFIG 1
#define WITH_MQTT_BRIDGE 1
#define WITH_ESPNOW_BRIDGE 1
#define MESH_DEBUG_PRINTLN(...) ((void)0)
constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0;
enum wifi_mode_t { WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2 };
constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF;
constexpr wifi_mode_t WIFI_MODE_AP = WIFI_AP;
constexpr int WIFI_IF_STA = 0, WIFI_IF_AP = 1, WIFI_AUTH_OPEN = 0;
constexpr int WIFI_SCAN_RUNNING = -1, AP_STARTED_BIT = 1;
struct wifi_config_t {
struct {
uint8_t ssid[32] = {};
uint8_t ssid_len = 0, ssid_hidden = 0, channel = 1;
int authmode = WIFI_AUTH_OPEN;
} ap;
};
static unsigned clock_ms = 100;
unsigned millis() { return clock_ms; }
void delay(unsigned amount) { clock_ms += amount; }
int esp_wifi_stop();
int esp_wifi_deinit();
constexpr int ESP_OK = 0;
constexpr int LISTEN = 10;
struct IPAddress {
uint32_t value = 0;
IPAddress() = default;
IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d)
: value((uint32_t(a) << 24) | (uint32_t(b) << 16) | (uint32_t(c) << 8) | d) {}
explicit operator uint32_t() const { return value; }
std::string toString() const {
char text[16];
std::snprintf(text, sizeof(text), "%u.%u.%u.%u", (value >> 24) & 255,
(value >> 16) & 255, (value >> 8) & 255, value & 255);
return text;
}
};
// Raw SDK lifecycle is separate from Arduino's cached initialization state.
// A raw stop/deinit must not magically update wrapper caches or ota_server.
bool sdk_initialized = false, sdk_started = false, sdk_ap_active = false;
struct MockWiFi {
int station_status = WL_DISCONNECTED, ap_start_calls = 0;
bool ap_config_ok = true, ap_start_ok = true;
bool initialized_cache = false, started_cache = false;
IPAddress station_ip, ap_ip, configured_ap_ip;
int ap_protocol = 15;
wifi_config_t ap_config;
int getStatusBits() const { return sdk_ap_active ? AP_STARTED_BIT : 0; }
int status() const { return station_status; }
wifi_mode_t getMode() const {
return started_cache && sdk_started ? (sdk_ap_active ? WIFI_AP : WIFI_STA) : WIFI_OFF;
}
int channel() const { return initialized_cache && sdk_initialized ? 1 : 0; }
bool mode(wifi_mode_t requested) {
if (requested == getMode()) return true;
if (requested == WIFI_OFF) {
esp_wifi_stop(); esp_wifi_deinit();
initialized_cache = started_cache = false;
} else {
sdk_initialized = sdk_started = initialized_cache = started_cache = true;
if (!(requested & WIFI_AP)) sdk_ap_active = false;
}
return true;
}
IPAddress localIP() const { return station_ip; }
IPAddress softAPIP() const { return ap_ip; }
bool softAPConfig(IPAddress ip, IPAddress, IPAddress) {
configured_ap_ip = ip;
return ap_config_ok;
}
bool softAP(const char* ssid, const char*, int channel = 1) {
assert(channel == 1);
++ap_start_calls;
if (ap_start_ok) {
sdk_initialized = sdk_started = sdk_ap_active = true;
initialized_cache = started_cache = true;
ap_ip = configured_ap_ip;
ap_config.ap.ssid_len = strlen(ssid);
memcpy(ap_config.ap.ssid, ssid, strlen(ssid));
ap_config.ap.channel = channel;
}
return ap_start_ok;
}
void softAPdisconnect(bool) { sdk_ap_active = false; ap_ip = IPAddress(); }
int scanNetworks(bool, bool, bool, int, uint8_t) { return 0; }
int scanComplete() const { return 0; }
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; }
int accessPointChannel() { return 1; }
uint8_t stationScanChannel() { return 0; }
bool espNowChannelConstrained() { return false; }
} }
int esp_wifi_get_mode(wifi_mode_t*);
int esp_wifi_scan_stop() { return ESP_OK; }
int esp_wifi_get_config(int, wifi_config_t* config) { *config = WiFi.ap_config; return ESP_OK; }
int esp_wifi_get_protocol(int, uint8_t* value) { *value = WiFi.ap_protocol; return ESP_OK; }
@AP_LIFECYCLE_POLICY@
int wifi_stop_calls = 0, wifi_deinit_calls = 0;
int esp_wifi_stop() {
++wifi_stop_calls;
sdk_started = sdk_ap_active = false;
WiFi.station_status = WL_DISCONNECTED;
WiFi.station_ip = IPAddress();
WiFi.ap_ip = IPAddress();
return 0;
}
int esp_wifi_deinit() { ++wifi_deinit_calls; sdk_initialized = false; return 0; }
int esp_wifi_get_mode(wifi_mode_t* mode) {
if (!sdk_initialized) return -1;
*mode = sdk_ap_active ? WIFI_AP : WIFI_STA;
return ESP_OK;
}
int server_count = 0, server_begin_calls = 0;
struct AsyncWebServerRequest {
template<class... Args> void send(Args...) {}
};
struct AsyncWebServer {
bool listening = false;
explicit AsyncWebServer(int port) { assert(port == 80); ++server_count; }
template<class Handler> void on(const char*, int, Handler) {}
void begin() { ++server_begin_calls; listening = true; }
void end() { listening = false; }
int state() const { return listening ? LISTEN : 0; }
};
struct MockElegantOTA {
void setID(const char*) {}
void begin(AsyncWebServer* server) { assert(server != nullptr); }
bool setEnabled(bool) { return true; }
} AsyncElegantOTA;
static AsyncWebServer* async_ota_host = nullptr;
struct MockSPIFFS {} SPIFFS;
class ESP32Board {
public:
bool inhibit_sleep = false;
bool ota_started_ap = false;
bool ota_started_radio = false;
AsyncWebServer* ota_server = nullptr;
~ESP32Board() { delete ota_server; }
const char* getManufacturerName() const { return "test board"; }
bool startOTAUpdate(const char* id, char reply[], bool force_ap);
@OTA_RUNNING@
} board;
struct MockMesh {
bool web = false, mqtt = false, espnow = false;
bool isWebConfigActive() const { return web; }
bool isMqttBridgeRunning() const { return mqtt; }
bool isEspNowBridgeRunning() const { return espnow; }
} the_mesh;
// Only platform endpoints are mocked: enabled() and the controller facade
// below are production, never a hard-coded "WiFi owns everything" backend.
class InfrastructureWirelessBackend : public mesh::wireless::Backend {
public:
uint8_t available() const override { return mesh::wireless::WiFi | mesh::wireless::EspNow; }
@ENABLED@
uint8_t clients() const override { return mesh::wireless::Independent; }
mesh::wireless::Result set(uint8_t, bool) override { return mesh::wireless::Result::Done; }
};
@CLEANUP@
@START_OTA@
'''
CHECKS = r'''
void seedStation() {
sdk_initialized = sdk_started = true;
WiFi.initialized_cache = WiFi.started_cache = true;
WiFi.station_status = WL_CONNECTED;
WiFi.station_ip = IPAddress(10, 0, 0, 7);
}
bool endpointUsable() {
return sdk_initialized && sdk_started
&& ((WiFi.station_status == WL_CONNECTED && WiFi.station_ip.value != 0)
|| (sdk_ap_active && WiFi.ap_ip.value != 0 && WiFi.ap_protocol == 7));
}
int main(int argc, char** argv) {
assert(argc == 2);
const std::string scenario = argv[1];
InfrastructureWirelessBackend backend;
mesh::wireless::control().begin(backend);
char reply[160] = {};
bool first_started = false, started = false;
if (scenario == "owners_web" || scenario == "cleanup_web") {
the_mesh.web = true;
const IPAddress ip(192, 168, 4, 1), mask(255, 255, 255, 0);
assert(WiFi.softAPConfig(ip, ip, mask) && WiFi.softAP("setup", nullptr));
mesh::wifi::applyAccessPointProtocolMask();
assert(endpointUsable());
} else if (scenario == "owners_mqtt" || scenario == "cleanup_mqtt") {
the_mesh.mqtt = true;
seedStation();
assert(endpointUsable());
} else if (scenario == "owners_espnow" || scenario == "cleanup_espnow") {
the_mesh.espnow = true;
seedStation();
}
else if (scenario == "owners_httpota" || scenario == "cleanup_httpota"
|| scenario == "owners_httpota_sta" || scenario == "cleanup_httpota_sta"
|| scenario == "ota_ap_fresh" || scenario == "ota_ap_repeat"
|| scenario == "ota_sta_repeat" || scenario == "ota_stale_ap") {
if (scenario == "ota_sta_repeat" || scenario == "owners_httpota_sta"
|| scenario == "cleanup_httpota_sta") seedStation();
first_started = board.startOTAUpdate("test", reply, false);
// Preconditions for fault injection are checked before expectedFailure.
assert(first_started && board.isOTAUpdateRunning());
assert(!the_mesh.web && !the_mesh.mqtt);
assert(endpointUsable() && sdk_initialized && sdk_started);
assert(WiFi.initialized_cache && WiFi.started_cache);
assert(std::strstr(reply, "0.0.0.0") == nullptr);
assert(server_count == 1 && server_begin_calls == 1);
started = first_started;
} else if (scenario == "ota_ap_failure") {
WiFi.ap_start_ok = false;
started = board.startOTAUpdate("test", reply, true);
} else if (scenario == "cleanup_none") {
seedStation(); // An unowned driver is actually running before cleanup.
} else if (scenario != "owners_none") {
return 2;
}
if (scenario.compare(0, 8, "cleanup_") == 0) stopBridgeWiFiIfUnused();
if (scenario == "ota_stale_ap") {
// Inject raw-driver teardown, not OTA's recovery behavior. Wrapper caches
// and the server pointer remain stale while the live AP/driver disappear.
// The real startOTAUpdate() must either repair the AP or report failure.
esp_wifi_stop();
esp_wifi_deinit();
assert(!sdk_initialized && !sdk_started && !sdk_ap_active);
assert(WiFi.initialized_cache && WiFi.started_cache && board.isOTAUpdateRunning());
started = board.startOTAUpdate("test", reply, true);
} else if (scenario == "ota_ap_repeat" || scenario == "ota_sta_repeat") {
started = board.startOTAUpdate("test", reply, false);
}
std::cout << "{\"scenario\":\"" << scenario << "\",\"enabled\":" << unsigned(backend.enabled())
<< ",\"stop_calls\":" << wifi_stop_calls << ",\"deinit_calls\":" << wifi_deinit_calls
<< ",\"first_started\":" << first_started << ",\"started\":" << started
<< ",\"zero_url\":" << (std::strstr(reply, "Started: http://0.0.0.0/") != nullptr)
<< ",\"zero_ip\":" << ((WiFi.status() == WL_CONNECTED ? WiFi.localIP() : WiFi.softAPIP()).value == 0)
<< ",\"endpoint_usable\":" << endpointUsable()
<< ",\"sdk_initialized\":" << sdk_initialized << ",\"sdk_started\":" << sdk_started
<< ",\"sdk_ap_active\":" << sdk_ap_active
<< ",\"initialized_cache\":" << WiFi.initialized_cache << ",\"started_cache\":" << WiFi.started_cache
<< ",\"station_ip\":\"" << WiFi.station_ip.toString() << "\",\"ap_ip\":\"" << WiFi.ap_ip.toString() << "\""
<< ",\"reply\":\"" << reply << "\",\"server_count\":" << server_count
<< ",\"server_begin_calls\":" << server_begin_calls << ",\"ap_start_calls\":" << WiFi.ap_start_calls
<< ",\"ota_running\":" << board.isOTAUpdateRunning()
<< ",\"sleep_inhibited\":" << board.inhibit_sleep << "}\n";
}
'''
def production_harness():
"""Extract real bodies, explicitly selecting ElegantOTA, not LightweightOTA."""
infrastructure = (ROOT / "examples/InfrastructureWireless.h").read_text()
bridge = (ROOT / "src/helpers/bridges/ESPNowBridge.cpp").read_text()
board_source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
board_header = (ROOT / "src/helpers/ESP32Board.h").read_text()
elegant = board_source.index(
"#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)")
start_ota = method(board_source[elegant:], "bool ESP32Board::startOTAUpdate(")
enabled = method(infrastructure, "uint8_t enabled() const override")
cleanup = method(bridge, "static void stopBridgeWiFiIfUnused(")
ota_running = method(board_header, "bool isOTAUpdateRunning() const override")
ap_policy = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text()
return (STUBS.replace("@ENABLED@", enabled).replace("@CLEANUP@", cleanup)
.replace("@START_OTA@", start_ota)
.replace("@AP_LIFECYCLE_POLICY@", ap_policy[ap_policy.index("namespace mesh {"):])
.replace("@OTA_RUNNING@", ota_running.replace(" override", "")) + CHECKS)
class WifiOtaLifecycleTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
directory = tempfile.TemporaryDirectory(prefix="meshcore-wifi-ota-")
cls.addClassCleanup(directory.cleanup)
folder = Path(directory.name)
source = folder / "lifecycle.cpp"
binary = folder / ("lifecycle.exe" if os.name == "nt" else "lifecycle")
source.write_text(production_harness())
built = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I", str(ROOT / "src"), str(source), "-o", str(binary),
], capture_output=True, text=True, timeout=60)
if built.returncode:
raise RuntimeError(built.stdout + built.stderr)
cls.observations = {}
for scenario in SCENARIOS:
result = subprocess.run([str(binary), scenario], check=True,
capture_output=True, text=True, timeout=10)
observation = json.loads(result.stdout)
if observation["scenario"] != scenario:
raise ValueError(f"Wrong scenario from fixture: {observation!r}")
# Missing/malformed metrics must not become an expected assertion
# failure merely because only a known-bug test consumes that field.
numeric_fields = (
"enabled", "stop_calls", "deinit_calls", "first_started", "started",
"zero_url", "zero_ip", "server_count", "server_begin_calls",
"ap_start_calls", "ota_running", "sleep_inhibited",
"endpoint_usable", "sdk_initialized", "sdk_started", "sdk_ap_active",
"initialized_cache", "started_cache",
)
if (any(type(observation.get(field)) is not int for field in numeric_fields)
or any(type(observation.get(field)) is not str
for field in ("reply", "station_ip", "ap_ip"))):
raise ValueError(f"Malformed fixture metrics: {observation!r}")
cls.observations[scenario] = observation
def test_no_service_has_no_wifi_owner(self):
self.assertEqual(self.observations["owners_none"]["enabled"], 0)
def test_webconfig_owns_wifi(self):
self.assertEqual(self.observations["owners_web"]["enabled"], 1)
def test_mqtt_owns_wifi(self):
self.assertEqual(self.observations["owners_mqtt"]["enabled"], 1)
def test_espnow_alone_is_not_infrastructure_wifi(self):
self.assertEqual(self.observations["owners_espnow"]["enabled"], 4)
def test_http_ota_alone_owns_wifi(self):
"""HTTP OTA owns WiFi in both AP and STA modes."""
self.assertEqual(tuple(self.observations[scenario]["enabled"] & 1
for scenario in ("owners_httpota", "owners_httpota_sta")), (1, 1))
def assert_cleanup(self, scenario, count):
observation = self.observations[scenario]
self.assertEqual(observation["stop_calls"], count)
self.assertEqual(observation["deinit_calls"], count)
self.assertEqual(observation["sdk_initialized"], 1 - count)
self.assertEqual(observation["sdk_started"], 1 - count)
self.assertEqual(observation["initialized_cache"], 1 - count)
self.assertEqual(observation["started_cache"], 1 - count)
self.assertEqual(observation["endpoint_usable"], 1 - count)
if count == 0:
if scenario == "cleanup_web":
self.assertEqual(observation["ap_ip"], "192.168.4.1")
else:
self.assertEqual(observation["station_ip"], "10.0.0.7")
def test_no_owner_stops_and_deinitializes_wifi(self):
self.assert_cleanup("cleanup_none", 1)
def test_webconfig_owner_protects_wifi_from_bridge_cleanup(self):
self.assert_cleanup("cleanup_web", 0)
def test_mqtt_owner_protects_wifi_from_bridge_cleanup(self):
self.assert_cleanup("cleanup_mqtt", 0)
def test_espnow_without_infrastructure_allows_driver_cleanup(self):
self.assert_cleanup("cleanup_espnow", 1)
def test_http_ota_owner_protects_wifi_from_bridge_cleanup(self):
"""Bridge cleanup retains the AP or STA owned only by HTTP OTA."""
self.assertEqual(tuple((self.observations[scenario]["stop_calls"],
self.observations[scenario]["deinit_calls"],
self.observations[scenario]["endpoint_usable"],
self.observations[scenario]["station_ip" if scenario.endswith("_sta") else "ap_ip"])
for scenario in ("cleanup_httpota", "cleanup_httpota_sta")),
((0, 0, 1, "192.168.4.1"), (0, 0, 1, "10.0.0.7")))
def assert_healthy_ota(self, scenario, url, ap_starts):
observation = self.observations[scenario]
self.assertTrue(observation["first_started"] and observation["started"])
network = "Join WiFi MeshCore-OTA" if url == "192.168.4.1" else "Use same WiFi/LAN"
self.assertEqual(observation["reply"], f"Started: http://{url}/update - WiFi on; {network}")
self.assertEqual(observation["server_count"], 1)
self.assertEqual(observation["server_begin_calls"], 1)
self.assertEqual(observation["ap_start_calls"], ap_starts)
self.assertTrue(observation["sdk_initialized"] and observation["sdk_started"]
and observation["endpoint_usable"])
self.assertTrue(observation["ota_running"] and observation["sleep_inhibited"])
def test_fresh_ap_ota_start(self):
self.assert_healthy_ota("ota_ap_fresh", "192.168.4.1", 1)
def test_healthy_ap_repeat_start_is_idempotent(self):
self.assert_healthy_ota("ota_ap_repeat", "192.168.4.1", 1)
def test_healthy_station_repeat_start_is_idempotent(self):
self.assert_healthy_ota("ota_sta_repeat", "10.0.0.7", 0)
def test_stale_server_cannot_claim_success_at_zero_ap_ip(self):
"""A non-null ElegantOTA server still validates/recovers a dead AP."""
observation = self.observations["ota_stale_ap"]
if observation["started"]:
self.assertTrue(observation["endpoint_usable"])
self.assertFalse(observation["zero_ip"] or observation["zero_url"])
self.assertTrue(observation["reply"].startswith("Started: http://"))
else:
self.assertTrue(observation["reply"].startswith(("ERR", "Error")))
self.assertFalse(observation["reply"].startswith("Started:"))
def test_ap_start_failure_is_not_reported_as_success(self):
observation = self.observations["ota_ap_failure"]
self.assertFalse(observation["started"])
self.assertEqual(observation["reply"], "ERR: OTA WiFi failed")
self.assertEqual(observation["server_count"], 0)
self.assertFalse(observation["ota_running"] or observation["sleep_inhibited"])
if __name__ == "__main__":
unittest.main()