Fix ESP32 Full idle radio teardown and compact AP startup
This commit is contained in:
1 parent
f1ffe53e8a
commit
d0859f925d
29 files changed
+1724
-144
No files matched your search
Vendored
+1
@@ -233,6 +233,7 @@ public:
|
||||
MyMesh* _callbacks;
|
||||
FakeSensors* _sensors;
|
||||
FakeBoard* _board;
|
||||
@OTA_STATE@
|
||||
NodePrefs stored;
|
||||
unsigned saves = 0;
|
||||
bool fail_save = false;
|
||||
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <helpers/WirelessControl.h>
|
||||
struct Worker {
|
||||
bool running = false, stopping = false;
|
||||
bool isRunning() const { return running; }
|
||||
@@ -22,23 +23,28 @@ struct WebConfigServer {
|
||||
bool isRunning() const { return false; }
|
||||
bool isStopping() const { return false; }
|
||||
void updateWiFiOwnership(bool owner) { owns = owner; }
|
||||
void startAutoMode(char* reply) { strcpy(reply, "starting"); }
|
||||
void startSetupMode(char* reply) { strcpy(reply, "setup"); }
|
||||
bool startAutoMode(char* reply) { strcpy(reply, "starting"); return true; }
|
||||
bool startSetupMode(char* reply) { strcpy(reply, "setup"); return true; }
|
||||
};
|
||||
struct MyMesh {
|
||||
Worker* @WORKER@ = nullptr;
|
||||
CLI _cli;
|
||||
WebConfigServer* _webconfig = nullptr;
|
||||
bool _unconfigured_setup_espnow_suspended = false;
|
||||
bool isMqttBridgeRunning() const { return @WORKER@ && @WORKER@->isRunning(); }
|
||||
bool isMqttBridgeStopping() const { return @WORKER@ && @WORKER@->isStopping(); }
|
||||
struct { unsigned char pub_key[32] = {}; } self_id;
|
||||
const char* getFirmwareVer() const { return "test"; }
|
||||
const char* getBuildDate() const { return "test"; }
|
||||
const char* getRole() const { return "test"; }
|
||||
bool startWebConfig(bool force_ap, char* reply);
|
||||
void suspendUnconfiguredSetupBridges();
|
||||
void refresh() {
|
||||
@REFRESH@
|
||||
}
|
||||
};
|
||||
@START@
|
||||
@SUSPEND@
|
||||
int main() {
|
||||
MyMesh mesh;
|
||||
Worker worker;
|
||||
|
||||
Vendored
+168
-11
@@ -1,17 +1,23 @@
|
||||
// WiFi and the HTTP server are peripheral fakes; both OTA lifecycle methods
|
||||
// are inserted verbatim from ESP32Board.cpp by test_wifi_ota_start.py.
|
||||
#include <cassert>
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <helpers/WirelessControl.h>
|
||||
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
constexpr int WL_CONNECTED = 3;
|
||||
constexpr int HTTP_GET = 0;
|
||||
constexpr int WIFI_AP = 2;
|
||||
constexpr int ESP_OK = 0, WIFI_IF_AP = 1;
|
||||
using wifi_mode_t = int;
|
||||
constexpr int WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2;
|
||||
constexpr int WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_AP = WIFI_AP;
|
||||
constexpr int ESP_OK = 0, WIFI_IF_AP = 1, WIFI_IF_STA = 0;
|
||||
constexpr int WIFI_AUTH_OPEN = 0;
|
||||
constexpr int WIFI_SCAN_RUNNING = -1, WIFI_SCAN_FAILED = -2, AP_STARTED_BIT = 1;
|
||||
constexpr int LISTEN = 10;
|
||||
bool async_bind_allowed = true;
|
||||
constexpr uint8_t WIFI_PROTOCOL_11B = 1, WIFI_PROTOCOL_11G = 2;
|
||||
@@ -19,6 +25,19 @@ constexpr uint8_t WIFI_PROTOCOL_11N = 4, WIFI_PROTOCOL_LR = 8;
|
||||
using esp_err_t = int;
|
||||
bool allow_protocol_reset = true;
|
||||
bool constrain_espnow_channel = true;
|
||||
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;
|
||||
};
|
||||
enum class Fault { None, Ssid, Hidden, Channel, Protocol, Mode, StartEvent, ZeroIp,
|
||||
DelayedStart, ModeRead, ConfigRead, ProtocolRead };
|
||||
enum class ScanFault { None, FailedFacade, StopRejected, Timeout, DelayedStop };
|
||||
static unsigned clock_ms = 100;
|
||||
unsigned millis() { return clock_ms; }
|
||||
void delay(unsigned);
|
||||
struct IPAddress {
|
||||
std::string value;
|
||||
IPAddress() = default;
|
||||
@@ -30,37 +49,121 @@ struct IPAddress {
|
||||
explicit operator uint32_t() const { return value.empty() || value == "0.0.0.0" ? 0 : 1; }
|
||||
};
|
||||
struct WiFiFake {
|
||||
bool connected = true, ap = false, allow_ap = true;
|
||||
unsigned starts = 0;
|
||||
bool connected = true, ap = false, allow_ap = true, radio = true, ap_started = false;
|
||||
bool mode_allowed = true, sdk_scan_active = false;
|
||||
unsigned starts = 0, sta_preparations = 0, radio_stops = 0, scans = 0, scan_stops = 0;
|
||||
unsigned pending_start = 0, pending_stop = 0, scan_remaining = 0;
|
||||
int scan_result = WIFI_SCAN_FAILED;
|
||||
Fault fault = Fault::None;
|
||||
ScanFault scan_fault = ScanFault::None;
|
||||
wifi_config_t live_config;
|
||||
struct APStatus { bool started() const; } AP;
|
||||
int ap_channel = 0;
|
||||
uint8_t ap_protocol = 15; // Valid driver state, but LR beacons hide the AP from ordinary clients.
|
||||
int status() const { return connected ? WL_CONNECTED : 0; }
|
||||
int getMode() const { return ap ? WIFI_AP : 0; }
|
||||
int getMode() const { return (ap ? WIFI_AP : 0) | (radio ? WIFI_STA : 0); }
|
||||
int getStatusBits() const { return ap_started ? AP_STARTED_BIT : 0; }
|
||||
bool mode(int requested) {
|
||||
if (!mode_allowed) return false;
|
||||
radio = requested != WIFI_OFF;
|
||||
if (!radio) { ++radio_stops; connected = false; }
|
||||
if (requested == WIFI_STA) ++sta_preparations;
|
||||
if (!(requested & WIFI_AP)) {
|
||||
ap = false;
|
||||
if (ap_started) pending_stop = 60;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
IPAddress localIP() const { return IPAddress(10, 20, 30, 40); }
|
||||
IPAddress softAPIP() const { return ap ? IPAddress(192, 168, 4, 1) : IPAddress(0, 0, 0, 0); }
|
||||
// A configured netif IP is independent from the live SDK AP/event state.
|
||||
IPAddress softAPIP() const { return fault != Fault::ZeroIp && ap
|
||||
? IPAddress(192, 168, 4, 1) : IPAddress(0, 0, 0, 0); }
|
||||
bool softAPConfig(IPAddress ip, IPAddress gateway, IPAddress mask) {
|
||||
assert(ip.value == "192.168.4.1" && gateway.value == ip.value);
|
||||
assert(mask.value == "255.255.255.0");
|
||||
assert(!sdk_scan_active);
|
||||
return allow_ap;
|
||||
}
|
||||
bool softAP(const char* ssid, const char* password, int channel = 1) {
|
||||
assert(strcmp(ssid, "MeshCore-OTA") == 0 && password == nullptr);
|
||||
assert(ssid && strlen(ssid) <= 32 && password == nullptr);
|
||||
ap_channel = channel;
|
||||
++starts; ap = allow_ap; return ap;
|
||||
++starts; ap = allow_ap; radio = true;
|
||||
if (ap) {
|
||||
live_config = {};
|
||||
const char* live_ssid = fault == Fault::Ssid ? "old-hotspot" : ssid;
|
||||
live_config.ap.ssid_len = uint8_t(strlen(live_ssid));
|
||||
memcpy(live_config.ap.ssid, live_ssid, strlen(live_ssid));
|
||||
live_config.ap.ssid_hidden = fault == Fault::Hidden;
|
||||
live_config.ap.channel = fault == Fault::Channel ? 11 : channel;
|
||||
ap_started = fault != Fault::StartEvent && fault != Fault::DelayedStart;
|
||||
if (fault == Fault::DelayedStart) pending_start = 60;
|
||||
}
|
||||
return ap;
|
||||
}
|
||||
void softAPdisconnect(bool) { ap = false; ap_started = false; }
|
||||
void scanDelete() { if (!sdk_scan_active) scan_result = WIFI_SCAN_FAILED; }
|
||||
int scanComplete() const { return scan_result; }
|
||||
int scanNetworks(bool async, bool hidden, bool passive, unsigned maximum, uint8_t channel) {
|
||||
assert(async && !hidden && !passive && maximum == 200);
|
||||
assert(channel == (constrain_espnow_channel ? 6 : 0));
|
||||
assert(radio && !ap && !ap_started);
|
||||
++scans; sdk_scan_active = true; scan_result = WIFI_SCAN_RUNNING;
|
||||
if (scan_fault == ScanFault::None) scan_remaining = 100;
|
||||
else if (scan_fault == ScanFault::FailedFacade || scan_fault == ScanFault::StopRejected)
|
||||
scan_result = WIFI_SCAN_FAILED;
|
||||
return scan_result;
|
||||
}
|
||||
void softAPdisconnect(bool) { ap = false; }
|
||||
} WiFi;
|
||||
esp_err_t esp_wifi_set_protocol(int interface_id, uint8_t protocols) {
|
||||
bool WiFiFake::APStatus::started() const { return WiFi.ap_started; }
|
||||
void delay(unsigned amount) {
|
||||
clock_ms += amount;
|
||||
auto consume = [amount](unsigned& pending) {
|
||||
if (!pending) return false;
|
||||
pending -= std::min(pending, amount); return pending == 0;
|
||||
};
|
||||
if (consume(WiFi.pending_stop)) WiFi.ap_started = false;
|
||||
if (consume(WiFi.pending_start)) WiFi.ap_started = true;
|
||||
if (consume(WiFi.scan_remaining)) {
|
||||
WiFi.sdk_scan_active = false;
|
||||
WiFi.scan_result = WiFi.scan_fault == ScanFault::None ? 2 : WIFI_SCAN_FAILED;
|
||||
}
|
||||
}
|
||||
int esp_wifi_scan_stop() {
|
||||
++WiFi.scan_stops;
|
||||
if (WiFi.scan_fault == ScanFault::StopRejected) return -1;
|
||||
if (WiFi.scan_fault == ScanFault::DelayedStop) WiFi.scan_remaining = 60;
|
||||
else { WiFi.sdk_scan_active = false; WiFi.scan_result = WIFI_SCAN_FAILED; }
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_get_mode(wifi_mode_t* value) {
|
||||
if (WiFi.fault == Fault::ModeRead) return -1;
|
||||
*value = WiFi.fault == Fault::Mode ? WIFI_STA : WiFi.getMode(); return ESP_OK;
|
||||
}
|
||||
int esp_wifi_get_config(int interface_id, wifi_config_t* value) {
|
||||
assert(interface_id == WIFI_IF_AP);
|
||||
if (WiFi.fault == Fault::ConfigRead) return -1;
|
||||
*value = WiFi.live_config; return ESP_OK;
|
||||
}
|
||||
int esp_wifi_get_protocol(int interface_id, uint8_t* value) {
|
||||
assert(interface_id == WIFI_IF_AP);
|
||||
if (WiFi.fault == Fault::ProtocolRead) return -1;
|
||||
*value = WiFi.fault == Fault::Protocol ? WIFI_PROTOCOL_LR : WiFi.ap_protocol;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_wifi_set_protocol(int interface_id, uint8_t protocols) {
|
||||
assert(interface_id == WIFI_IF_AP || interface_id == WIFI_IF_STA);
|
||||
if (!allow_protocol_reset) return -1;
|
||||
WiFi.ap_protocol = protocols;
|
||||
if (interface_id == WIFI_IF_AP) WiFi.ap_protocol = protocols;
|
||||
return ESP_OK;
|
||||
}
|
||||
namespace mesh { namespace wifi {
|
||||
bool espNowChannelConstrained() { return constrain_espnow_channel; }
|
||||
uint8_t activeEspNowChannel() { return 6; }
|
||||
uint8_t stationScanChannel() { return constrain_espnow_channel ? 6 : 0; }
|
||||
esp_err_t applyProtocolMask(int interface_id) { return esp_wifi_set_protocol(interface_id, 15); }
|
||||
@AP_PROTOCOL_POLICY@
|
||||
} }
|
||||
@AP_LIFECYCLE_POLICY@
|
||||
static unsigned server_starts = 0;
|
||||
struct AsyncWebServerRequest {
|
||||
void send(int, const char*, const char*) {}
|
||||
@@ -85,6 +188,7 @@ static int SPIFFS;
|
||||
struct ESP32Board {
|
||||
bool inhibit_sleep = false;
|
||||
bool ota_started_ap = false;
|
||||
bool ota_started_radio = false;
|
||||
#ifdef LIGHTWEIGHT_WIFI_OTA
|
||||
void* ota_server = nullptr;
|
||||
#else
|
||||
@@ -180,6 +284,7 @@ int main() {
|
||||
lightweight_ota_server.allow_stop = true;
|
||||
#endif
|
||||
assert(board.stopOTAUpdate(reply.text));
|
||||
assert(WiFi.scans == 0); // Raising an AP preserves the live LAN without scanning.
|
||||
assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap && WiFi.connected);
|
||||
|
||||
constrain_espnow_channel = false;
|
||||
@@ -204,6 +309,58 @@ int main() {
|
||||
assert(!board.inhibit_sleep && !board.ota_server && !WiFi.ap);
|
||||
async_bind_allowed = true;
|
||||
#endif
|
||||
// Cold start/stop must restore OFF without leaking the temporary STA radio.
|
||||
WiFi.allow_ap = true;
|
||||
WiFi.radio = false;
|
||||
WiFi.connected = false;
|
||||
start(true, "192.168.4.1", "Join WiFi MeshCore-OTA");
|
||||
assert(WiFi.scans > 0); // Offline OTA uses STA-only scan-first, including minimal.
|
||||
assert(board.ota_started_radio && WiFi.radio && WiFi.ap_started);
|
||||
assert(board.stopOTAUpdate(reply.text));
|
||||
assert(!WiFi.radio && !board.ota_started_radio);
|
||||
|
||||
// A facade-successful AP cannot report success with any unusable live state.
|
||||
for (Fault fault : {Fault::Ssid, Fault::Hidden, Fault::Channel, Fault::Protocol,
|
||||
Fault::Mode, Fault::StartEvent, Fault::ZeroIp,
|
||||
Fault::ModeRead, Fault::ConfigRead, Fault::ProtocolRead}) {
|
||||
WiFi.fault = fault;
|
||||
const unsigned started = millis();
|
||||
assert(!board.startOTAUpdate("test", reply.text, true));
|
||||
assert(strcmp(reply.text, "ERR: OTA WiFi failed") == 0);
|
||||
assert(!WiFi.ap && !WiFi.radio && !board.inhibit_sleep && !board.ota_server);
|
||||
assert(millis() - started < 5000);
|
||||
}
|
||||
WiFi.fault = Fault::DelayedStart;
|
||||
start(true, "192.168.4.1", "Join WiFi MeshCore-OTA");
|
||||
assert(WiFi.ap_started);
|
||||
assert(board.stopOTAUpdate(reply.text));
|
||||
WiFi.fault = Fault::None;
|
||||
// Failed/timeout scans must stop in the SDK before a hotspot can start.
|
||||
for (ScanFault fault : {ScanFault::FailedFacade, ScanFault::Timeout, ScanFault::DelayedStop}) {
|
||||
WiFi.scan_fault = fault;
|
||||
const unsigned started = millis();
|
||||
start(true, "192.168.4.1", "Join WiFi MeshCore-OTA");
|
||||
assert(!WiFi.sdk_scan_active && millis() - started < 5000);
|
||||
assert(board.stopOTAUpdate(reply.text));
|
||||
}
|
||||
WiFi.scan_fault = ScanFault::StopRejected;
|
||||
const unsigned ap_before_scan_failure = WiFi.starts;
|
||||
assert(!board.startOTAUpdate("test", reply.text, true));
|
||||
assert(WiFi.starts == ap_before_scan_failure && !WiFi.ap && !WiFi.radio);
|
||||
WiFi.sdk_scan_active = false;
|
||||
WiFi.scan_fault = ScanFault::None;
|
||||
// A later ESP-NOW owner or associated station protects its radio on stop.
|
||||
start(true, "192.168.4.1", "Join WiFi MeshCore-OTA");
|
||||
constrain_espnow_channel = true;
|
||||
assert(board.stopOTAUpdate(reply.text));
|
||||
assert(WiFi.radio && !WiFi.ap);
|
||||
constrain_espnow_channel = false;
|
||||
WiFi.radio = false;
|
||||
start(true, "192.168.4.1", "Join WiFi MeshCore-OTA");
|
||||
WiFi.connected = true;
|
||||
assert(board.stopOTAUpdate(reply.text));
|
||||
assert(WiFi.radio && WiFi.connected);
|
||||
WiFi.connected = false;
|
||||
// A pending wireless shutdown must not race a newly started uploader.
|
||||
struct Backend : mesh::wireless::Backend {
|
||||
uint8_t active = mesh::wireless::All;
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
#!/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/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;
|
||||
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;
|
||||
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) {
|
||||
assert(!off && !erase); ++disconnects; connected = false;
|
||||
}
|
||||
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; }
|
||||
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.
|
||||
};
|
||||
using ESPNowBridge = AbstractBridge;
|
||||
struct MQTTNodeInfo {
|
||||
const char* node_name; float* freq; float* bw;
|
||||
uint8_t* sf; uint8_t* cr; uint8_t* repeat_flag; bool repeat_when_nonzero;
|
||||
};
|
||||
struct MQTTBridge : AbstractBridge {
|
||||
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...) {}
|
||||
};
|
||||
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;
|
||||
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; }
|
||||
bool isRunning() const { return running; }
|
||||
bool isStopping() const { return stopping; }
|
||||
void updateWiFiOwnership(bool owner) { owns = owner; }
|
||||
bool startSetupMode(char* reply) {
|
||||
if (!allow_start) {
|
||||
if (owns) WiFi.mode(WIFI_OFF);
|
||||
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) WiFi.mode(WIFI_OFF); 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;
|
||||
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 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@
|
||||
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):
|
||||
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 stopWebConfigForOTA("))
|
||||
methods = "\n".join(extract_braced(source, signature) for signature in (
|
||||
"void MyMesh::suspendUnconfiguredSetupBridges()", "void MyMesh::serviceIdleWiFi()",
|
||||
"bool MyMesh::startWebConfig(", "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(")
|
||||
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("@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_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_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()
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Execute Full defaults and the actual Companion idle branch with host fakes."""
|
||||
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def function(source, signature):
|
||||
start = source.index(signature)
|
||||
opening = source.index("{", start)
|
||||
depth = 0
|
||||
for end in range(opening, len(source)):
|
||||
depth += (source[end] == "{") - (source[end] == "}")
|
||||
if depth == 0:
|
||||
return source[start:end + 1]
|
||||
raise AssertionError(signature)
|
||||
|
||||
|
||||
def run_cpp(source, defines=()):
|
||||
with tempfile.TemporaryDirectory(prefix="mesh-full-sleep-") as directory:
|
||||
cpp = Path(directory) / "test.cpp"
|
||||
exe = Path(directory) / "test"
|
||||
cpp.write_text(source)
|
||||
built = subprocess.run(
|
||||
[os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra",
|
||||
"-I", str(ROOT / "src"),
|
||||
*("-D" + flag for flag in defines), str(cpp), "-o", str(exe)],
|
||||
text=True, capture_output=True)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stderr)
|
||||
return subprocess.run([str(exe)], text=True, capture_output=True)
|
||||
|
||||
|
||||
class FullSleepTest(unittest.TestCase):
|
||||
def test_full_optional_logging_starts_off_and_saved_intent_wins(self):
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
start = cli.index("#if defined(ESP32_PLATFORM) && defined(MESHCORE_EXPANDED_PARTITION_PROFILE)",
|
||||
cli.index("void CommonCLI::loadPrefs("))
|
||||
default = cli[start:cli.index(" _prefs->usb_debug_enabled", start)]
|
||||
logger = (ROOT / "src/helpers/UsbLogging.cpp").read_text()
|
||||
start = logger.index("#if defined(ENABLE_USB_INTERFACE)", logger.index("namespace mesh {"))
|
||||
early = logger[start:logger.index("static std::atomic<bool> usb_logging_preference_known", start)]
|
||||
# Execute the real persisted-byte read; neither a fresh default nor an
|
||||
# upgrade is allowed to silently discard an explicit logging choice.
|
||||
persisted = ""
|
||||
for line in cli.splitlines():
|
||||
if "file.read" in line and "usb_logging_enabled" in line:
|
||||
start = cli.index(line.strip())
|
||||
persisted = cli[start:cli.index(";", start) + 1]
|
||||
break
|
||||
self.assertTrue(persisted)
|
||||
source = r'''
|
||||
#include <atomic>
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
@EARLY@
|
||||
struct Prefs { uint8_t usb_logging_enabled = 99; } prefs;
|
||||
struct File { uint8_t value; void read(uint8_t* out, unsigned) { *out = value; } } file;
|
||||
int main() {
|
||||
auto* _prefs = &prefs;
|
||||
@DEFAULT@
|
||||
assert(prefs.usb_logging_enabled == EXPECTED_DEFAULT);
|
||||
assert(usb_logging_enabled.load() == (EXPECTED_DEFAULT != 0));
|
||||
file.value = 1;
|
||||
@PERSISTED@
|
||||
assert(prefs.usb_logging_enabled == 1);
|
||||
file.value = 0;
|
||||
@PERSISTED@
|
||||
assert(prefs.usb_logging_enabled == 0);
|
||||
}
|
||||
'''.replace("@EARLY@", early).replace("@DEFAULT@", default).replace("@PERSISTED@", persisted)
|
||||
for flags in (("ESP32_PLATFORM", "MESHCORE_EXPANDED_PARTITION_PROFILE", "EXPECTED_DEFAULT=0"),
|
||||
("ESP32_PLATFORM", "EXPECTED_DEFAULT=1"),
|
||||
("MESHCORE_EXPANDED_PARTITION_PROFILE", "EXPECTED_DEFAULT=1")):
|
||||
with self.subTest(flags=flags):
|
||||
result = run_cpp(source, flags)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
broken = run_cpp(source.replace("_prefs->usb_logging_enabled = 0;",
|
||||
"_prefs->usb_logging_enabled = 1;"),
|
||||
("ESP32_PLATFORM", "MESHCORE_EXPANDED_PARTITION_PROFILE", "EXPECTED_DEFAULT=0"))
|
||||
self.assertNotEqual(broken.returncode, 0)
|
||||
|
||||
def test_compiled_full_transports_do_not_exclude_runtime_idle_sleep(self):
|
||||
main = (ROOT / "examples/companion_radio/main.cpp").read_text()
|
||||
ready = function(main, "static bool companionNativeUsbLightSleepReady()")
|
||||
usb_held = function((ROOT / "src/helpers/ESP32Board.h").read_text(),
|
||||
"bool isUsbSleepHeld()")
|
||||
start = main.index(" // USB power alone")
|
||||
end = main.index("#if defined(ESP32) && defined(WIFI_SSID)",
|
||||
main.index("else if (the_mesh.getNodePrefs()->powersaving_enabled)", start))
|
||||
branch = main[start:end]
|
||||
source = r'''
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <helpers/UsbHostSleepPolicy.h>
|
||||
static uint32_t now = 1000;
|
||||
uint32_t millis() { return now; }
|
||||
using esp_err_t = int;
|
||||
enum wifi_mode_t { WIFI_MODE_NULL, WIFI_MODE_STA, WIFI_MODE_AP, WIFI_MODE_APSTA };
|
||||
constexpr int ESP_OK = 0, ESP_ERR_WIFI_NOT_INIT = 1, ESP_ERR_WIFI_NOT_STARTED = 2;
|
||||
static wifi_mode_t sdk_mode = WIFI_MODE_NULL;
|
||||
static int sdk_result = ESP_OK, sleeps = 0, yields = 0;
|
||||
constexpr int ESP_BT_CONTROLLER_STATUS_ENABLED = 1;
|
||||
static bool bt_controller_active = false;
|
||||
int esp_bt_controller_get_status() { return bt_controller_active ? 1 : 0; }
|
||||
constexpr int ESP_BLUEDROID_STATUS_ENABLED = 1;
|
||||
static bool bt_host_active = false;
|
||||
int esp_bluedroid_get_status() { return bt_host_active ? 1 : 0; }
|
||||
int esp_wifi_get_mode(wifi_mode_t* mode) { *mode = sdk_mode; return sdk_result; }
|
||||
int esp_sleep_enable_timer_wakeup(uint64_t us) { assert(us == 10000); return ESP_OK; }
|
||||
int esp_light_sleep_start() { ++sleeps; return ESP_OK; }
|
||||
int pdMS_TO_TICKS(int ms) { return ms; }
|
||||
void vTaskDelay(int) { ++yields; }
|
||||
void delay(int) { ++yields; }
|
||||
struct Board {
|
||||
bool host = false, ota = false, test = false;
|
||||
mesh::UsbHostSleepPolicy usb_host_sleep_policy;
|
||||
bool isUsbHostConnected() { usb_host_sleep_policy.observe(host, millis()); return host; }
|
||||
@USB_HELD@
|
||||
bool isOTAUpdateRunning() const { return ota; }
|
||||
bool isRadioTestActive() const { return test; }
|
||||
} board;
|
||||
struct Prefs { bool powersaving_enabled = true; } prefs;
|
||||
struct Mesh {
|
||||
bool pending = false;
|
||||
Prefs* getNodePrefs() { return &prefs; }
|
||||
bool hasPendingWork() const { return pending; }
|
||||
} the_mesh;
|
||||
struct Interfaces { bool pending = false; bool hasPendingIO() const { return pending; } } interface_manager;
|
||||
struct Sensors { bool gps_uart = false; bool gpsUsesSerialUart(uint8_t uart) const { return uart == 1 && gps_uart; } } sensors;
|
||||
struct Button { bool polling = false; bool needsPolling() const { return polling; } } user_btn;
|
||||
struct Wireless { unsigned services = 0; unsigned enabled() const { return services; } } companion_wireless;
|
||||
namespace mesh {
|
||||
bool logging = false, watchdog = false;
|
||||
bool isUsbLoggingEnabled() { return logging; }
|
||||
bool isUsbLoggingWatchdogArmed() { return watchdog; }
|
||||
namespace wireless {
|
||||
struct Control { bool transition = false; bool pending() const { return transition; } } controller;
|
||||
Control& control() { return controller; }
|
||||
}
|
||||
}
|
||||
@READY@
|
||||
void idle() { @BRANCH@ }
|
||||
template<class T> void blocks(T& flag) {
|
||||
flag = true; int before = sleeps; idle(); assert(sleeps == before); flag = false;
|
||||
}
|
||||
int main() {
|
||||
idle(); assert(sleeps == 1);
|
||||
for (unsigned service : {1u, 2u, 4u}) {
|
||||
companion_wireless.services = service; int before = sleeps; idle(); assert(sleeps == before);
|
||||
}
|
||||
companion_wireless.services = 0;
|
||||
for (wifi_mode_t mode : {WIFI_MODE_STA, WIFI_MODE_AP, WIFI_MODE_APSTA}) {
|
||||
sdk_mode = mode; int before = sleeps; idle(); assert(sleeps == before);
|
||||
}
|
||||
sdk_mode = WIFI_MODE_NULL;
|
||||
for (int result : {ESP_ERR_WIFI_NOT_INIT, ESP_ERR_WIFI_NOT_STARTED}) {
|
||||
sdk_result = result; int before = sleeps; idle(); assert(sleeps == before + 1);
|
||||
}
|
||||
sdk_result = 99; int before = sleeps; idle(); assert(sleeps == before); sdk_result = ESP_OK;
|
||||
blocks(board.ota); blocks(board.test); blocks(the_mesh.pending);
|
||||
blocks(interface_manager.pending); blocks(user_btn.polling); blocks(mesh::logging);
|
||||
blocks(bt_controller_active);
|
||||
blocks(bt_host_active); blocks(sensors.gps_uart);
|
||||
blocks(mesh::watchdog); blocks(mesh::wireless::controller.transition);
|
||||
prefs.powersaving_enabled = false; before = sleeps; idle(); assert(sleeps == before);
|
||||
prefs.powersaving_enabled = true; idle(); assert(sleeps == before + 1);
|
||||
// The real board policy must observe USB even while pending work/logging
|
||||
// inhibit this branch. A rebooting host cannot disappear for one SOF gap
|
||||
// and cause Full to shut down its console immediately.
|
||||
the_mesh.pending = true; board.host = true; idle();
|
||||
board.host = false; the_mesh.pending = false; now += 6;
|
||||
before = sleeps; idle(); assert(sleeps == before);
|
||||
now += 120000; idle(); assert(sleeps == before + 1);
|
||||
}
|
||||
'''.replace("@READY@", ready).replace("@BRANCH@", branch).replace("@USB_HELD@", usb_held)
|
||||
source = "#include <initializer_list>\n" + source
|
||||
flags = ("ESP32_PLATFORM", "ESP32", "ENABLE_USB_INTERFACE", "ARDUINO_USB_CDC_ON_BOOT=1",
|
||||
'WIFI_SSID=""', "BLE_PIN_CODE=123456", "MESH_USB_LOGGING_AVAILABLE=1",
|
||||
"CONFIG_BLUEDROID_ENABLED=1",
|
||||
"MESH_ESP32_USB_CONSOLE_COOPERATIVE=1", "MESH_ESP32_USB_HOST_LOSS_SLEEP_GRACE_MS=120000UL",
|
||||
"MOMENTARY_BUTTON_WAKE_FROM_SLEEP=1", "PIN_USER_BTN=0", "DISPLAY_CLASS=Mock")
|
||||
for pm in (0, 1):
|
||||
with self.subTest(pm=pm):
|
||||
result = run_cpp(source, (*flags, f"COMPANION_IDF_PM_AVAILABLE={pm}"))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
broken = run_cpp(source.replace("companion_wireless.enabled() != 0", "false"),
|
||||
(*flags, "COMPANION_IDF_PM_AVAILABLE=0"))
|
||||
self.assertNotEqual(broken.returncode, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -478,7 +478,7 @@ class Esp32UsbSleepTest(unittest.TestCase):
|
||||
methods = "\n".join(board_method(signature) for signature in (
|
||||
"void sleep(uint32_t secs) override",
|
||||
"bool isUsbDataConnected() override",
|
||||
"bool isUsbHostConnected() override"))
|
||||
"bool isUsbHostConnected() override", "bool isUsbSleepHeld()"))
|
||||
compiler = os.environ.get("CXX", "c++")
|
||||
self.assertIsNotNone(shutil.which(compiler), "a C++17 compiler is required")
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-v4-button-wake-") as directory:
|
||||
@@ -552,7 +552,7 @@ class Esp32UsbSleepTest(unittest.TestCase):
|
||||
def test_raw_uart_driver_preserves_fresh_console_indefinitely_until_end(self):
|
||||
methods = "\n".join(board_method(signature) for signature in (
|
||||
"void sleep(uint32_t secs) override", "bool isUsbDataConnected() override",
|
||||
"bool isUsbHostConnected() override"))
|
||||
"bool isUsbHostConnected() override", "bool isUsbSleepHeld()"))
|
||||
harness = HARNESS.split("int main()", 1)[0] + r'''
|
||||
int main() {
|
||||
try {
|
||||
@@ -600,6 +600,7 @@ int main() {
|
||||
"void sleep(uint32_t secs) override",
|
||||
"bool isUsbDataConnected() override",
|
||||
"bool isUsbHostConnected() override",
|
||||
"bool isUsbSleepHeld()",
|
||||
))
|
||||
with tempfile.TemporaryDirectory(prefix="meshcore-usb-sleep-") as temp:
|
||||
cpp = Path(temp) / "test.cpp"
|
||||
@@ -629,6 +630,7 @@ int main() {
|
||||
"void sleep(uint32_t secs) override",
|
||||
"bool isUsbDataConnected() override",
|
||||
"bool isUsbHostConnected() override",
|
||||
"bool isUsbSleepHeld()",
|
||||
))
|
||||
harness = HARNESS.split("int main()", 1)[0] + r'''
|
||||
int main() {
|
||||
|
||||
@@ -12,6 +12,12 @@ from test_replay_reset_integration import extract_braced
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def ota_resume_fields():
|
||||
header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
|
||||
start = header.index(" bool _wifi_ota_resume_mqtt = false;")
|
||||
return header[start:header.index("#endif", start)]
|
||||
|
||||
|
||||
class RS232MQTTRuntimeTests(unittest.TestCase):
|
||||
def test_independent_transports_route_retry_restart_and_survive_browser_ota(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
@@ -93,6 +99,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase):
|
||||
"ROUTE_TX": routing["logTx"], "SERVICE": implementation[service_start:service_end],
|
||||
"PENDING": implementation[pending_start:pending_end], "SET_BRANCHES": sets,
|
||||
"GET_BRANCHES": gets, "OTA": cli[ota_start:ota_end],
|
||||
"OTA_STATE": ota_resume_fields(),
|
||||
"PARSERS": "\n".join(extract_braced(cli, signature) for signature in (
|
||||
"static bool parseOnOffStrict(", "static bool configKeyEquals(")),
|
||||
}.items():
|
||||
@@ -171,6 +178,7 @@ class RS232MQTTRuntimeTests(unittest.TestCase):
|
||||
"PARSERS": "\n".join(extract_braced(cli, signature) for signature in (
|
||||
"static bool parseOnOffStrict(", "static bool configKeyEquals(")),
|
||||
"OTA": cli[ota_start:cli.index(' } else if (memcmp(command, "clock", 5)', ota_start)],
|
||||
"OTA_STATE": ota_resume_fields(),
|
||||
}
|
||||
for key, value in substitutions.items():
|
||||
code = code.replace("@" + key + "@", value)
|
||||
|
||||
@@ -543,6 +543,9 @@ class TBeamChargeTargetTest(unittest.TestCase):
|
||||
harness.write_text(source, encoding="ascii")
|
||||
binary = directory / "harness"
|
||||
command = [self.compiler, "-std=c++11", "-Wall", "-Wextra", "-Werror",
|
||||
# Production retains editor folding pragmas that GCC does
|
||||
# not implement. Keep real compiler warnings fatal.
|
||||
"-Wno-unknown-pragmas",
|
||||
"-I", str(directory), "-I", str(ROOT / "src"), str(harness),
|
||||
*extra, "-o", str(binary)]
|
||||
built = subprocess.run(command, capture_output=True, text=True)
|
||||
|
||||
@@ -35,6 +35,11 @@ class WebConfigApStartTest(unittest.TestCase):
|
||||
mask = policy[policy.index("static constexpr uint8_t kAccessPointProtocolMask ="):]
|
||||
mask = mask[:mask.index(";") + 1]
|
||||
fixture = FIXTURE.read_text(encoding="utf-8").replace("@AP_MASK@", mask)
|
||||
ap_policy = (ROOT / "src/helpers/esp32/WiFiAccessPointPolicy.h").read_text(encoding="utf-8")
|
||||
scanner = "namespace mesh { namespace wifi {\n" + "\n".join(
|
||||
extract_braced(ap_policy, signature) for signature in (
|
||||
"inline bool finishAccessPointScan(", "inline bool scanBeforeAccessPoint(")) + "\n} }\n"
|
||||
helper = scanner + helper
|
||||
fixture = fixture.replace("@METHODS@", helper + "\n" + method + "\n" + scan_handler)
|
||||
with tempfile.TemporaryDirectory(prefix="webconfig-ap-start-") as directory:
|
||||
work = Path(directory)
|
||||
|
||||
@@ -27,6 +27,8 @@ class WebConfigStationOwnershipTest(unittest.TestCase):
|
||||
refresh = source[refresh_start:refresh_end]
|
||||
self.assertIn("_webconfig->updateWiFiOwnership(", refresh)
|
||||
body = fixture.replace("@WORKER@", worker).replace("@START@", start)
|
||||
body = body.replace("@SUSPEND@", extract_braced(source,
|
||||
"void MyMesh::suspendUnconfiguredSetupBridges()"))
|
||||
body = body.replace("@REFRESH@", refresh)
|
||||
self.compile_and_run(body, "-DWITH_MQTT_BRIDGE=1")
|
||||
# A stopped/unconfigured MQTT implementation used to keep
|
||||
|
||||
@@ -39,6 +39,19 @@ STUBS = r'''
|
||||
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;
|
||||
@@ -67,6 +80,8 @@ struct MockWiFi {
|
||||
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;
|
||||
@@ -79,6 +94,7 @@ struct MockWiFi {
|
||||
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;
|
||||
}
|
||||
@@ -88,22 +104,37 @@ struct MockWiFi {
|
||||
configured_ap_ip = ip;
|
||||
return ap_config_ok;
|
||||
}
|
||||
bool softAP(const char*, const char*, int channel = 1) {
|
||||
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 {
|
||||
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() {
|
||||
@@ -145,6 +176,7 @@ 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"; }
|
||||
@@ -275,8 +307,10 @@ def production_harness():
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+189
-12
@@ -31,9 +31,7 @@ class WiFiOtaStartTest(unittest.TestCase):
|
||||
text=True, timeout=15)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
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)
|
||||
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 =.*?;",
|
||||
@@ -41,6 +39,14 @@ class WiFiOtaStartTest(unittest.TestCase):
|
||||
fixture = fixture.replace("@AP_PROTOCOL_POLICY@", mask + "\n" + 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"]),
|
||||
@@ -49,7 +55,137 @@ class WiFiOtaStartTest(unittest.TestCase):
|
||||
with self.subTest(uploader=name):
|
||||
methods = "\n".join(extract_braced(implementation, signature) for signature in (
|
||||
"bool ESP32Board::startOTAUpdate(", "bool ESP32Board::stopOTAUpdate("))
|
||||
self.compile_and_run(fixture.replace("@METHODS@", methods), *flags)
|
||||
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;
|
||||
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_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()
|
||||
@@ -103,26 +239,38 @@ int main() {
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
struct Callbacks {
|
||||
bool active = true, allow_stop = true;
|
||||
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;
|
||||
}
|
||||
void setBridgeState(bool enabled) { if (enabled) ++bridge_starts; else ++bridge_stops; }
|
||||
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;
|
||||
bool allow_start = true, running = false;
|
||||
int starts = 0;
|
||||
bool isOTAUpdateRunning() const { return running; }
|
||||
bool startOTAUpdate(const char*, char* reply, bool) {
|
||||
assert(!callbacks.active); ++starts;
|
||||
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*) { return true; }
|
||||
bool stopOTAUpdate(char*) { running = false; return true; }
|
||||
} board;
|
||||
struct Prefs {
|
||||
const char* node_name = "test";
|
||||
@@ -132,6 +280,7 @@ 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@
|
||||
@@ -150,18 +299,36 @@ int main() {
|
||||
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 == 2);
|
||||
assert(callbacks.stops == 3 && board.starts == 3 && callbacks.bridge_stops == 1);
|
||||
cli.run("stop ota", reply.text);
|
||||
assert(callbacks.bridge_starts == 0); // Do not enable disabled bridges.
|
||||
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 == 1); // Restore ESP-NOW even with MQTT off.
|
||||
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);
|
||||
}
|
||||
'''
|
||||
@@ -209,6 +376,7 @@ struct 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;
|
||||
@@ -230,6 +398,7 @@ 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@
|
||||
@@ -269,6 +438,14 @@ int main() {
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user