Include runtime ESP-NOW in Full ESP32 repeaters
This commit is contained in:
1 parent
6cd1289c8b
commit
69d9ac84bc
21 files changed
+1098
-104
No files matched your search
Vendored
+219
@@ -0,0 +1,219 @@
|
||||
// Production methods and dispatch branches are inserted by test_espnow_runtime.py.
|
||||
// The bridge driver is mocked to inject SDK failures without ESP32 hardware.
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
#include <initializer_list>
|
||||
#include <helpers/CLICommandUtils.h>
|
||||
#include <helpers/bridges/ESPNowBridgeFormat.h>
|
||||
#define MESH_DEBUG_PRINTLN(...) ((void)0)
|
||||
|
||||
namespace mesh { struct Packet {}; }
|
||||
struct StrHelper {
|
||||
static void strncpy(char* out, const char* in, size_t size) {
|
||||
assert(size); std::strncpy(out, in, size - 1); out[size - 1] = 0;
|
||||
}
|
||||
};
|
||||
struct NodePrefs {
|
||||
uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0;
|
||||
uint16_t bridge_delay = 0;
|
||||
uint8_t bridge_pkt_src = 0, bridge_uart = 0, bridge_channel = 0, bridge_format = 0;
|
||||
uint32_t bridge_baud = 0;
|
||||
char bridge_secret[32] = {};
|
||||
};
|
||||
class AbstractBridge {
|
||||
public:
|
||||
virtual ~AbstractBridge() = default;
|
||||
virtual void begin() = 0;
|
||||
virtual void end() = 0;
|
||||
virtual bool isRunning() const = 0;
|
||||
virtual void loop() = 0;
|
||||
virtual void sendPacket(mesh::Packet*) = 0;
|
||||
};
|
||||
class ESPNowBridge : public AbstractBridge {
|
||||
bool running = false;
|
||||
public:
|
||||
unsigned begins = 0, ends = 0, fail_starts = 0, sent = 0, loops = 0;
|
||||
void begin() override {
|
||||
++begins;
|
||||
if (fail_starts) { --fail_starts; return; }
|
||||
running = true;
|
||||
}
|
||||
void end() override { ++ends; running = false; }
|
||||
bool isRunning() const override { return running; }
|
||||
void loop() override { ++loops; }
|
||||
void sendPacket(mesh::Packet*) override { ++sent; }
|
||||
};
|
||||
static uint32_t fake_millis = 100;
|
||||
static uint32_t millis() { return fake_millis; }
|
||||
static bool millisHasNowPassed(uint32_t deadline) {
|
||||
return static_cast<int32_t>(fake_millis - deadline) >= 0;
|
||||
}
|
||||
struct FakeBoard { bool ota = false; bool isOTAUpdateRunning() const { return ota; } };
|
||||
struct FakeCLI { FakeBoard board; FakeBoard* getBoard() { return &board; } };
|
||||
struct Callbacks {
|
||||
virtual ~Callbacks() = default;
|
||||
virtual bool isBridgeRunning() const = 0;
|
||||
virtual bool setBridgeState(bool) = 0;
|
||||
virtual bool restartBridge() = 0;
|
||||
@CALLBACK_FORWARDS@
|
||||
};
|
||||
class MyMesh : public Callbacks {
|
||||
public:
|
||||
NodePrefs _prefs;
|
||||
ESPNowBridge bridge;
|
||||
uint32_t shared_espnow_retry_at = 0;
|
||||
FakeCLI _cli;
|
||||
void configureBridgeFilter(AbstractBridge*) {}
|
||||
void defaults() { @DEFAULTS@ }
|
||||
void boot() {
|
||||
@BOOT@
|
||||
}
|
||||
void routeRx(mesh::Packet* pkt) {
|
||||
@ROUTE_RX@
|
||||
}
|
||||
void routeTx(mesh::Packet* pkt) {
|
||||
@ROUTE_TX@
|
||||
}
|
||||
void serviceBridges() {
|
||||
@SERVICE@
|
||||
}
|
||||
bool bridgesPreventSleep() const {
|
||||
@PENDING@
|
||||
return false;
|
||||
}
|
||||
@LIFECYCLE@
|
||||
};
|
||||
@PARSERS@
|
||||
class CommonCLI {
|
||||
public:
|
||||
NodePrefs* _prefs;
|
||||
MyMesh* _callbacks;
|
||||
NodePrefs stored;
|
||||
unsigned saves = 0;
|
||||
explicit CommonCLI(MyMesh& mesh) : _prefs(&mesh._prefs), _callbacks(&mesh) {}
|
||||
void savePrefs() { ++saves; stored = *_prefs; }
|
||||
void set(const char* input, char* reply) {
|
||||
char config[256] = {};
|
||||
assert(strlen(input) < sizeof(config)); strcpy(config, input);
|
||||
@SET_BRANCHES@
|
||||
}
|
||||
void get(const char* config, char* reply) { @GET_BRANCHES@ }
|
||||
};
|
||||
static void expect_get(CommonCLI& cli, const char* key, const char* expected) {
|
||||
char reply[256] = {}; cli.get(key, reply); assert(strcmp(reply, expected) == 0);
|
||||
}
|
||||
static void assert_aliases(CommonCLI& cli, bool enabled, bool running) {
|
||||
expect_get(cli, "bridge.enabled", enabled ? "> on" : "> off");
|
||||
expect_get(cli, "espnow.enabled", enabled ? "> on" : "> off");
|
||||
expect_get(cli, "bridge.running", running ? "> on" : "> off");
|
||||
expect_get(cli, "espnow.running", running ? "> on" : "> off");
|
||||
}
|
||||
int main() {
|
||||
{
|
||||
MyMesh mesh; mesh.defaults();
|
||||
CommonCLI cli(mesh);
|
||||
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
|
||||
const bool initial_enabled = false;
|
||||
#else
|
||||
const bool initial_enabled = true;
|
||||
#endif
|
||||
assert(mesh._prefs.bridge_enabled == initial_enabled);
|
||||
assert(mesh._prefs.espnow_bridge_enabled == initial_enabled);
|
||||
mesh.boot();
|
||||
assert(mesh.bridge.begins == (initial_enabled ? 1 : 0));
|
||||
assert_aliases(cli, initial_enabled, initial_enabled);
|
||||
expect_get(cli, "bridge.type", "> espnow");
|
||||
}
|
||||
MyMesh mesh; mesh.defaults();
|
||||
CommonCLI cli(mesh);
|
||||
char reply[256] = {};
|
||||
cli.set("bridge.enabled off", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && !mesh.bridgesPreventSleep());
|
||||
assert_aliases(cli, false, false);
|
||||
const unsigned initial_begins = mesh.bridge.begins;
|
||||
cli.set("bridge.channel 6", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && mesh.bridge.begins == initial_begins);
|
||||
cli.set("bridge.secret combined-secret", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && mesh.bridge.begins == initial_begins);
|
||||
cli.set("bridge.format raw", reply);
|
||||
assert(strncmp(reply, "OK", 2) == 0 && mesh.bridge.begins == initial_begins);
|
||||
cli.set("espnow.enabled on", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && mesh.bridgesPreventSleep());
|
||||
assert_aliases(cli, true, true);
|
||||
mesh::Packet packet;
|
||||
mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges();
|
||||
assert(mesh.bridge.sent == 1 && mesh.bridge.loops == 1); // RX is routed once.
|
||||
cli.set("bridge.source tx", reply);
|
||||
assert(strcmp(reply, "OK") == 0);
|
||||
mesh.routeRx(&packet); mesh.routeTx(&packet);
|
||||
assert(mesh.bridge.sent == 2); // TX is routed once, RX no longer feeds bridge.
|
||||
const unsigned configured_begins = mesh.bridge.begins;
|
||||
cli.set("bridge.channel 11", reply);
|
||||
cli.set("bridge.secret updated-secret", reply);
|
||||
cli.set("bridge.format wrapped", reply);
|
||||
assert(mesh.bridge.begins == configured_begins + 3);
|
||||
expect_get(cli, "bridge.channel", "> 11");
|
||||
expect_get(cli, "bridge.secret", "> updated-secret");
|
||||
expect_get(cli, "bridge.format", "> wrapped");
|
||||
const unsigned saves = cli.saves;
|
||||
for (const char* invalid : {"bridge.enabled onjunk", "espnow.enabled onjunk",
|
||||
"bridge.channel 0", "bridge.channel 14", "bridge.channel 6junk",
|
||||
"bridge.secret ", "bridge.format unsupported", "bridge.source rxjunk"}) {
|
||||
cli.set(invalid, reply);
|
||||
assert(strncmp(reply, "Error", 5) == 0 && cli.saves == saves);
|
||||
assert_aliases(cli, true, true);
|
||||
}
|
||||
// The persisted aliases reconstruct exactly the enabled sole runtime.
|
||||
MyMesh restarted; restarted.defaults(); restarted._prefs = cli.stored;
|
||||
restarted.boot();
|
||||
assert(restarted.bridge.begins == 1 && restarted.isBridgeRunning());
|
||||
// Failed SDK initialization preserves saved intent and retries cooperatively.
|
||||
cli.set("espnow.enabled off", reply);
|
||||
mesh.bridge.fail_starts = 2;
|
||||
cli.set("bridge.enabled on", reply);
|
||||
assert(strstr(reply, "setting saved") && !mesh.isBridgeRunning());
|
||||
assert_aliases(cli, true, false);
|
||||
mesh.serviceBridges();
|
||||
const unsigned after_first_retry = mesh.bridge.begins;
|
||||
mesh.serviceBridges();
|
||||
assert(mesh.bridge.begins == after_first_retry && !mesh.bridgesPreventSleep());
|
||||
fake_millis += 5001;
|
||||
mesh.serviceBridges();
|
||||
assert(mesh.bridge.begins == after_first_retry + 1 && mesh.isBridgeRunning());
|
||||
// Paused browser OTA must block direct enable, restart, and timed retries.
|
||||
assert(mesh.setEspNowBridgeState(false));
|
||||
mesh._cli.board.ota = true;
|
||||
const unsigned before_ota = mesh.bridge.begins;
|
||||
cli.set("espnow.enabled on", reply);
|
||||
assert(strstr(reply, "setting saved") && !mesh.isEspNowBridgeRunning());
|
||||
cli.set("bridge.channel 3", reply);
|
||||
assert(strstr(reply, "setting saved") && !mesh.isBridgeRunning());
|
||||
for (unsigned tick = 0; tick < 10; ++tick) {
|
||||
fake_millis += 5001;
|
||||
mesh.serviceBridges();
|
||||
}
|
||||
assert(mesh.bridge.begins == before_ota && !mesh.bridgesPreventSleep());
|
||||
mesh._cli.board.ota = false;
|
||||
mesh.serviceBridges();
|
||||
assert(mesh.bridge.begins == before_ota + 1 && mesh.isBridgeRunning());
|
||||
// Runtime switches through either spelling must not leave the radio running.
|
||||
for (unsigned cycle = 0; cycle < 100; ++cycle) {
|
||||
cli.set("bridge.enabled off", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && !mesh.bridgesPreventSleep());
|
||||
assert_aliases(cli, false, false);
|
||||
cli.set("espnow.enabled on", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && mesh.bridgesPreventSleep());
|
||||
assert_aliases(cli, true, true);
|
||||
cli.set("espnow.enabled off", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && !mesh.bridgesPreventSleep());
|
||||
cli.set("bridge.enabled on", reply);
|
||||
assert(strcmp(reply, "OK") == 0 && mesh.isBridgeRunning());
|
||||
}
|
||||
cli.set("espnow.enabled off", reply);
|
||||
const unsigned sent_when_off = mesh.bridge.sent;
|
||||
mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges();
|
||||
assert(mesh.bridge.sent == sent_when_off && !mesh.bridgesPreventSleep());
|
||||
puts("ESP-NOW runtime checks passed");
|
||||
}
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
// Production bridge methods are injected by test_espnow_radio_lifecycle.py.
|
||||
// Keep Arduino's private caches distinct from the underlying SDK driver.
|
||||
#include <cassert>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <helpers/WirelessControl.h>
|
||||
#include <helpers/ESPNowRawFragmentation.h>
|
||||
#include <helpers/bridges/ESPNowBridgeFormat.h>
|
||||
|
||||
#define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c))
|
||||
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
|
||||
#define portENTER_CRITICAL(x) ((void)0)
|
||||
#define portEXIT_CRITICAL(x) ((void)0)
|
||||
#define ESP_NOW_ETH_ALEN 6
|
||||
#define WIFI_INIT_CONFIG_DEFAULT() 0
|
||||
using wifi_init_config_t = int;
|
||||
using wifi_second_chan_t = int;
|
||||
using esp_err_t = int;
|
||||
enum wifi_mode_t { WIFI_OFF=0, WIFI_STA=1, WIFI_AP=2, WIFI_AP_STA=3 };
|
||||
constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF, WIFI_MODE_STA = WIFI_STA;
|
||||
constexpr int ESP_OK=0, WIFI_STORAGE_RAM=0, WIFI_IF_STA=0, WIFI_SECOND_CHAN_NONE=0;
|
||||
constexpr int WIFI_PROTOCOL_11B=1, WIFI_PROTOCOL_11G=2, WIFI_PROTOCOL_11N=4, WIFI_PROTOCOL_LR=8;
|
||||
constexpr int WIFI_PHY_MODE_LR=1, WIFI_PHY_RATE_LORA_250K=2;
|
||||
enum esp_now_send_status_t { ESP_NOW_SEND_SUCCESS=0, ESP_NOW_SEND_FAIL=1 };
|
||||
struct esp_now_peer_info_t { uint8_t peer_addr[6]; int channel, ifidx; bool encrypt; };
|
||||
struct esp_now_rate_config_t { int phymode, rate; bool ersu, dcm; };
|
||||
|
||||
int failure=0, wifi_starts=0, wifi_stops=0, wifi_deinits=0, facade_initializations=0;
|
||||
bool sdk_initialized=false, sdk_started=false, espnow_active=false;
|
||||
uint8_t wifi_channel=6;
|
||||
wifi_mode_t sdk_mode=WIFI_OFF;
|
||||
int esp_wifi_init(wifi_init_config_t*) {
|
||||
if (sdk_initialized) return ESP_OK; // esp-idf init is idempotent.
|
||||
if (failure==11) return -1;
|
||||
sdk_initialized=true;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_set_storage(int) { return !sdk_initialized || failure==12 ? -1 : ESP_OK; }
|
||||
int esp_wifi_set_mode(wifi_mode_t mode) {
|
||||
if (!sdk_initialized || failure==13) return -1;
|
||||
sdk_mode=mode;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_get_mode(wifi_mode_t* mode) {
|
||||
if (!sdk_initialized) return -1;
|
||||
*mode=sdk_mode;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_start() {
|
||||
if (!sdk_initialized || failure==14) return -1;
|
||||
sdk_started=true;
|
||||
++wifi_starts;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_stop() {
|
||||
if (!sdk_initialized || failure==16) return -1;
|
||||
assert(!espnow_active);
|
||||
sdk_started=false;
|
||||
++wifi_stops;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_deinit() {
|
||||
if (!sdk_initialized || sdk_started || failure==17) return -1;
|
||||
assert(!espnow_active);
|
||||
sdk_initialized=false;
|
||||
sdk_mode=WIFI_OFF;
|
||||
++wifi_deinits;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_get_channel(uint8_t* channel, wifi_second_chan_t*) {
|
||||
if (!sdk_initialized) return -1;
|
||||
*channel=wifi_channel;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_set_channel(int channel, int) {
|
||||
if (!sdk_started || failure==15) return -1;
|
||||
wifi_channel=channel;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_wifi_set_protocol(int, int) { return !sdk_started || failure==2 ? -1 : ESP_OK; }
|
||||
int esp_now_init() {
|
||||
if (!sdk_started || failure==4) return -1;
|
||||
assert(!espnow_active);
|
||||
espnow_active=true;
|
||||
return ESP_OK;
|
||||
}
|
||||
int esp_now_deinit() { assert(espnow_active); espnow_active=false; return ESP_OK; }
|
||||
template<typename T> int esp_now_register_send_cb(T) { return failure==7 ? -1 : ESP_OK; }
|
||||
template<typename T> int esp_now_register_recv_cb(T) { return failure==8 ? -1 : ESP_OK; }
|
||||
int esp_now_add_peer(esp_now_peer_info_t* peer) {
|
||||
assert(peer->channel==6);
|
||||
return failure==9 ? -1 : ESP_OK;
|
||||
}
|
||||
int esp_now_del_peer(const uint8_t*) { return ESP_OK; }
|
||||
int esp_now_set_peer_rate_config(const uint8_t*, esp_now_rate_config_t*) { return failure==10 ? -1 : ESP_OK; }
|
||||
int esp_wifi_config_espnow_rate(int, int) { return failure==10 ? -1 : ESP_OK; }
|
||||
|
||||
struct Facade {
|
||||
bool initialized=false, started=false;
|
||||
wifi_mode_t getMode() const {
|
||||
// Pinned Arduino 2.x and 3.x both gate this getter on both caches.
|
||||
if (!initialized || !started) return WIFI_OFF;
|
||||
wifi_mode_t mode=WIFI_OFF;
|
||||
return esp_wifi_get_mode(&mode)==ESP_OK ? mode : WIFI_OFF;
|
||||
}
|
||||
int channel() const {
|
||||
// channel() only checks initialized, not started, in both pinned cores.
|
||||
if (!initialized) return 0;
|
||||
uint8_t channel=0;
|
||||
wifi_second_chan_t secondary=0;
|
||||
return esp_wifi_get_channel(&channel, &secondary)==ESP_OK ? channel : 0;
|
||||
}
|
||||
bool mode(wifi_mode_t requested) {
|
||||
const wifi_mode_t current=getMode();
|
||||
if (current==requested) return true;
|
||||
if (!current && requested && !initialized) {
|
||||
wifi_init_config_t config=0;
|
||||
if (esp_wifi_init(&config)!=ESP_OK) return false;
|
||||
initialized=true;
|
||||
++facade_initializations;
|
||||
if (esp_wifi_set_storage(WIFI_STORAGE_RAM)!=ESP_OK) {
|
||||
initialized=false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (current && !requested) {
|
||||
if (!started) return true;
|
||||
started=false;
|
||||
if (esp_wifi_stop()!=ESP_OK) { started=true; return false; }
|
||||
if (initialized && esp_wifi_deinit()!=ESP_OK) return false;
|
||||
initialized=false;
|
||||
return true;
|
||||
}
|
||||
if (esp_wifi_set_mode(requested)!=ESP_OK) return false;
|
||||
if (started) return true;
|
||||
started=true;
|
||||
if (esp_wifi_start()!=ESP_OK) { started=false; return false; }
|
||||
return true;
|
||||
}
|
||||
} WiFi;
|
||||
|
||||
namespace mesh { namespace wifi {
|
||||
std::atomic<uint8_t>& bridgeEspNowChannel() { static std::atomic<uint8_t> channel{0}; return channel; }
|
||||
} }
|
||||
struct Backend : mesh::wireless::Backend {
|
||||
bool wifi=false;
|
||||
uint8_t available() const override { return mesh::wireless::WiFi|mesh::wireless::EspNow; }
|
||||
uint8_t enabled() const override { return wifi ? mesh::wireless::WiFi : 0; }
|
||||
uint8_t clients() const override { return mesh::wireless::Independent; }
|
||||
mesh::wireless::Result set(uint8_t, bool) override { return mesh::wireless::Result::Done; }
|
||||
};
|
||||
class Bridge {
|
||||
public:
|
||||
struct Prefs { int bridge_format=mesh::bridge::ESPNOW_FORMAT_RAW; uint8_t bridge_channel=6; } prefs;
|
||||
Prefs* _prefs=&prefs;
|
||||
bool _initialized=false;
|
||||
int _active_format=0, _rx_mux=0, _rx_head=0, _rx_tail=0, _rx_count=0, _rx_dropped=0, _rx_dropped_reported=0;
|
||||
int _tx_mux=0, _tx_head=0, _tx_tail=0, _tx_count=0, _tx_waiting=0, _tx_callback_done=0;
|
||||
int _tx_callback_status=0, _tx_dropped=0, _tx_dropped_reported=0;
|
||||
mesh::espnow::ESPNowRawReassembler _raw_reassembler;
|
||||
struct QueuedTransmit { bool started=false; int packet=0; };
|
||||
static constexpr int TX_QUEUE_DEPTH=2;
|
||||
QueuedTransmit _tx_queue[TX_QUEUE_DEPTH];
|
||||
struct { void clear(const int*) {} } _seen_packets;
|
||||
static void recv_cb() {}
|
||||
static void send_cb() {}
|
||||
void begin();
|
||||
void end();
|
||||
};
|
||||
|
||||
@METHODS@
|
||||
|
||||
void coherent() {
|
||||
// SDK ownership may exist without Arduino, but never the reverse.
|
||||
assert(!WiFi.initialized || sdk_initialized);
|
||||
assert(!WiFi.started || (sdk_initialized && sdk_started));
|
||||
}
|
||||
void off() {
|
||||
coherent();
|
||||
assert(!sdk_initialized && !sdk_started && !espnow_active);
|
||||
assert(!WiFi.initialized && !WiFi.started);
|
||||
assert(mesh::wifi::bridgeEspNowChannel().load()==0);
|
||||
}
|
||||
int main() {
|
||||
Backend backend;
|
||||
mesh::wireless::control().begin(backend);
|
||||
Bridge bridge;
|
||||
// Stopping a bridge that only used IDF must not allocate the Arduino facade.
|
||||
for (int cycle=0; cycle<100; ++cycle) {
|
||||
bridge.begin();
|
||||
assert(bridge._initialized && sdk_started && !WiFi.initialized);
|
||||
bridge.end();
|
||||
bridge.end();
|
||||
off();
|
||||
assert(facade_initializations==0);
|
||||
}
|
||||
// Every driver startup failure releases pure-IDF resources without a facade.
|
||||
for (int stage : {11,12,13,14,15,2,4,7,8,9,10}) {
|
||||
failure=stage;
|
||||
bridge.begin();
|
||||
assert(!bridge._initialized);
|
||||
bridge.end();
|
||||
off();
|
||||
assert(facade_initializations==0);
|
||||
}
|
||||
failure=0;
|
||||
for (int cycle=0; cycle<100; ++cycle) {
|
||||
// ESP-NOW -> WebConfig -> stop WebConfig -> stop ESP-NOW -> WebConfig.
|
||||
bridge.begin();
|
||||
assert(bridge._initialized);
|
||||
assert(WiFi.mode(WIFI_AP_STA));
|
||||
backend.wifi=true;
|
||||
coherent();
|
||||
assert(WiFi.mode(WIFI_STA)); // WebConfig keeps STA for the running bridge.
|
||||
backend.wifi=false;
|
||||
bridge.end();
|
||||
off();
|
||||
assert(WiFi.mode(WIFI_AP_STA)); // Must initialize again, rather than use stale caches.
|
||||
coherent();
|
||||
assert(WiFi.mode(WIFI_OFF));
|
||||
off();
|
||||
|
||||
// Browser OTA pauses ESP-NOW, initializes Arduino, then leaves STA alive.
|
||||
bridge.begin();
|
||||
bridge.end();
|
||||
off();
|
||||
assert(WiFi.mode(WIFI_AP_STA));
|
||||
backend.wifi=true;
|
||||
coherent();
|
||||
assert(WiFi.mode(WIFI_STA));
|
||||
backend.wifi=false;
|
||||
bridge.begin();
|
||||
assert(bridge._initialized && WiFi.started);
|
||||
bridge.end();
|
||||
off();
|
||||
}
|
||||
// An active infrastructure owner survives all bridge failures and stops.
|
||||
assert(WiFi.mode(WIFI_AP_STA));
|
||||
backend.wifi=true;
|
||||
const int stops=wifi_stops, deinits=wifi_deinits;
|
||||
for (int stage : {2,4,7,8,9,10}) {
|
||||
failure=stage;
|
||||
bridge.begin();
|
||||
assert(!bridge._initialized && !espnow_active);
|
||||
bridge.end();
|
||||
coherent();
|
||||
assert(sdk_started && WiFi.started && WiFi.getMode()==WIFI_AP_STA);
|
||||
assert(wifi_stops==stops && wifi_deinits==deinits);
|
||||
}
|
||||
failure=0;
|
||||
bridge.begin();
|
||||
bridge.end();
|
||||
assert(wifi_stops==stops && wifi_deinits==deinits);
|
||||
backend.wifi=false;
|
||||
assert(WiFi.mode(WIFI_OFF));
|
||||
off();
|
||||
|
||||
// A failed Arduino start retains initialized=true and started=false.
|
||||
// Driver teardown must repair this partial cache, including its own failures.
|
||||
for (int stage : {0,12,13,14,15,2,4,7,8,9,10}) {
|
||||
failure=14;
|
||||
assert(!WiFi.mode(WIFI_STA));
|
||||
assert(WiFi.initialized && !WiFi.started && sdk_initialized);
|
||||
assert(WiFi.getMode()==WIFI_OFF && WiFi.channel()==6);
|
||||
failure=stage;
|
||||
bridge.begin();
|
||||
if (!stage) assert(bridge._initialized);
|
||||
else assert(!bridge._initialized);
|
||||
bridge.end();
|
||||
coherent(); // A failed repair must not destroy the SDK under a cached facade.
|
||||
failure=0;
|
||||
stopBridgeWiFiIfUnused();
|
||||
off();
|
||||
assert(WiFi.mode(WIFI_AP_STA));
|
||||
coherent();
|
||||
assert(WiFi.mode(WIFI_OFF));
|
||||
off();
|
||||
}
|
||||
// SDK shutdown errors also leave facade caches coherent for a safe retry.
|
||||
for (int stage : {16,17}) {
|
||||
assert(WiFi.mode(WIFI_STA));
|
||||
bridge.begin();
|
||||
assert(bridge._initialized);
|
||||
failure=stage;
|
||||
bridge.end();
|
||||
assert(!espnow_active);
|
||||
coherent();
|
||||
failure=0;
|
||||
stopBridgeWiFiIfUnused();
|
||||
off();
|
||||
}
|
||||
}
|
||||
Vendored
+13
-1
@@ -66,7 +66,10 @@ int main() {
|
||||
FakeFS fs{json};
|
||||
assert(cli.import(&fs) && cli.saves == 1);
|
||||
assert(strcmp(cli.prefs.node_name, "imported node") == 0);
|
||||
#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON)
|
||||
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON) \
|
||||
&& !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
|
||||
assert(cli.prefs.bridge_enabled == 0);
|
||||
#elif defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON)
|
||||
const bool explicit_uart = strstr(json, "uart:1") || strstr(json, "uart:2");
|
||||
const bool enabled = explicit_uart && strstr(json, "en:1");
|
||||
assert(cli.prefs.bridge_enabled == enabled && cli.prefs.bridge_uart == 2);
|
||||
@@ -77,6 +80,8 @@ int main() {
|
||||
#endif
|
||||
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
|
||||
assert(cli.prefs.espnow_bridge_enabled == 0);
|
||||
#elif defined(ESPNOW_BRIDGE_MERGED) && !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
|
||||
assert(cli.prefs.espnow_bridge_enabled == cli.prefs.bridge_enabled);
|
||||
#else
|
||||
assert(cli.prefs.espnow_bridge_enabled == 1);
|
||||
#endif
|
||||
@@ -85,9 +90,16 @@ int main() {
|
||||
}
|
||||
// Malformed JSON may partially apply scalar fields before the parser rejects it.
|
||||
CLI broken;
|
||||
broken.prefs.espnow_bridge_enabled = 1;
|
||||
FakeFS malformed{"{bridge:{en:1,uart:1}"};
|
||||
assert(!broken.import(&malformed) && broken.saves == 0);
|
||||
#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON)
|
||||
assert(broken.prefs.bridge_enabled == 0 && broken.prefs.bridge_uart == 2);
|
||||
#endif
|
||||
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
|
||||
assert(broken.prefs.espnow_bridge_enabled == 1);
|
||||
#if !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
|
||||
assert(broken.prefs.bridge_enabled == 0);
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
+19
-3
@@ -46,7 +46,13 @@ int main() {
|
||||
CommonCLI reader;
|
||||
reader.fs.files["/com_prefs"] = image;
|
||||
reader.loadPrefsInt(&reader.fs, "/com_prefs");
|
||||
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
|
||||
// The new sole bridge stores independent ESP-NOW intent and mirrors the
|
||||
// legacy primary alias, even if an older caller supplied different flags.
|
||||
assert(reader.prefs.bridge_enabled == secondary);
|
||||
#else
|
||||
assert(reader.prefs.bridge_enabled == primary);
|
||||
#endif
|
||||
assert(reader.prefs.espnow_bridge_enabled == secondary);
|
||||
#ifdef WITH_RS232_BRIDGE
|
||||
assert(reader.prefs.bridge_uart == 2);
|
||||
@@ -85,7 +91,7 @@ int main() {
|
||||
assert(legacy_reader.prefs.bridge_enabled == 1 && legacy_reader.prefs.bridge_uart == 2);
|
||||
#endif
|
||||
|
||||
#ifdef ESPNOW_BRIDGE_MERGED
|
||||
#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)
|
||||
for (unsigned invalid = 0; invalid <= 255; ++invalid) {
|
||||
if (invalid == 0xA1) continue;
|
||||
auto old = on.bytes;
|
||||
@@ -94,7 +100,12 @@ int main() {
|
||||
reader.fs.files["/com_prefs"] = old;
|
||||
reader.loadPrefsInt(&reader.fs, "/com_prefs");
|
||||
assert(reader.prefs.espnow_bridge_enabled == 0);
|
||||
assert(reader.prefs.bridge_enabled == 1 && reader._com_prefs_needs_upgrade);
|
||||
#if !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
|
||||
assert(reader.prefs.bridge_enabled == 0);
|
||||
#else
|
||||
assert(reader.prefs.bridge_enabled == 1);
|
||||
#endif
|
||||
assert(reader._com_prefs_needs_upgrade);
|
||||
}
|
||||
// Old 874-byte profiles saved the unused secondary flag as one. It is not intent.
|
||||
auto old = on.bytes;
|
||||
@@ -102,7 +113,12 @@ int main() {
|
||||
CommonCLI reader;
|
||||
reader.fs.files["/com_prefs"] = old;
|
||||
reader.loadPrefsInt(&reader.fs, "/com_prefs");
|
||||
assert(reader.prefs.espnow_bridge_enabled == 0 && reader.prefs.bridge_enabled == 1);
|
||||
assert(reader.prefs.espnow_bridge_enabled == 0);
|
||||
#if !defined(WITH_RS232_BRIDGE) && !defined(WITH_MQTT_BRIDGE)
|
||||
assert(reader.prefs.bridge_enabled == 0);
|
||||
#else
|
||||
assert(reader.prefs.bridge_enabled == 1);
|
||||
#endif
|
||||
reader.fs.files["/com_prefs"] = on.bytes;
|
||||
reader.fs.fail_read_after = static_cast<int>(marker_offset);
|
||||
reader.loadPrefsInt(&reader.fs, "/com_prefs");
|
||||
|
||||
@@ -966,24 +966,95 @@ apply_esp32_full_shared_bridge_profile Station_G2_repeater_observer_mqtt
|
||||
|| fail "Station G2 Full MQTT image did not include the ESP-NOW bridge"
|
||||
[[ " ${BUILD_CAPABILITIES[*]} " == *" bridge.espnow "* ]] \
|
||||
|| fail "Station G2 Full MQTT image did not report ESP-NOW capability"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" != *"ESPNOW_BRIDGE_MERGED"* ]] \
|
||||
|| fail "Station G2 existing MQTT default changed during ESP-NOW composition"
|
||||
[[ "$(get_unified_full_infrastructure_target Station_G2_repeater_bridge_espnow)" \
|
||||
= "Station_G2_repeater_observer_mqtt" ]] \
|
||||
|| fail "Station G2 Full ESP-NOW target did not resolve to the combined MQTT recipe"
|
||||
|
||||
# Heltec V4 TFT has no combined observer recipe, so its dedicated ESP-NOW
|
||||
# image must survive release selection in either input order. MKE now owns
|
||||
# both bridges directly and is checked against its canonical alias below.
|
||||
for plain_target in heltec_v4_tft_repeater; do
|
||||
# Full repeaters without an observer compile the real bridge directly. Both
|
||||
# historical ESP-NOW defaults and unsupported role boundaries stay explicit.
|
||||
for plain_target in heltec_v4_tft_repeater Heltec_v2_repeater \
|
||||
Heltec_ct62_repeater ThinkNode_M2_Repeater ThinkNode_M5_Repeater \
|
||||
Meshadventurer_sx1262_repeater Meshadventurer_sx1268_repeater \
|
||||
Generic_E22_sx1262_repeater Generic_E22_sx1268_repeater \
|
||||
Tenstar_C3_sx1262_repeater Tenstar_C3_sx1268_repeater \
|
||||
nibble_zero_connect_repeater_ nibble_screen_connect_repeater_ \
|
||||
Station_G2_logging_repeater; do
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_SRC_FILTER=""
|
||||
BUILD_CAPABILITIES=()
|
||||
apply_esp32_full_shared_bridge_profile "$plain_target"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DWITH_ESPNOW_BRIDGE=1"* ]] \
|
||||
|| fail "$plain_target Full image omitted the runtime ESP-NOW bridge"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DESPNOW_BRIDGE_MERGED=1"* ]] \
|
||||
|| fail "$plain_target Full image omitted safe default-off migration"
|
||||
[[ "$PLATFORMIO_BUILD_SRC_FILTER" == *"helpers/bridges/ESPNowBridge.cpp"* ]] \
|
||||
|| fail "$plain_target Full image omitted the actual ESP-NOW driver source"
|
||||
[[ " ${BUILD_CAPABILITIES[*]} " == *" bridge.espnow "* ]] \
|
||||
|| fail "$plain_target Full image omitted the ESP-NOW capability"
|
||||
(
|
||||
BUILD_PROFILE_FOR_TARGET=full
|
||||
BUILD_EXPECTATIONS=()
|
||||
declare_build_capability_contract "$plain_target" ESP32_PLATFORM
|
||||
[[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.espnow=_ZN12ESPNowBridge5beginEv"* ]] \
|
||||
|| fail "$plain_target Full image does not require a linked ESP-NOW driver"
|
||||
)
|
||||
done
|
||||
for existing_target in Heltec_v2_repeater_bridge_espnow \
|
||||
Station_G2_repeater_observer_mqtt; do
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
apply_esp32_full_shared_bridge_profile "$existing_target"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" == *"-DWITH_ESPNOW_BRIDGE=1"* ]] \
|
||||
|| fail "$existing_target existing bridge source was dropped"
|
||||
[[ "$PLATFORMIO_BUILD_FLAGS" != *"ESPNOW_BRIDGE_MERGED"* ]] \
|
||||
|| fail "$existing_target lost historical bridge-on behavior"
|
||||
done
|
||||
for excluded_target in heltec_v4_sensor heltec_v4_tft_room_server \
|
||||
heltec_v4_tft_companion_radio_full RAK_4631_repeater; do
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_SRC_FILTER=""
|
||||
apply_esp32_full_shared_bridge_profile "$excluded_target"
|
||||
[ -z "$PLATFORMIO_BUILD_FLAGS$PLATFORMIO_BUILD_SRC_FILTER" ] \
|
||||
|| fail "$excluded_target acquired unsupported ESP32 repeater bridge wiring"
|
||||
done
|
||||
(
|
||||
ESP32_FULL_BUILD=0
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_SRC_FILTER=""
|
||||
apply_esp32_full_shared_bridge_profile heltec_v4_tft_repeater
|
||||
[ -z "$PLATFORMIO_BUILD_FLAGS$PLATFORMIO_BUILD_SRC_FILTER" ] \
|
||||
|| fail "standard repeater acquired the expanded Full ESP-NOW overlay"
|
||||
)
|
||||
for primary_flag in MESH_PRIMARY_ESPNOW MESH_ESPNOW_RADIO; do
|
||||
(
|
||||
pio_env_option_contains() { [ "$3" = "$primary_flag" ]; }
|
||||
PLATFORMIO_BUILD_FLAGS=""
|
||||
PLATFORMIO_BUILD_SRC_FILTER=""
|
||||
if supports_esp32_full_shared_espnow heltec_v4_tft_repeater; then
|
||||
fail "$primary_flag radio acquired a second conflicting SDK owner"
|
||||
fi
|
||||
apply_esp32_full_shared_bridge_profile heltec_v4_tft_repeater
|
||||
[ -z "$PLATFORMIO_BUILD_FLAGS$PLATFORMIO_BUILD_SRC_FILTER" ] \
|
||||
|| fail "$primary_flag primary radio acquired the secondary bridge overlay"
|
||||
)
|
||||
done
|
||||
|
||||
# The TFT's normal Full image now owns ESP-NOW without an observer recipe.
|
||||
# Canonical selection must replace the sibling in either input order.
|
||||
for plain_target in heltec_v4_tft_repeater Meshadventurer_sx1262_repeater; do
|
||||
espnow_target=${plain_target}_bridge_espnow
|
||||
if get_unified_full_infrastructure_target "$espnow_target" >/dev/null; then
|
||||
fail "$espnow_target unexpectedly acquired a combined observer recipe"
|
||||
fi
|
||||
[ "$(select_ordinary_full_targets "$plain_target" "$espnow_target")" \
|
||||
= "$(printf "%s\n" "$plain_target" "$espnow_target")" ] \
|
||||
|| fail "$plain_target incorrectly replaced its dedicated ESP-NOW image"
|
||||
= "$plain_target" ] \
|
||||
|| fail "$plain_target did not replace its dedicated ESP-NOW image"
|
||||
[ "$(select_ordinary_full_targets "$espnow_target" "$plain_target")" \
|
||||
= "$(printf "%s\n" "$espnow_target" "$plain_target")" ] \
|
||||
|| fail "$plain_target lost its dedicated ESP-NOW image with reversed inputs"
|
||||
= "$plain_target" ] \
|
||||
|| fail "$plain_target input order changed the combined ESP-NOW selection"
|
||||
[ "$(get_merged_espnow_repeater_replacement "$espnow_target")" = "$plain_target" ] \
|
||||
|| fail "$plain_target ESP-NOW legacy target did not map to its combined Full image"
|
||||
done
|
||||
[ "$(get_unified_full_infrastructure_target Heltec_v3_repeater_bridge_espnow)" \
|
||||
= Heltec_v3_repeater_observer_mqtt ] \
|
||||
|
||||
@@ -34,6 +34,7 @@ struct FakeWiFi {
|
||||
wifi_mode_t current=WIFI_OFF;bool autoreconnect=true;
|
||||
void persistent(bool){}
|
||||
wifi_mode_t getMode(){return current;}
|
||||
int channel(){return current==WIFI_OFF?0:6;}
|
||||
void setAutoReconnect(bool value){autoreconnect=value;}
|
||||
bool mode(wifi_mode_t value){if(failure==1)return false;current=value;return true;}
|
||||
} WiFi;
|
||||
@@ -167,14 +168,16 @@ class EspNowLifecycleTests(unittest.TestCase):
|
||||
using wifi_init_config_t=int;
|
||||
using wifi_second_chan_t=int;
|
||||
constexpr int WIFI_STORAGE_RAM=0,WIFI_MODE_STA=1,WIFI_SECOND_CHAN_NONE=0;
|
||||
constexpr wifi_mode_t WIFI_MODE_NULL=WIFI_OFF;
|
||||
constexpr int WIFI_PROTOCOL_11B=1,WIFI_PROTOCOL_11G=2,WIFI_PROTOCOL_11N=4;
|
||||
int wifi_stops=0,wifi_starts=0;uint8_t wifi_channel=6;
|
||||
int esp_wifi_init(wifi_init_config_t*){return 0;}
|
||||
int wifi_stops=0,wifi_starts=0;uint8_t wifi_channel=6;bool wifi_initialized=false;
|
||||
int esp_wifi_init(wifi_init_config_t*){wifi_initialized=true;return 0;}
|
||||
int esp_wifi_set_storage(int){return 0;}
|
||||
int esp_wifi_set_mode(int){return 0;}
|
||||
int esp_wifi_start(){++wifi_starts;return 0;}
|
||||
int esp_wifi_stop(){++wifi_stops;return 0;}
|
||||
int esp_wifi_deinit(){return 0;}
|
||||
int esp_wifi_deinit(){wifi_initialized=false;return 0;}
|
||||
int esp_wifi_get_mode(wifi_mode_t* mode){*mode=WIFI_STA;return wifi_initialized?0:-1;}
|
||||
int esp_wifi_get_channel(uint8_t* channel,wifi_second_chan_t*){*channel=wifi_channel;return 0;}
|
||||
int esp_wifi_set_channel(int channel,int){wifi_channel=channel;return 0;}
|
||||
int esp_now_del_peer(const uint8_t*){return 0;}
|
||||
@@ -294,5 +297,28 @@ struct {
|
||||
'-I',str(folder),'-I',str(ROOT/'src'),str(folder/'channel.cpp'),'-o',str(binary)],check=True)
|
||||
subprocess.run([str(binary)],check=True)
|
||||
|
||||
def test_bridge_arduino_sdk_handoffs_and_partial_startup(self):
|
||||
source = (ROOT/'src/helpers/bridges/ESPNowBridge.cpp').read_text()
|
||||
methods = method(source, 'static void stopBridgeWiFiIfUnused(') + '\n'
|
||||
methods += '\n'.join(
|
||||
method(source, f'void ESPNowBridge::{name}(').replace('ESPNowBridge::', 'Bridge::')
|
||||
for name in ('begin', 'end')
|
||||
)
|
||||
fixture = (ROOT/'test/fixtures/espnow_wifi_handoff.cpp').read_text()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = Path(tmp)
|
||||
(folder/'handoff.cpp').write_text(fixture.replace('@METHODS@', methods))
|
||||
for arduino, idf in [(2,40400), (3,50200), (3,50500)]:
|
||||
with self.subTest(arduino=arduino, idf=idf):
|
||||
binary = folder/f'handoff-{idf}'
|
||||
subprocess.run([
|
||||
os.environ.get('CXX', 'g++'), '-std=c++17',
|
||||
'-fsanitize=address,undefined', '-fno-omit-frame-pointer', '-fno-pie', '-no-pie',
|
||||
'-I', str(ROOT/'src'), f'-DESP_IDF_VERSION={idf}',
|
||||
f'-DESP_ARDUINO_VERSION_MAJOR={arduino}',
|
||||
str(folder/'handoff.cpp'), '-o', str(binary),
|
||||
], check=True)
|
||||
subprocess.run([str(binary)], check=True)
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the sole repeater ESP-NOW production lifecycle and CLI with fault injection."""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from test_replay_reset_integration import extract_braced
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class ESPNowRuntimeTests(unittest.TestCase):
|
||||
def test_production_defaults_aliases_routing_retries_and_ota_exclusion(self):
|
||||
compiler = shutil.which("g++") or shutil.which("clang++")
|
||||
self.assertIsNotNone(compiler, "a host C++ compiler is required")
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text(encoding="ascii")
|
||||
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii")
|
||||
callback_header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
|
||||
implementation = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii")
|
||||
defaults_start = implementation.index("// bridge defaults")
|
||||
defaults_end = implementation.index("// GPS defaults", defaults_start)
|
||||
boot_start = implementation.index('mesh::hilStartupTrace("mesh_bridge_begin");')
|
||||
boot_start = implementation.index("#if defined(WITH_BRIDGE)", boot_start)
|
||||
boot_end = implementation.index('mesh::hilStartupTrace("mesh_bridge_ready");', boot_start)
|
||||
lifecycle_signatures = (
|
||||
"AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {",
|
||||
"bool startSharedEspNowBridgeIfReady()", "bool isBridgeRunning() const override",
|
||||
"bool setBridgeState(bool enable) override",
|
||||
"bool restartBridge() override",
|
||||
)
|
||||
routing = {}
|
||||
for name in ("logRx", "logTx"):
|
||||
start = implementation.index("void MyMesh::" + name + "(")
|
||||
start = implementation.index("#ifdef WITH_MQTT_BRIDGE\n", start)
|
||||
end = implementation.index(" if (_logging)", start)
|
||||
routing[name] = implementation[start:end]
|
||||
service_start = implementation.index("#if defined(WITH_ESPNOW_BRIDGE)",
|
||||
implementation.index("MyMesh::servicePostMeshLoop()"))
|
||||
service_end = implementation.index(" if (next_flood_advert", service_start)
|
||||
pending_start = implementation.index("#if defined(WITH_BRIDGE)",
|
||||
implementation.index("bool MyMesh::hasPendingWork()"))
|
||||
pending_end = implementation.index(" if (radio_driver.isWatchdogObserving()", pending_start)
|
||||
commands = "\n".join(extract_braced(cli, signature) for signature in (
|
||||
"void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd("))
|
||||
template = (ROOT / "test/fixtures/espnow_runtime.cpp").read_text(encoding="ascii")
|
||||
for key, value in {
|
||||
"DEFAULTS": implementation[defaults_start:defaults_end],
|
||||
"BOOT": implementation[boot_start:boot_end],
|
||||
"CALLBACK_FORWARDS": "\n".join(extract_braced(callback_header, signature) for signature in (
|
||||
"virtual bool setEspNowBridgeState(bool enable)",
|
||||
"virtual bool restartEspNowBridge()", "virtual bool isEspNowBridgeRunning()")),
|
||||
"PARSERS": "\n".join(extract_braced(cli, signature) for signature in (
|
||||
"static bool parseOnOffStrict(", "static bool configKeyEquals(")),
|
||||
"ROUTE_RX": routing["logRx"], "ROUTE_TX": routing["logTx"],
|
||||
"SERVICE": implementation[service_start:service_end],
|
||||
"PENDING": implementation[pending_start:pending_end],
|
||||
}.items():
|
||||
template = template.replace("@" + key + "@", value)
|
||||
for profile, macros in (
|
||||
("merged", ["ESPNOW_BRIDGE_MERGED=1"]),
|
||||
("legacy_dedicated", []),
|
||||
("merged_default_on", ["ESPNOW_BRIDGE_MERGED=1", "ESPNOW_BRIDGE_DEFAULT_ON=1"]),
|
||||
):
|
||||
with self.subTest(profile=profile), tempfile.TemporaryDirectory(prefix="espnow-runtime-") as directory:
|
||||
work = Path(directory)
|
||||
flags = ["-DWITH_BRIDGE=1", "-DWITH_ESPNOW_BRIDGE=1",
|
||||
*["-D" + macro for macro in macros]]
|
||||
processed = subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, "-"],
|
||||
input=commands, capture_output=True, text=True, check=True).stdout
|
||||
processed_header = subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, "-"],
|
||||
input=re.sub(r'^\s*#include[^\n]*', '', header, flags=re.M),
|
||||
capture_output=True, text=True, check=True).stdout
|
||||
lifecycle = "\n".join(extract_braced(processed_header, signature)
|
||||
for signature in lifecycle_signatures)
|
||||
sets = "\nelse ".join(extract_braced(processed, signature) for signature in (
|
||||
'if (memcmp(config, "espnow.enabled ", 15) == 0)',
|
||||
'if (memcmp(config, "bridge.enabled ", 15) == 0)',
|
||||
'if (memcmp(config, "bridge.source ", 14) == 0)',
|
||||
'if (memcmp(config, "bridge.channel ", 15) == 0)',
|
||||
'if (memcmp(config, "bridge.secret ", 14) == 0)',
|
||||
'if (memcmp(config, "bridge.format ", 14) == 0)',
|
||||
))
|
||||
gets = "\nelse ".join(extract_braced(processed, signature) for signature in (
|
||||
'if (configKeyEquals(config, "bridge.type"))',
|
||||
'if (configKeyEquals(config, "bridge.enabled"))',
|
||||
'if (configKeyEquals(config, "bridge.running"))',
|
||||
'if (configKeyEquals(config, "espnow.enabled"))',
|
||||
'if (configKeyEquals(config, "espnow.running"))',
|
||||
'if (configKeyEquals(config, "bridge.channel"))',
|
||||
'if (configKeyEquals(config, "bridge.secret"))',
|
||||
'if (configKeyEquals(config, "bridge.format"))',
|
||||
))
|
||||
source, binary = work / "main.cpp", work / "test"
|
||||
source.write_text(template.replace("@SET_BRANCHES@", sets)
|
||||
.replace("@GET_BRANCHES@", gets)
|
||||
.replace("@LIFECYCLE@", lifecycle), encoding="ascii")
|
||||
result = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
*flags, "-I" + str(ROOT / "src"), str(source), "-o", str(binary)],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertIn("ESP-NOW runtime checks passed", result.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -159,10 +159,65 @@ class ReleaseFullSelectionTest(unittest.TestCase):
|
||||
|
||||
def test_qualified_combined_plain_image_replaces_only_its_matching_espnow_image(self):
|
||||
primary = record("Heltec_v3_repeater", capabilities=("bridge.espnow",))
|
||||
legacy = record("Heltec_v3_repeater_bridge_espnow", capabilities=("bridge.espnow",))
|
||||
other_board = record("heltec_v4_tft_repeater_bridge_espnow", capabilities=("bridge.espnow",))
|
||||
for inputs in ([primary, legacy, other_board], [legacy, primary, other_board]):
|
||||
self.assertEqual(select_ordinary_full_records(inputs), [primary, other_board])
|
||||
for profile in ("full", "standard"):
|
||||
legacy = record("Heltec_v3_repeater_bridge_espnow", profile, capabilities=("bridge.espnow",))
|
||||
for inputs in ([primary, legacy, other_board], [legacy, primary, other_board]):
|
||||
with self.subTest(profile=profile, primary_first=inputs[0] is primary):
|
||||
self.assertEqual(select_ordinary_full_records(inputs), [primary, other_board])
|
||||
|
||||
def test_matching_dedicated_images_survive_all_missing_driver_proof_cases(self):
|
||||
for profile in ("full", "standard"):
|
||||
for fault in ("missing", "false", "wrong_capability", "false_verified"):
|
||||
primary = record("heltec_v4_tft_repeater", capabilities=("bridge.espnow",))
|
||||
if fault == "missing":
|
||||
primary["manifest"]["verification"] = []
|
||||
elif fault == "false":
|
||||
primary["manifest"]["verification"][0]["present"] = False
|
||||
elif fault == "wrong_capability":
|
||||
primary["manifest"]["verification"][0]["capability"] = "bridge.rs232"
|
||||
else:
|
||||
primary["manifest"]["verified"] = False
|
||||
legacy = record("heltec_v4_tft_repeater_bridge_espnow", profile,
|
||||
capabilities=("bridge.espnow",))
|
||||
for inputs in ([primary, legacy], [legacy, primary]):
|
||||
with self.subTest(profile=profile, fault=fault, primary_first=inputs[0] is primary):
|
||||
self.assertCountEqual(select_ordinary_full_records(inputs), inputs)
|
||||
|
||||
def test_stale_plain_cannot_displace_the_observer_proving_the_combined_driver(self):
|
||||
for profile in ("full", "standard"):
|
||||
for fault in ("no_capability", "no_proof", "failed_proof", "unverified"):
|
||||
stale = record("heltec_v4_tft_repeater", capabilities=("bridge.espnow",))
|
||||
if fault == "no_capability":
|
||||
stale["manifest"]["capabilities"] = []
|
||||
elif fault == "no_proof":
|
||||
stale["manifest"]["verification"] = []
|
||||
elif fault == "failed_proof":
|
||||
stale["manifest"]["verification"][0]["present"] = False
|
||||
else:
|
||||
stale["manifest"]["verified"] = False
|
||||
observer = record("heltec_v4_tft_repeater_observer_mqtt",
|
||||
capabilities=("bridge.espnow",))
|
||||
legacy = record("heltec_v4_tft_repeater_bridge_espnow", profile,
|
||||
capabilities=("bridge.espnow",))
|
||||
for inputs in ([stale, observer, legacy], [legacy, observer, stale],
|
||||
[observer, legacy, stale], [legacy, stale, observer]):
|
||||
with self.subTest(profile=profile, fault=fault, first=inputs[0]["manifest"]["target"]):
|
||||
self.assertEqual(select_ordinary_full_records(inputs), [observer])
|
||||
|
||||
def test_new_non_mqtt_full_repeater_replaces_case_and_underscore_legacy_names(self):
|
||||
for primary_name, legacy_name in (
|
||||
("heltec_v4_tft_repeater", "heltec_v4_tft_repeater_bridge_espnow"),
|
||||
("ThinkNode_M2_repeater", "ThinkNode_M2_Repeater_bridge_espnow"),
|
||||
("nibble_zero_connect_repeater_", "nibble_zero_connect_repeater_bridge_espnow_"),
|
||||
("Meshadventurer_sx1262_repeater", "Meshadventurer_sx1262_repeater_bridge_espnow"),
|
||||
):
|
||||
primary = record(primary_name, capabilities=("bridge.espnow",))
|
||||
for profile in ("full", "standard"):
|
||||
legacy = record(legacy_name, profile, capabilities=("bridge.espnow",))
|
||||
for inputs in ([primary, legacy], [legacy, primary]):
|
||||
with self.subTest(primary=primary_name, profile=profile):
|
||||
self.assertEqual(select_ordinary_full_records(inputs), [primary])
|
||||
|
||||
def test_combined_observer_with_trailing_underscore_replaces_matching_espnow(self):
|
||||
observer = record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_",
|
||||
|
||||
@@ -27,10 +27,18 @@ class RS232RuntimeTests(unittest.TestCase):
|
||||
self.assertIn('def("bridge", bridge);', header)
|
||||
code = code.replace("@IMPORT@", extract_braced(source, 'if (fs->exists("/prefs.json"))'))
|
||||
for profile, macros in (
|
||||
("legacy_dedicated", []),
|
||||
("merged_default_on", ["RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]),
|
||||
("merged", ["RS232_BRIDGE_MERGED=1"]),
|
||||
("merged_composite", ["RS232_BRIDGE_MERGED=1", "ESPNOW_BRIDGE_MERGED=1"]),
|
||||
("legacy_dedicated", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2"]),
|
||||
("merged_default_on", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
|
||||
"RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]),
|
||||
("merged", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
|
||||
"RS232_BRIDGE_MERGED=1"]),
|
||||
("merged_composite", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
|
||||
"RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1",
|
||||
"ESPNOW_BRIDGE_MERGED=1"]),
|
||||
("sole_espnow_merged", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"]),
|
||||
("sole_espnow_dedicated", ["WITH_ESPNOW_BRIDGE=1"]),
|
||||
("sole_espnow_default_on", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1",
|
||||
"ESPNOW_BRIDGE_DEFAULT_ON=1"]),
|
||||
):
|
||||
with self.subTest(profile=profile), tempfile.TemporaryDirectory(prefix="rs232-json-") as directory:
|
||||
work = Path(directory)
|
||||
@@ -42,7 +50,7 @@ class RS232RuntimeTests(unittest.TestCase):
|
||||
source_file, binary = work / "main.cpp", work / "test"
|
||||
source_file.write_text(code, encoding="ascii")
|
||||
result = subprocess.run([
|
||||
compiler, "-std=c++17", "-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2",
|
||||
compiler, "-std=c++17",
|
||||
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
|
||||
*["-D" + macro for macro in macros], "-I" + str(work),
|
||||
"-I" + str(ROOT / "test/mocks"), "-I" + str(ROOT / "src"),
|
||||
@@ -68,6 +76,10 @@ class RS232RuntimeTests(unittest.TestCase):
|
||||
("rs232_espnow_merged", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
|
||||
"RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1",
|
||||
"ESPNOW_BRIDGE_MERGED=1"]),
|
||||
("sole_espnow_merged", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"]),
|
||||
("sole_espnow_dedicated", ["WITH_ESPNOW_BRIDGE=1"]),
|
||||
("sole_espnow_default_on", ["WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1",
|
||||
"ESPNOW_BRIDGE_DEFAULT_ON=1"]),
|
||||
):
|
||||
with self.subTest(profile=profile), tempfile.TemporaryDirectory(prefix="rs232-prefs-") as directory:
|
||||
work = Path(directory)
|
||||
|
||||
+30
-14
@@ -178,7 +178,7 @@ int main() {
|
||||
fixture = (ROOT / "test/fixtures/webconfig_ota_handoff.cpp").read_text()
|
||||
self.compile_and_run(fixture.replace("@METHODS@", methods))
|
||||
|
||||
def test_rs232_espnow_ota_pauses_only_wireless_bridge_and_restores_failures(self):
|
||||
def test_non_mqtt_espnow_ota_pauses_wireless_and_restores_saved_intent(self):
|
||||
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
|
||||
start = cli.index(' } else if (memcmp(command, "start ota", 9)')
|
||||
end = cli.index(' } else if (memcmp(command, "clock", 5)', start)
|
||||
@@ -271,6 +271,9 @@ int main() {
|
||||
assert(strstr(reply.text, "; ESP-NOW resume failed"));
|
||||
// A disabled secondary must stay disabled after the upload stops.
|
||||
prefs.espnow_bridge_enabled = false;
|
||||
#if !defined(WITH_RS232_BRIDGE)
|
||||
prefs.bridge_enabled = false;
|
||||
#endif
|
||||
callbacks.allow_resume = true;
|
||||
board.allow_start = true;
|
||||
const int resumes = callbacks.resumes;
|
||||
@@ -281,9 +284,14 @@ int main() {
|
||||
assert(callbacks.uart_changes == 0 && callbacks.uart && reply.guard == 42);
|
||||
}
|
||||
'''
|
||||
self.compile_and_run(fixture.replace("@CLI@", cli[start:end]),
|
||||
"-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1",
|
||||
"-DWITH_RS232_BRIDGE=1", "-DWITH_ESPNOW_BRIDGE=1")
|
||||
for profile, flags in (
|
||||
("rs232_composite", ["-DWITH_RS232_BRIDGE=1"]),
|
||||
("merged_sole", ["-DESPNOW_BRIDGE_MERGED=1"]),
|
||||
("legacy_dedicated_sole", []),
|
||||
):
|
||||
with self.subTest(profile=profile):
|
||||
self.compile_and_run(fixture.replace("@CLI@", cli[start:end]),
|
||||
"-DESP_PLATFORM=1", "-DADMIN_PASSWORD=1", "-DWITH_ESPNOW_BRIDGE=1", *flags)
|
||||
|
||||
def test_background_bridge_retry_waits_until_ota_stops(self):
|
||||
fixture = r'''
|
||||
@@ -303,11 +311,13 @@ struct Bridge {
|
||||
bool isRunning() const { return running; }
|
||||
void begin() { ++starts; running = true; }
|
||||
};
|
||||
using ESPNowBridge = Bridge;
|
||||
struct WiFiMock { bool isConnected() { return true; } } WiFi;
|
||||
struct Mesh {
|
||||
Cli _cli;
|
||||
struct { bool espnow_bridge_enabled = true; } _prefs;
|
||||
struct { bool bridge_enabled = true, espnow_bridge_enabled = true; } _prefs;
|
||||
Bridge espnow_bridge;
|
||||
@SOLE_MEMBER@
|
||||
Bridge* @MQTT_MEMBER@ = nullptr;
|
||||
uint32_t shared_espnow_retry_at = 0;
|
||||
void configureBridgeFilter(Bridge*) {}
|
||||
@@ -318,27 +328,33 @@ int main() {
|
||||
// Simulate repeated servicePostMeshLoop calls, spanning several retry windows.
|
||||
for (now = 0; now <= 30000; now += 100) {
|
||||
assert(!mesh.startSharedEspNowBridgeIfReady());
|
||||
assert(mesh.espnow_bridge.starts == 0);
|
||||
assert(mesh.@TRANSPORT@.starts == 0);
|
||||
}
|
||||
board.ota = false;
|
||||
assert(mesh.startSharedEspNowBridgeIfReady());
|
||||
assert(mesh.espnow_bridge.starts == 1);
|
||||
assert(mesh.@TRANSPORT@.starts == 1);
|
||||
assert(mesh.startSharedEspNowBridgeIfReady());
|
||||
assert(mesh.espnow_bridge.starts == 1);
|
||||
mesh.espnow_bridge.running = false;
|
||||
assert(mesh.@TRANSPORT@.starts == 1);
|
||||
mesh.@TRANSPORT@.running = false;
|
||||
mesh._prefs.espnow_bridge_enabled = false;
|
||||
mesh._prefs.bridge_enabled = false;
|
||||
now += 30000;
|
||||
assert(!mesh.startSharedEspNowBridgeIfReady());
|
||||
assert(mesh.espnow_bridge.starts == 1);
|
||||
assert(mesh.@TRANSPORT@.starts == 1);
|
||||
}
|
||||
'''
|
||||
for role, mqtt_member in (("simple_repeater", "mqtt_bridge"),
|
||||
("simple_room_server", "bridge")):
|
||||
with self.subTest(role=role):
|
||||
for role, mqtt_member, transport, flags in (
|
||||
("simple_repeater", "mqtt_bridge", "espnow_bridge", ["-DWITH_MQTT_BRIDGE=1"]),
|
||||
("simple_repeater", "mqtt_bridge", "espnow_bridge", ["-DWITH_RS232_BRIDGE=1"]),
|
||||
("simple_repeater", "mqtt_bridge", "bridge", []),
|
||||
("simple_room_server", "bridge", "espnow_bridge", ["-DWITH_MQTT_BRIDGE=1"]),
|
||||
):
|
||||
with self.subTest(role=role, flags=flags):
|
||||
source = (ROOT / "examples" / role / "MyMesh.h").read_text()
|
||||
retry = extract_braced(source, "bool startSharedEspNowBridgeIfReady()")
|
||||
self.compile_and_run(fixture.replace("@RETRY@", retry)
|
||||
.replace("@MQTT_MEMBER@", mqtt_member))
|
||||
.replace("@MQTT_MEMBER@", mqtt_member).replace("@TRANSPORT@", transport)
|
||||
.replace("@SOLE_MEMBER@", "Bridge bridge;" if role == "simple_repeater" else ""), *flags)
|
||||
|
||||
def test_full_companion_cli_reports_webconfig_stop_and_hardware_errors(self):
|
||||
source = (ROOT / "examples/companion_radio/MyMesh.cpp").read_text()
|
||||
|
||||
Reference in new issue
Block a user