777 lines
34 KiB
Python
777 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise the actual browser OTA start/stop methods with fake WiFi and servers."""
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
|
|
from test_replay_reset_integration import extract_braced
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
class WiFiOtaStartTest(unittest.TestCase):
|
|
def compile_and_run(self, source, *flags):
|
|
compiler = os.environ.get("CXX") or shutil.which("g++") or shutil.which("clang++")
|
|
self.assertIsNotNone(compiler, "A host C++17 compiler is required")
|
|
with tempfile.TemporaryDirectory(prefix="wifi-ota-start-") as directory:
|
|
path = Path(directory)
|
|
(path / "test.cpp").write_text(source, encoding="ascii")
|
|
sanitizer = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
|
|
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
|
|
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra",
|
|
*sanitizer, *flags, "-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")], capture_output=True,
|
|
text=True, timeout=15)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
def ap_fixture(self):
|
|
fixture = (ROOT / "test/fixtures/wifi_ota_start.cpp").read_text()
|
|
policy = (ROOT / "src/helpers/esp32/WiFiRadioPolicy.h").read_text()
|
|
mask = re.search(r"static constexpr uint8_t kAccessPointProtocolMask =.*?;",
|
|
policy, re.DOTALL).group(0)
|
|
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()
|
|
fixture = fixture.replace("@AP_LIFECYCLE_POLICY@", lifecycle[lifecycle.index("namespace mesh {"):])
|
|
return fixture
|
|
|
|
def test_network_instructions_and_switching_on_all_esp32_uploaders(self):
|
|
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
|
lightweight, other = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)
|
|
fixture = self.ap_fixture()
|
|
for name, implementation, flags in (
|
|
("lightweight infrastructure", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]),
|
|
("lightweight companion", lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1", "-DCOMPANION_RADIO_FULL=1"]),
|
|
("AsyncElegantOTA infrastructure", other, []),
|
|
):
|
|
with self.subTest(uploader=name):
|
|
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
|
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
|
for major in (2, 3):
|
|
with self.subTest(arduino_major=major):
|
|
self.compile_and_run(fixture.replace("@METHODS@", methods),
|
|
*flags, f"-DESP_ARDUINO_VERSION_MAJOR={major}")
|
|
|
|
def test_companion_setup_portal_uses_the_same_live_ap_contract(self):
|
|
source = (ROOT / "src/helpers/WiFiSetupPortal.cpp").read_text()
|
|
start = extract_braced(source, "bool WiFiSetupPortal::begin(")
|
|
task = extract_braced(source, "static void portalTask(")
|
|
cleanup = task[task.rindex("\n impl->dns.stop();"):task.rindex("\n}")]
|
|
endpoints = r'''
|
|
static const IPAddress SETUP_IP(192, 168, 4, 1);
|
|
using TaskHandle_t = void*;
|
|
constexpr int pdPASS = 1;
|
|
bool allow_dns = true, allow_task = true;
|
|
unsigned task_starts = 0;
|
|
struct WiFiServer {
|
|
bool listening = false;
|
|
explicit WiFiServer(int) {}
|
|
void begin() { assert(WiFi.ap_started); listening = true; }
|
|
void stop() { listening = false; }
|
|
void setNoDelay(bool) {}
|
|
};
|
|
struct DNSServer {
|
|
bool running = false;
|
|
bool start(int, const char*, IPAddress) {
|
|
assert(WiFi.ap_started); running = allow_dns; return running;
|
|
}
|
|
void stop() { running = false; }
|
|
};
|
|
class WiFiSetupPortal {
|
|
public:
|
|
using SaveCallback = bool (*)(void*, const char*, const char*);
|
|
volatile bool _active = false;
|
|
void* _impl = nullptr;
|
|
bool begin(const char*, SaveCallback, void*);
|
|
};
|
|
struct PortalImpl {
|
|
WiFiServer server{80};
|
|
DNSServer dns;
|
|
TaskHandle_t task = nullptr;
|
|
bool started_radio = false;
|
|
WiFiSetupPortal::SaveCallback save_callback = nullptr;
|
|
void* callback_context = nullptr;
|
|
volatile bool* active = nullptr;
|
|
uint32_t close_ap_at = 0, recovery_interval_ms = 0;
|
|
uint32_t ap_password_generation = 0, rekey_generation = 0, rekey_at = 0;
|
|
char recovery_ssid[32] = {}, ap_name[33] = {};
|
|
bool recovery_connecting = false;
|
|
};
|
|
void vTaskDelete(void*) {}
|
|
void portalTask(void* context) {
|
|
auto* impl = static_cast<PortalImpl*>(context);
|
|
@CLEANUP@
|
|
}
|
|
int xTaskCreatePinnedToCore(void (*)(void*), const char*, int, void*, int,
|
|
TaskHandle_t* task, int) {
|
|
++task_starts;
|
|
if (!allow_task) return 0;
|
|
*task = reinterpret_cast<void*>(1); return pdPASS;
|
|
}
|
|
namespace mesh {
|
|
struct Log { template<class... Args> void printf(const char*, Args...) {} };
|
|
Log& usbLoggingPort() { static Log log; return log; }
|
|
}
|
|
@START@
|
|
int main() {
|
|
constrain_espnow_channel = false;
|
|
WiFi.connected = WiFi.radio = false;
|
|
WiFiSetupPortal portal;
|
|
assert(portal.begin("MeshCore-Setup", nullptr, nullptr));
|
|
auto* impl = static_cast<PortalImpl*>(portal._impl);
|
|
assert(portal._active && impl->started_radio && impl->dns.running && impl->server.listening);
|
|
assert(WiFi.ap_started && WiFi.scans == 1);
|
|
const unsigned starts = WiFi.starts;
|
|
assert(portal.begin("MeshCore-Setup", nullptr, nullptr) && WiFi.starts == starts);
|
|
portalTask(impl);
|
|
assert(!portal._active && !impl->task && !impl->started_radio && !WiFi.radio);
|
|
for (Fault fault : {Fault::Ssid, Fault::StartEvent, Fault::ModeRead}) {
|
|
WiFi.fault = fault;
|
|
assert(!portal.begin("MeshCore-Setup", nullptr, nullptr));
|
|
assert(!portal._active && !impl->task && !WiFi.radio && !WiFi.ap);
|
|
}
|
|
WiFi.fault = Fault::None;
|
|
allow_dns = false;
|
|
assert(!portal.begin("MeshCore-Setup", nullptr, nullptr) && !WiFi.radio);
|
|
allow_dns = true;
|
|
allow_task = false;
|
|
assert(!portal.begin("MeshCore-Setup", nullptr, nullptr));
|
|
assert(!portal._active && !impl->task && !impl->dns.running && !impl->server.listening && !WiFi.radio);
|
|
allow_task = true;
|
|
WiFi.connected = WiFi.radio = true;
|
|
const unsigned scans = WiFi.scans;
|
|
assert(portal.begin("MeshCore-Setup", nullptr, nullptr));
|
|
assert(!impl->started_radio && WiFi.scans == scans && WiFi.connected);
|
|
portalTask(impl);
|
|
assert(WiFi.radio && WiFi.connected);
|
|
WiFi.connected = false;
|
|
constrain_espnow_channel = true;
|
|
assert(portal.begin("MeshCore-Setup", nullptr, nullptr));
|
|
assert(WiFi.ap_channel == 6);
|
|
portalTask(impl);
|
|
assert(WiFi.radio && !WiFi.ap);
|
|
delete impl;
|
|
}
|
|
'''
|
|
fixture = self.ap_fixture().split("static unsigned server_starts", 1)[0]
|
|
fixture += endpoints.replace("@CLEANUP@", cleanup).replace("@START@", start)
|
|
for major in (2, 3):
|
|
with self.subTest(arduino_major=major):
|
|
self.compile_and_run(fixture, f"-DESP_ARDUINO_VERSION_MAJOR={major}")
|
|
|
|
def test_management_ap_uses_current_password_and_rekeys_after_response_grace(self):
|
|
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
|
lightweight, elegant = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)
|
|
for implementation, flags in ((lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), (elegant, [])):
|
|
with self.subTest(flags=flags):
|
|
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
|
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
|
methods += "\n" + extract_braced(source, "bool ESP32Board::rotateOTAAccessPoint(")
|
|
fixture = self.ap_fixture().split("int main() {", 1)[0]
|
|
fixture = fixture.replace("static bool rotateOTAAccessPoint(void*) { return true; }",
|
|
"static bool rotateOTAAccessPoint(void*);")
|
|
fixture = fixture.replace("@METHODS@", methods) + r'''
|
|
int main() {
|
|
using namespace mesh::esp32::managementPassword;
|
|
assert(publish("password"));
|
|
ESP32Board board; char reply[160];
|
|
assert(board.startOTAUpdate("fixture", reply, true));
|
|
assert(strstr(reply, "(admin password)") && WiFi.connected);
|
|
assert(strstr(reply, "Login: admin / node admin password"));
|
|
assert(WiFi.live_config.ap.authmode == WIFI_AUTH_WPA2_PSK);
|
|
assert(!strcmp(reinterpret_cast<char*>(WiFi.live_config.ap.password), "password"));
|
|
const unsigned starts = WiFi.starts;
|
|
assert(board.startOTAUpdate("fixture", reply, true) && WiFi.starts == starts);
|
|
// Reuse must check the actual driver password, not just authmode/SSID.
|
|
strcpy(reinterpret_cast<char*>(WiFi.live_config.ap.password), "stale-password");
|
|
assert(board.startOTAUpdate("fixture", reply, true) && WiFi.starts == starts + 1);
|
|
Snapshot current; snapshot(current);
|
|
{
|
|
UploadGuard upload(current.generation); assert(upload.acquired());
|
|
assert(!publish("new-password"));
|
|
assert(!board.stopOTAUpdate(reply) && strstr(reply, "upload active"));
|
|
}
|
|
assert(publish("new-password"));
|
|
const unsigned before = WiFi.starts;
|
|
service(100); service(849); assert(WiFi.starts == before);
|
|
service(850); assert(WiFi.starts == before + 1 && WiFi.connected);
|
|
assert(!strcmp(reinterpret_cast<char*>(WiFi.live_config.ap.password), "new-password"));
|
|
assert(publish("tiny")); service(1000); service(1750);
|
|
assert(WiFi.live_config.ap.authmode == WIFI_AUTH_OPEN && WiFi.connected);
|
|
assert(board.startOTAUpdate("fixture", reply, true) && strstr(reply, "OPEN compatibility"));
|
|
assert(board.stopOTAUpdate(reply));
|
|
assert(!WiFi.ap && WiFi.connected); // Preserve the station owner.
|
|
}
|
|
'''
|
|
self.compile_and_run(fixture, *flags, "-DESP_ARDUINO_VERSION_MAJOR=2")
|
|
|
|
def test_ota_refuses_missing_or_empty_published_admin_before_network_effects(self):
|
|
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
|
lightweight, elegant = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)
|
|
for implementation, flags in ((lightweight, ["-DLIGHTWEIGHT_WIFI_OTA=1"]), (elegant, [])):
|
|
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
|
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
|
fixture = self.ap_fixture().split("int main() {", 1)[0]
|
|
fixture = fixture.replace('static const bool published_password = mesh::esp32::managementPassword::publish("short");', "")
|
|
fixture = fixture.replace("@METHODS@", methods) + r'''
|
|
int main() {
|
|
ESP32Board board; char reply[160];
|
|
assert(!board.startOTAUpdate("fixture", reply, true));
|
|
assert(!WiFi.starts && !server_starts && !board.inhibit_sleep);
|
|
assert(mesh::esp32::managementPassword::initialize(""));
|
|
assert(!board.startOTAUpdate("fixture", reply, true));
|
|
assert(!WiFi.starts && !server_starts && !board.inhibit_sleep);
|
|
}
|
|
'''
|
|
self.compile_and_run(fixture, *flags, "-DESP_ARDUINO_VERSION_MAJOR=2")
|
|
|
|
def test_elegant_root_and_log_do_not_bypass_management_auth(self):
|
|
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
|
elegant = source.split("#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)[1]
|
|
handlers = []
|
|
for path in ("/", "/log"):
|
|
marker = 'async_ota_host->on("' + path + '", HTTP_GET, [](AsyncWebServerRequest *request)'
|
|
body = extract_braced(elegant, marker)
|
|
body = body[body.index("{"):]
|
|
handlers.append("void handle" + str(len(handlers)) + "(AsyncWebServerRequest* request) " + body)
|
|
fixture = r'''
|
|
#include <cassert>
|
|
struct AsyncWebServerRequest {
|
|
int replies = 0;
|
|
template<class... Args> void send(Args...) { ++replies; }
|
|
};
|
|
struct Auth {
|
|
bool permitted = false;
|
|
int checks = 0;
|
|
bool authenticateRequest(AsyncWebServerRequest*) { ++checks; return permitted; }
|
|
} AsyncElegantOTA;
|
|
static const char* home_buf = "fixture";
|
|
int SPIFFS = 0;
|
|
@HANDLERS@
|
|
int main() {
|
|
for (auto handler : {handle0, handle1}) {
|
|
AsyncWebServerRequest request;
|
|
AsyncElegantOTA.permitted = false; handler(&request); assert(request.replies == 0);
|
|
AsyncElegantOTA.permitted = true; handler(&request); assert(request.replies == 1);
|
|
}
|
|
assert(AsyncElegantOTA.checks == 4);
|
|
}
|
|
'''
|
|
fixture = '#include <initializer_list>\n' + fixture.replace("@HANDLERS@", "\n".join(handlers))
|
|
self.compile_and_run(fixture)
|
|
old = fixture.replace(" if (!AsyncElegantOTA.authenticateRequest(request)) return;", "")
|
|
self.assertNotEqual(old, fixture)
|
|
with self.assertRaises(AssertionError):
|
|
self.compile_and_run(old)
|
|
|
|
def test_join_password_describes_live_ap_during_rekey_grace(self):
|
|
fixture = self.ap_fixture().split("static unsigned server_starts", 1)[0] + r'''
|
|
int main() {
|
|
using namespace mesh::esp32::managementPassword;
|
|
char password[65] = "old-value";
|
|
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
|
assert(publish("password"));
|
|
assert(mesh::wifi::startManagementAccessPoint("setup", false));
|
|
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)));
|
|
assert(!strcmp(password, "password"));
|
|
assert(publish("different"));
|
|
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)));
|
|
assert(!strcmp(password, "password")); // Registry leads actual AP during reply grace.
|
|
assert(!mesh::wifi::accessPointJoinPassword("other", password, sizeof(password)) && !password[0]);
|
|
assert(!mesh::wifi::accessPointJoinPassword("setup", password, 4) && !password[0]);
|
|
assert(mesh::wifi::startManagementAccessPoint("setup", false));
|
|
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)));
|
|
assert(!strcmp(password, "different"));
|
|
WiFi.live_config.ap.authmode = 99;
|
|
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
|
WiFi.live_config.ap.authmode = WIFI_AUTH_WPA2_PSK;
|
|
memset(WiFi.live_config.ap.password, 'x', sizeof(WiFi.live_config.ap.password));
|
|
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
|
WiFi.live_config.ap.authmode = WIFI_AUTH_OPEN;
|
|
assert(mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
|
WiFi.fault = Fault::ConfigRead;
|
|
assert(!mesh::wifi::accessPointJoinPassword("setup", password, sizeof(password)) && !password[0]);
|
|
}
|
|
'''
|
|
for major in (2, 3):
|
|
self.compile_and_run(fixture, f"-DESP_ARDUINO_VERSION_MAJOR={major}")
|
|
|
|
def test_minimal_ota_never_reports_a_stale_sdk_hotspot_as_started(self):
|
|
implementation = (ROOT / "src/helpers/ESP32Board.cpp").read_text().split(
|
|
"#elif defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA)", 1)[0]
|
|
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
|
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
|
fixture = self.ap_fixture().split("int main() {", 1)[0]
|
|
fixture = fixture.replace("@METHODS@", methods) + r'''
|
|
int main() {
|
|
ESP32Board board;
|
|
char reply[160] = {};
|
|
WiFi.connected = WiFi.radio = false;
|
|
constrain_espnow_channel = false;
|
|
WiFi.fault = Fault::Ssid; // Setters succeed with an old SSID in the actual SDK.
|
|
assert(!board.startOTAUpdate("test", reply, true));
|
|
assert(strcmp(reply, "ERR: OTA WiFi failed") == 0);
|
|
assert(!board.ota_server && !board.inhibit_sleep && !WiFi.radio);
|
|
}
|
|
'''
|
|
self.compile_and_run(fixture, "-DLIGHTWEIGHT_WIFI_OTA=1",
|
|
"-DESP_ARDUINO_VERSION_MAJOR=2")
|
|
|
|
def test_common_cli_preserves_actionable_ota_errors(self):
|
|
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
|
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
|
fixture = r'''
|
|
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
struct Board {
|
|
const char* error = "ERR: OTA WiFi failed";
|
|
bool startOTAUpdate(const char*, char* reply, bool) {
|
|
strcpy(reply, error); return false;
|
|
}
|
|
bool stopOTAUpdate(char* reply) { strcpy(reply, error); return false; }
|
|
} board;
|
|
struct Prefs { const char* node_name = "test"; } prefs;
|
|
struct CLI {
|
|
Board* _board = &board;
|
|
Prefs* _prefs = &prefs;
|
|
void run(const char* command, char* reply) {
|
|
if (false) {
|
|
@CLI@
|
|
}
|
|
}
|
|
};
|
|
int main() {
|
|
CLI cli;
|
|
char reply[160] = {};
|
|
for (const char* command : {"start ota", "start ota ap", "stop ota"}) {
|
|
cli.run(command, reply);
|
|
assert(strcmp(reply, "ERR: OTA WiFi failed") == 0);
|
|
board.error = "ERR: OTA server failed";
|
|
cli.run(command, reply);
|
|
assert(strcmp(reply, "ERR: OTA server failed") == 0);
|
|
board.error = "";
|
|
cli.run(command, reply);
|
|
assert(strcmp(reply, "Error") == 0);
|
|
board.error = "ERR: OTA WiFi failed";
|
|
}
|
|
}
|
|
'''
|
|
self.compile_and_run('#include <initializer_list>\n' + fixture.replace("@CLI@", cli[start:end]))
|
|
|
|
def test_common_cli_stops_webconfig_and_reports_the_handoff(self):
|
|
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
|
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
|
fixture = r'''
|
|
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
struct Callbacks {
|
|
bool active = true, allow_stop = true, mqtt = true, espnow = true;
|
|
int stops = 0, bridge_stops = 0, bridge_starts = 0;
|
|
int espnow_stops = 0, espnow_starts = 0;
|
|
bool isWebConfigActive() const { return active; }
|
|
bool stopWebConfigForOTA(char* reply) {
|
|
++stops;
|
|
if (!allow_stop) { strcpy(reply, "ERR: handoff failed"); return false; }
|
|
active = false; strcpy(reply, "WebConfig stopped"); return true;
|
|
}
|
|
bool isMqttBridgeRunning() const { return mqtt; }
|
|
bool isEspNowBridgeRunning() const { return espnow; }
|
|
bool setMqttBridgeState(bool enabled) {
|
|
if (enabled) ++bridge_starts; else ++bridge_stops;
|
|
mqtt = enabled; return true;
|
|
}
|
|
bool setEspNowBridgeState(bool enabled) {
|
|
if (enabled) ++espnow_starts; else ++espnow_stops;
|
|
espnow = enabled; return true;
|
|
}
|
|
} callbacks;
|
|
struct Board {
|
|
bool allow_start = true, running = false;
|
|
int starts = 0;
|
|
bool isOTAUpdateRunning() const { return running; }
|
|
bool startOTAUpdate(const char*, char* reply, bool) {
|
|
assert(!callbacks.active && !callbacks.espnow); ++starts;
|
|
if (allow_start) running = true;
|
|
strcpy(reply, allow_start ? "Started: http://10.20.30.40/update - Use same WiFi/LAN"
|
|
: "ERR: OTA WiFi failed");
|
|
return allow_start;
|
|
}
|
|
bool stopOTAUpdate(char*) { running = false; return true; }
|
|
} board;
|
|
struct Prefs {
|
|
const char* node_name = "test";
|
|
const char* password = "password";
|
|
bool bridge_enabled = false, espnow_bridge_enabled = false;
|
|
} prefs;
|
|
struct CLI {
|
|
Board* _board = &board;
|
|
Prefs* _prefs = &prefs;
|
|
Callbacks* _callbacks = &callbacks;
|
|
bool _wifi_ota_resume_mqtt = false, _wifi_ota_resume_espnow = false;
|
|
void run(const char* command, char* reply) {
|
|
if (false) {
|
|
@CLI@
|
|
}
|
|
}
|
|
};
|
|
int main() {
|
|
CLI cli;
|
|
struct { char text[160] = {}; unsigned guard = 123; } reply;
|
|
cli.run("start ota invalid", reply.text);
|
|
assert(strstr(reply.text, "usage") && callbacks.stops == 0 && board.starts == 0);
|
|
callbacks.allow_stop = false;
|
|
cli.run("start ota", reply.text);
|
|
assert(strcmp(reply.text, "ERR: handoff failed") == 0 && board.starts == 0);
|
|
callbacks.allow_stop = true;
|
|
cli.run("start ota", reply.text);
|
|
assert(strstr(reply.text, "http://10.20.30.40/update") && strstr(reply.text, "; WebConfig stopped"));
|
|
assert(callbacks.stops == 2 && board.starts == 1 && callbacks.bridge_stops == 1);
|
|
assert(callbacks.espnow_stops == 1 && !callbacks.mqtt && !callbacks.espnow);
|
|
cli.run("start ota ap", reply.text);
|
|
assert(!strstr(reply.text, "WebConfig stopped") && callbacks.stops == 2 && board.starts == 2);
|
|
callbacks.active = true;
|
|
board.allow_start = false;
|
|
cli.run("start ota", reply.text);
|
|
assert(strcmp(reply.text, "ERR: OTA WiFi failed; WebConfig stopped") == 0);
|
|
assert(callbacks.stops == 3 && board.starts == 3 && callbacks.bridge_stops == 1);
|
|
cli.run("stop ota", reply.text);
|
|
assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 0);
|
|
// Saving intent after an inactive attempt must not manufacture a resume.
|
|
prefs.bridge_enabled = true;
|
|
prefs.espnow_bridge_enabled = true;
|
|
cli.run("stop ota", reply.text);
|
|
assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 0);
|
|
callbacks.espnow = true; // Only ESP-NOW was actually running on entry.
|
|
board.allow_start = true;
|
|
cli.run("start ota ap", reply.text);
|
|
cli.run("start ota ap", reply.text); // Repeat start preserves the first snapshot.
|
|
prefs.bridge_enabled = false;
|
|
cli.run("stop ota", reply.text);
|
|
assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 1);
|
|
assert(callbacks.espnow && !callbacks.mqtt);
|
|
// A stopped setup session with both saved defaults enabled stays stopped.
|
|
callbacks.espnow = false;
|
|
prefs.bridge_enabled = true;
|
|
callbacks.active = true;
|
|
cli.run("start ota ap", reply.text);
|
|
cli.run("stop ota", reply.text);
|
|
assert(callbacks.bridge_starts == 0 && callbacks.espnow_starts == 1);
|
|
assert(reply.guard == 123);
|
|
}
|
|
'''
|
|
self.compile_and_run(fixture.replace("@CLI@", cli[start:end]),
|
|
"-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1",
|
|
"-DWITH_MQTT_BRIDGE=1", "-DLIGHTWEIGHT_WIFI_OTA=1",
|
|
"-DWITH_ESPNOW_BRIDGE=1")
|
|
|
|
def test_webconfig_teardown_cannot_turn_ota_wifi_off(self):
|
|
source = (ROOT / "src/helpers/esp32/WebConfigServer.cpp").read_text()
|
|
methods = "\n".join(extract_braced(source, signature) for signature in (
|
|
"void WebConfigServer::requestStop(", "bool WebConfigServer::stopForOTA(",
|
|
"void WebConfigServer::finalizeTeardown(",
|
|
"void WebConfigServer::clearSessionLocked(",
|
|
"bool WebConfigServer::isNetworkForegroundActive(",
|
|
"void WebConfigServer::refreshNetworkPowerHold("))
|
|
fixture = (ROOT / "test/fixtures/webconfig_ota_handoff.cpp").read_text()
|
|
self.compile_and_run(fixture.replace("@METHODS@", methods))
|
|
|
|
def test_non_mqtt_espnow_ota_pauses_wireless_and_restores_saved_intent(self):
|
|
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
|
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
|
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
|
fixture = r'''
|
|
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
bool ota = false;
|
|
struct Callbacks {
|
|
bool web = false, espnow = true, uart = true;
|
|
bool allow_pause = true, allow_resume = true;
|
|
int pauses = 0, resumes = 0, uart_changes = 0;
|
|
bool isWebConfigActive() const { return web; }
|
|
bool stopWebConfigForOTA(char*) { web = false; return true; }
|
|
bool isEspNowBridgeRunning() const { return espnow; }
|
|
bool setEspNowBridgeState(bool enabled) {
|
|
if (enabled) {
|
|
++resumes; assert(!ota);
|
|
if (!allow_resume) return false;
|
|
} else {
|
|
++pauses;
|
|
if (!allow_pause) return false;
|
|
}
|
|
espnow = enabled; return true;
|
|
}
|
|
bool setBridgeState(bool enabled) { ++uart_changes; uart = enabled; return true; }
|
|
} callbacks;
|
|
struct Board {
|
|
bool allow_start = true, allow_stop = true;
|
|
int starts = 0;
|
|
bool isOTAUpdateRunning() const { return ota; }
|
|
bool startOTAUpdate(const char*, char* reply, bool) {
|
|
assert(!callbacks.espnow && !callbacks.web); ++starts;
|
|
ota = allow_start;
|
|
strcpy(reply, allow_start ? "Started: http://192.168.4.1/update"
|
|
: "ERR: OTA WiFi failed");
|
|
return allow_start;
|
|
}
|
|
bool stopOTAUpdate(char* reply) {
|
|
if (allow_stop) ota = false;
|
|
strcpy(reply, allow_stop ? "OK - OTA stopped" : "ERR: OTA upload active");
|
|
return allow_stop;
|
|
}
|
|
} board;
|
|
struct Prefs {
|
|
const char* node_name = "test";
|
|
const char* password = "password";
|
|
bool bridge_enabled = true, espnow_bridge_enabled = true;
|
|
} prefs;
|
|
struct CLI {
|
|
Board* _board = &board;
|
|
Prefs* _prefs = &prefs;
|
|
Callbacks* _callbacks = &callbacks;
|
|
bool _wifi_ota_resume_mqtt = false, _wifi_ota_resume_espnow = false;
|
|
void run(const char* command, char* reply) {
|
|
if (false) {
|
|
@CLI@
|
|
}
|
|
}
|
|
};
|
|
int main() {
|
|
CLI cli;
|
|
struct { char text[160] = {}; unsigned guard = 42; } reply;
|
|
cli.run("start ota invalid", reply.text);
|
|
assert(strstr(reply.text, "usage") && callbacks.pauses == 0 && board.starts == 0);
|
|
callbacks.allow_pause = false;
|
|
cli.run("start ota", reply.text);
|
|
assert(strcmp(reply.text, "ERR: could not pause ESP-NOW for OTA") == 0);
|
|
assert(board.starts == 0 && callbacks.espnow && callbacks.uart);
|
|
callbacks.allow_pause = true;
|
|
callbacks.web = true;
|
|
cli.run("start ota ap", reply.text);
|
|
assert(ota && !callbacks.espnow && callbacks.uart);
|
|
assert(strstr(reply.text, "; ESP-NOW paused") && strstr(reply.text, "; WebConfig stopped"));
|
|
assert(prefs.bridge_enabled && prefs.espnow_bridge_enabled);
|
|
const int resumed_before_failure = callbacks.resumes;
|
|
board.allow_stop = false;
|
|
cli.run("stop ota", reply.text);
|
|
assert(strcmp(reply.text, "ERR: OTA upload active") == 0);
|
|
assert(callbacks.resumes == resumed_before_failure && !callbacks.espnow);
|
|
board.allow_stop = true;
|
|
cli.run("stop ota", reply.text);
|
|
assert(!ota && callbacks.espnow && callbacks.uart);
|
|
assert(strstr(reply.text, "; ESP-NOW resumed"));
|
|
board.allow_start = false;
|
|
cli.run("start ota", reply.text);
|
|
assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0 && callbacks.espnow);
|
|
callbacks.allow_resume = false;
|
|
cli.run("start ota", reply.text);
|
|
assert(strcmp(reply.text, "ERR: OTA WiFi failed; ESP-NOW resume failed") == 0);
|
|
assert(!callbacks.espnow && callbacks.uart);
|
|
cli.run("stop ota", reply.text);
|
|
assert(strstr(reply.text, "; ESP-NOW resume failed"));
|
|
// A fresh successful OTA must preserve an outstanding failed resume.
|
|
board.allow_start = true;
|
|
cli.run("start ota", reply.text);
|
|
assert(ota && !callbacks.espnow);
|
|
callbacks.allow_resume = true;
|
|
cli.run("stop ota", reply.text);
|
|
assert(!ota && callbacks.espnow && strstr(reply.text, "; ESP-NOW resumed"));
|
|
callbacks.espnow = false;
|
|
// A disabled secondary must stay disabled after the upload stops.
|
|
prefs.espnow_bridge_enabled = false;
|
|
#if !defined(WITH_RS232_BRIDGE)
|
|
prefs.bridge_enabled = false;
|
|
#endif
|
|
callbacks.allow_resume = true;
|
|
board.allow_start = true;
|
|
const int resumes = callbacks.resumes;
|
|
cli.run("start ota", reply.text);
|
|
assert(!strstr(reply.text, "ESP-NOW") && !callbacks.espnow);
|
|
cli.run("stop ota", reply.text);
|
|
assert(callbacks.resumes == resumes && !callbacks.espnow);
|
|
assert(callbacks.uart_changes == 0 && callbacks.uart && reply.guard == 42);
|
|
}
|
|
'''
|
|
for profile, flags in (
|
|
("rs232_composite", ["-DWITH_RS232_BRIDGE=1"]),
|
|
("merged_sole", ["-DESPNOW_BRIDGE_MERGED=1"]),
|
|
("legacy_dedicated_sole", []),
|
|
):
|
|
with self.subTest(profile=profile):
|
|
self.compile_and_run(fixture.replace("@CLI@", cli[start:end]),
|
|
"-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1", "-DWITH_ESPNOW_BRIDGE=1", *flags)
|
|
|
|
def test_background_bridge_retry_waits_until_ota_stops(self):
|
|
fixture = r'''
|
|
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
uint32_t now = 0;
|
|
uint32_t millis() { return now; }
|
|
bool millisHasNowPassed(uint32_t deadline) { return int32_t(now - deadline) >= 0; }
|
|
struct Board {
|
|
bool ota = true;
|
|
bool isOTAUpdateRunning() const { return ota; }
|
|
} board;
|
|
struct Cli { Board* getBoard() { return &board; } };
|
|
struct Bridge {
|
|
unsigned starts = 0;
|
|
bool running = false;
|
|
bool isRunning() const { return running; }
|
|
void begin() { ++starts; running = true; }
|
|
};
|
|
using ESPNowBridge = Bridge;
|
|
struct WiFiMock { bool isConnected() { return true; } } WiFi;
|
|
struct Mesh {
|
|
Cli _cli;
|
|
struct { bool bridge_enabled = true, espnow_bridge_enabled = true; } _prefs;
|
|
Bridge espnow_bridge;
|
|
@SOLE_MEMBER@
|
|
Bridge* @MQTT_MEMBER@ = nullptr;
|
|
uint32_t shared_espnow_retry_at = 0;
|
|
void configureBridgeFilter(Bridge*) {}
|
|
@RETRY@
|
|
};
|
|
int main() {
|
|
Mesh mesh;
|
|
// Simulate repeated servicePostMeshLoop calls, spanning several retry windows.
|
|
for (now = 0; now <= 30000; now += 100) {
|
|
assert(!mesh.startSharedEspNowBridgeIfReady());
|
|
assert(mesh.@TRANSPORT@.starts == 0);
|
|
}
|
|
board.ota = false;
|
|
assert(mesh.startSharedEspNowBridgeIfReady());
|
|
assert(mesh.@TRANSPORT@.starts == 1);
|
|
assert(mesh.startSharedEspNowBridgeIfReady());
|
|
assert(mesh.@TRANSPORT@.starts == 1);
|
|
mesh.@TRANSPORT@.running = false;
|
|
mesh._prefs.espnow_bridge_enabled = false;
|
|
mesh._prefs.bridge_enabled = false;
|
|
now += 30000;
|
|
assert(!mesh.startSharedEspNowBridgeIfReady());
|
|
assert(mesh.@TRANSPORT@.starts == 1);
|
|
}
|
|
'''
|
|
for role, mqtt_member, transport, flags in (
|
|
("simple_repeater", "mqtt_bridge", "espnow_bridge", ["-DWITH_MQTT_BRIDGE=1"]),
|
|
("simple_repeater", "mqtt_bridge", "espnow_bridge", ["-DWITH_RS232_BRIDGE=1"]),
|
|
("simple_repeater", "mqtt_bridge", "bridge", []),
|
|
("simple_room_server", "bridge", "espnow_bridge", ["-DWITH_MQTT_BRIDGE=1"]),
|
|
):
|
|
with self.subTest(role=role, flags=flags):
|
|
source = (ROOT / "examples" / role / "MyMesh.h").read_text()
|
|
retry = extract_braced(source, "bool startSharedEspNowBridgeIfReady()")
|
|
self.compile_and_run(fixture.replace("@RETRY@", retry)
|
|
.replace("@MQTT_MEMBER@", mqtt_member).replace("@TRANSPORT@", transport)
|
|
.replace("@SOLE_MEMBER@", "Bridge bridge;" if role == "simple_repeater" else ""), *flags)
|
|
|
|
def test_full_companion_cli_reports_webconfig_stop_and_hardware_errors(self):
|
|
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
|
command = extract_braced(source, 'if (strcmp(command, "start ota") == 0')
|
|
fixture = r'''
|
|
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#include <cassert>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
struct WebConfig {
|
|
bool active = true, allowed = true;
|
|
bool stopForOTA(char* reply) {
|
|
if (!allowed) { strcpy(reply, "ERR: handoff failed"); return false; }
|
|
active = false; return true;
|
|
}
|
|
} portal;
|
|
struct Board {
|
|
bool allowed = true;
|
|
int starts = 0;
|
|
bool startOTAUpdate(const char*, char* reply, bool) {
|
|
assert(!portal.active); ++starts;
|
|
strcpy(reply, allowed ? "Started: http://192.168.4.1:8080/update - Join WiFi MeshCore-OTA"
|
|
: "ERR: this partition layout requires a USB firmware update");
|
|
return allowed;
|
|
}
|
|
} board;
|
|
struct Mesh {
|
|
struct { const char* node_name = "test"; } _prefs;
|
|
WebConfig* _webconfig = &portal;
|
|
bool isWebConfigActiveOrStopping() { return portal.active; }
|
|
bool run(const char* command, char* reply, size_t reply_size) {
|
|
@COMMAND@
|
|
return false;
|
|
}
|
|
};
|
|
int main() {
|
|
Mesh mesh;
|
|
char reply[160] = {};
|
|
portal.allowed = false;
|
|
assert(mesh.run("start ota", reply, sizeof(reply)));
|
|
assert(strcmp(reply, "ERR: handoff failed") == 0 && board.starts == 0);
|
|
portal.allowed = true;
|
|
assert(mesh.run("start ota", reply, sizeof(reply)));
|
|
assert(strstr(reply, ":8080/update") && strstr(reply, "; WebConfig stopped"));
|
|
assert(board.starts == 1);
|
|
portal.active = true;
|
|
board.allowed = false;
|
|
assert(mesh.run("start ota ap", reply, sizeof(reply)));
|
|
assert(strstr(reply, "partition layout") && strstr(reply, "; WebConfig stopped"));
|
|
struct { char text[20]; unsigned guard = 42; } short_reply;
|
|
assert(mesh.run("start ota", short_reply.text, sizeof(short_reply.text)));
|
|
assert(short_reply.guard == 42 && short_reply.text[19] == 0);
|
|
}
|
|
'''
|
|
self.compile_and_run(fixture.replace("@COMMAND@", command), "-DWITH_WEBCONFIG=1")
|
|
|
|
def test_lightweight_stop_keeps_slow_client_state_until_task_exits(self):
|
|
source = (ROOT / "src/helpers/ESP32Board.cpp").read_text()
|
|
implementation = source[source.index("class LightweightOTAServer {"):]
|
|
method = extract_braced(implementation, "bool end()")
|
|
fixture = r'''
|
|
#define MESH_MANAGEMENT_PASSWORD_HOST_TEST 1
|
|
#include <helpers/esp32/ManagementPassword.h>
|
|
#include <cassert>
|
|
#include <cstdint>
|
|
static unsigned delays = 0;
|
|
static void delay(unsigned value) { assert(value == 10); ++delays; }
|
|
struct Server { void stop() {} };
|
|
struct Updater {
|
|
bool running = true;
|
|
Server server;
|
|
void* task = reinterpret_cast<void*>(1);
|
|
void* board = reinterpret_cast<void*>(2);
|
|
@END@
|
|
};
|
|
int main() {
|
|
Updater updater;
|
|
assert(!updater.end());
|
|
assert(!updater.running && updater.task && updater.board && delays == 100);
|
|
updater.task = nullptr; // Slow network write has now returned and task exited.
|
|
assert(updater.end() && !updater.board && delays == 100);
|
|
}
|
|
'''
|
|
self.compile_and_run(fixture.replace("@END@", method))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|