Fix ESP32 Full idle radio teardown and compact AP startup

This commit is contained in:
mikecarper committed 2026-10-06 11:46:10 -07:00
1 parent f1ffe53e8a
commit d0859f925d
29 files changed
+1724 -144

No files matched your search

+1
View File
@@ -233,6 +233,7 @@ public:
MyMesh* _callbacks;
FakeSensors* _sensors;
FakeBoard* _board;
@OTA_STATE@
NodePrefs stored;
unsigned saves = 0;
bool fail_save = false;
+8 -2
View File
@@ -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;
+168 -11
View File
@@ -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;
+502
View File
@@ -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()
+198
View File
@@ -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()
+4 -2
View File
@@ -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() {
+8
View File
@@ -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)
+3
View File
@@ -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)
+5
View File
@@ -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)
+2
View File
@@ -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
+35 -1
View File
@@ -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
View File
@@ -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)