Retain independent RS232 in Full MQTT repeaters

This commit is contained in:
mikecarper committed 2026-10-05 18:21:42 -07:00
1 parent 28023c7302
commit 51d74a200e
37 files changed
+1572 -196

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
// Real repeater methods and CLI dispatch are inserted by the Python harness.
#include <cassert>
#include <cstdio>
#include <cstring>
#include <cstdint>
#include <initializer_list>
#include <new>
#include <helpers/CLICommandUtils.h>
#include <helpers/bridges/ESPNowBridgeFormat.h>
#define BRIDGE_MAX_BAUD 115200
#define PUB_KEY_SIZE 32
#define MESH_DEBUG_PRINTLN(...) ((void)0)
namespace mesh {
struct Packet { bool allowed = true; };
struct LocalIdentity { uint8_t pub_key[32] = {}; };
struct Utils { static void toHex(char* out, const uint8_t*, unsigned) { strcpy(out, "mock-node"); } };
}
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, rs232_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;
float freq = 915, bw = 125;
uint8_t sf = 7, cr = 5, disable_fwd = 0;
char bridge_secret[32] = {}, node_name[32] = "triple bridge";
};
struct FakeUart {
unsigned begins = 0, ends = 0;
int rx = -1, tx = -1;
uint32_t baud = 0;
bool running = false;
} Serial2;
struct FakeSensors {
unsigned yield_calls = 0;
bool gpsUsesSerialUart(uint8_t uart) const { return uart == 1; }
bool gpsSerialTransportMayConflict(uint8_t uart) const { return uart == 1; }
bool gpsSerialTransportCanYield(uint8_t) const { return true; }
bool setGpsSerialTransportBlocked(uint8_t, bool) { ++yield_calls; return true; }
};
class AbstractBridge {
bool (*filter)(void*, const mesh::Packet*) = nullptr;
void* filter_context = nullptr;
protected:
bool allow(mesh::Packet* packet) { return !filter || filter(filter_context, packet); }
public:
unsigned filters = 0;
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;
void setPacketFilter(bool (*check)(void*, const mesh::Packet*), void* context) {
filter = check; filter_context = context; ++filters;
}
};
class RS232Bridge : public AbstractBridge {
NodePrefs* prefs;
FakeUart& uart;
int rx, tx;
bool running = false;
public:
inline static unsigned live = 0, fail_starts = 0, fail_allocations = 0;
static void* operator new(size_t size, const std::nothrow_t&) noexcept {
if (fail_allocations) { --fail_allocations; return nullptr; }
return ::operator new(size, std::nothrow);
}
static void operator delete(void* pointer) noexcept { ::operator delete(pointer); }
unsigned loops = 0, sent = 0;
RS232Bridge(NodePrefs* p, FakeUart& u, int r, int t, void*, void*)
: prefs(p), uart(u), rx(r), tx(t) { ++live; }
~RS232Bridge() override { --live; }
void begin() override {
assert(filters); ++uart.begins;
if (fail_starts) { --fail_starts; return; }
uart.rx = rx; uart.tx = tx; uart.baud = prefs->bridge_baud;
running = uart.running = true;
}
void end() override { ++uart.ends; running = uart.running = false; }
bool isRunning() const override { return running; }
void loop() override { ++loops; }
void sendPacket(mesh::Packet* p) override { if (allow(p)) ++sent; }
};
class ESPNowBridge : public AbstractBridge {
bool running = false;
public:
unsigned begins = 0, ends = 0, fail_starts = 0, loops = 0, sent = 0;
void begin() override {
assert(filters); ++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* p) override { if (allow(p)) ++sent; }
};
struct MQTTNodeInfo {
const char* node_name;
float* freq;
float* bw;
uint8_t* sf;
uint8_t* cr;
uint8_t* repeat_flag;
bool repeat_when_nonzero;
};
class MQTTBridge : public AbstractBridge {
bool running = false;
public:
inline static unsigned live = 0, fail_starts = 0;
unsigned begins = 0, ends = 0, loops = 0, sent = 0, received = 0;
MQTTBridge(MQTTNodeInfo, void*, void*, mesh::LocalIdentity*) { ++live; }
~MQTTBridge() override { --live; }
void setDeviceID(const char*) {}
void setFirmwareVersion(const char*) {}
void setBoardModel(const char*) {}
void setBuildDate(const char*) {}
void setStatsSources(void*, void*, void*, void*) {}
void begin() override {
assert(filters); ++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* p) override { if (allow(p)) ++sent; }
void onPacketReceived(mesh::Packet* p) { if (allow(p)) ++received; }
};
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 FakeWiFi { bool connected = true; bool isConnected() const { return connected; } } WiFi;
struct FakeBoard {
bool ota = false, allow_start = true, allow_stop = true;
bool isOTAUpdateRunning() const { return ota; }
const char* getManufacturerName() const { return "synthetic ESP32"; }
bool startOTAUpdate(const char*, char* reply, bool) {
ota = allow_start; strcpy(reply, allow_start ? "Started: http://192.168.4.1/update" : "ERR: OTA failed");
return allow_start;
}
bool stopOTAUpdate(char* reply) {
if (allow_stop) ota = false;
strcpy(reply, allow_stop ? "OK - OTA stopped" : "ERR: upload active"); return allow_stop;
}
};
struct FakeCLI {
FakeBoard board;
FakeBoard* getBoard() { return &board; }
void* getObserverPrefs() { return nullptr; }
};
struct FakeAlerter { void setBridge(MQTTBridge*) {} };
struct Callbacks {
virtual ~Callbacks() = default;
virtual bool isBridgeRunning() const = 0;
virtual bool setBridgeState(bool) = 0;
virtual bool restartBridge() = 0;
virtual bool isRs232BridgeRunning() const = 0;
virtual bool setRs232BridgeState(bool) = 0;
virtual bool restartRs232Bridge() = 0;
virtual bool isEspNowBridgeRunning() = 0;
virtual bool setEspNowBridgeState(bool) = 0;
virtual bool restartEspNowBridge() = 0;
virtual bool isMqttBridgeRunning() = 0;
virtual bool setMqttBridgeState(bool) = 0;
virtual bool restartMqttBridge() = 0;
};
class MyMesh : public Callbacks {
public:
NodePrefs _prefs;
RS232Bridge* bridge = nullptr;
MQTTBridge* mqtt_bridge = nullptr;
ESPNowBridge espnow_bridge;
uint8_t active_rs232_bridge_uart = 0;
uint32_t shared_espnow_retry_at = 0;
unsigned filter_checks = 0;
FakeSensors sensors;
FakeCLI _cli;
FakeAlerter _alerter;
mesh::LocalIdentity self_id;
void* _mgr = nullptr; void* _radio = nullptr; void* _ms = nullptr;
void* getRTCClock() { return nullptr; }
mesh::LocalIdentity getSelfId() { return self_id; }
const char* getFirmwareVer() { return "test"; }
const char* getBuildDate() { return "test"; }
bool allowTransportPacket(const mesh::Packet* p, unsigned mode) {
assert(mode == 2); ++filter_checks; return p->allowed;
}
@FILTER@
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;
}
~MyMesh() override {
assert(setRs232BridgeState(false));
assert(setBridgeState(false));
delete mqtt_bridge;
}
@LIFECYCLE@
};
@PARSERS@
class CommonCLI {
public:
NodePrefs* _prefs;
MyMesh* _callbacks;
FakeSensors* _sensors;
FakeBoard* _board;
NodePrefs stored;
unsigned saves = 0;
bool fail_save = false;
explicit CommonCLI(MyMesh& mesh) : _prefs(&mesh._prefs), _callbacks(&mesh),
_sensors(&mesh.sensors), _board(mesh._cli.getBoard()) {}
void savePrefs() { ++saves; stored = *_prefs; }
bool trySavePrefs() { if (fail_save) return false; savePrefs(); return true; }
void set(const char* input, char* reply) {
char config[256] = {}; assert(strlen(input) < sizeof(config)); strcpy(config, input);
@SET_BRANCHES@
}
bool get(const char* config, char* reply) {
@GET_BRANCHES@
return false;
}
void run(const char* command, char* reply) {
if (false) {
@OTA@
}
}
};
static void expect_get(CommonCLI& cli, const char* key, const char* expected) {
char reply[256] = {}; cli.get(key, reply); assert(strcmp(reply, expected) == 0);
}
int main() {
{
MyMesh fresh; fresh.defaults(); CommonCLI cli(fresh);
assert(fresh._prefs.bridge_enabled && fresh._prefs.espnow_bridge_enabled);
assert(!fresh._prefs.rs232_bridge_enabled);
const unsigned before = Serial2.begins;
fresh.boot();
assert(fresh.isMqttBridgeRunning() && fresh.isEspNowBridgeRunning());
assert(!fresh.isRs232BridgeRunning() && !fresh.bridge && Serial2.begins == before);
expect_get(cli, "bridge.type", "> mqtt+rs232+espnow");
expect_get(cli, "rs232.enabled", "> off");
expect_get(cli, "rs232.running", "> off");
}
MyMesh mesh; mesh.defaults(); mesh.boot(); CommonCLI cli(mesh);
char reply[256] = {};
cli.set("rs232.enabled on", reply);
assert(strcmp(reply, "OK") == 0 && mesh.isRs232BridgeRunning());
assert(Serial2.rx == 5 && Serial2.tx == 6 && Serial2.baud == 115200);
assert(mesh.sensors.yield_calls == 0 && mesh.bridgesPreventSleep());
expect_get(cli, "rs232.enabled", "> on"); expect_get(cli, "rs232.running", "> on");
mesh::Packet packet;
mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges();
assert(mesh.bridge->sent == 1 && mesh.bridge->loops == 1);
assert(mesh.espnow_bridge.sent == 1 && mesh.espnow_bridge.loops == 1);
assert(mesh.mqtt_bridge->received == 1 && mesh.mqtt_bridge->sent == 1);
assert(mesh.mqtt_bridge->loops == 0); // Its actual task services MQTT independently.
assert(mesh.filter_checks == 4);
packet.allowed = false;
mesh.routeRx(&packet); mesh.routeTx(&packet);
assert(mesh.filter_checks == 8 && mesh.bridge->sent == 1 && mesh.espnow_bridge.sent == 1);
assert(mesh.mqtt_bridge->received == 1 && mesh.mqtt_bridge->sent == 1);
packet.allowed = true;
const unsigned wifi_starts = mesh.mqtt_bridge->begins, wireless_starts = mesh.espnow_bridge.begins;
cli.set("bridge.baud 57600", reply);
assert(strcmp(reply, "OK") == 0 && Serial2.baud == 57600);
cli.set("bridge.uart 2", reply);
assert(strcmp(reply, "OK") == 0 && mesh.isRs232BridgeRunning());
assert(mesh.mqtt_bridge->begins == wifi_starts && mesh.espnow_bridge.begins == wireless_starts);
const unsigned saves = cli.saves;
for (const char* invalid : {"rs232.enabled onjunk", "bridge.uart 1", "bridge.uart 2junk",
"bridge.baud 9599", "bridge.baud 115201", "bridge.baud 57600junk"}) {
cli.set(invalid, reply); assert(strncmp(reply, "Error", 5) == 0 && cli.saves == saves);
assert(mesh.isRs232BridgeRunning() && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning());
}
RS232Bridge::fail_starts = 1;
cli.set("bridge.baud 19200", reply);
assert(strstr(reply, "baud unchanged") && cli.saves == saves);
assert(mesh._prefs.bridge_baud == 57600 && Serial2.baud == 57600 && mesh.isRs232BridgeRunning());
assert(mesh.mqtt_bridge->begins == wifi_starts && mesh.espnow_bridge.begins == wireless_starts);
cli.set("rs232.enabled off", reply); assert(strcmp(reply, "OK") == 0 && !mesh.bridge);
const unsigned disabled_saves = cli.saves;
RS232Bridge::fail_starts = 1;
cli.set("rs232.enabled on", reply);
assert(strstr(reply, "setting unchanged") && cli.saves == disabled_saves && !mesh.bridge);
assert(!mesh._prefs.rs232_bridge_enabled && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning());
RS232Bridge::fail_allocations = 1;
cli.set("rs232.enabled on", reply);
assert(strstr(reply, "setting unchanged") && !mesh.bridge && !mesh._prefs.rs232_bridge_enabled);
assert(cli.saves == disabled_saves && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning());
cli.fail_save = true;
cli.set("rs232.enabled on", reply);
assert(strstr(reply, "setting unchanged") && !mesh.bridge && !mesh._prefs.rs232_bridge_enabled);
assert(cli.saves == disabled_saves && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning());
cli.fail_save = false;
cli.set("rs232.enabled on", reply); assert(strcmp(reply, "OK") == 0);
const unsigned saved_after_enable = cli.saves;
cli.fail_save = true;
cli.set("rs232.enabled off", reply);
assert(strstr(reply, "setting unchanged") && mesh.isRs232BridgeRunning() && mesh._prefs.rs232_bridge_enabled);
cli.set("bridge.baud 19200", reply);
assert(strstr(reply, "baud unchanged") && mesh._prefs.bridge_baud == 57600 && Serial2.baud == 57600);
cli.set("bridge.uart 2", reply);
assert(strstr(reply, "UART unchanged") && mesh._prefs.bridge_uart == 2 && mesh.isRs232BridgeRunning());
assert(cli.saves == saved_after_enable && mesh.mqtt_bridge->begins == wifi_starts);
assert(mesh.espnow_bridge.begins == wireless_starts);
cli.fail_save = false;
// Saved UART settings recreate a running independent bridge after reboot.
{
MyMesh restarted; restarted.defaults(); restarted._prefs = cli.stored; restarted.boot();
assert(restarted.isRs232BridgeRunning() && restarted.isMqttBridgeRunning());
assert(restarted._prefs.bridge_baud == 57600 && restarted.isEspNowBridgeRunning());
}
// ESP-NOW format/channel changes and its legacy alias never restart the UART.
const unsigned uart_before_wireless = Serial2.begins;
cli.set("bridge.channel 6", reply); cli.set("bridge.secret three-transports", reply);
cli.set("bridge.format raw", reply); assert(strncmp(reply, "OK", 2) == 0);
assert(Serial2.begins == uart_before_wireless && mesh.mqtt_bridge->begins == wifi_starts);
cli.set("bridge.enabled off", reply);
assert(strcmp(reply, "OK") == 0 && !mesh.isEspNowBridgeRunning());
assert(mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning());
cli.set("mqtt.enabled off", reply);
assert(strcmp(reply, "OK") == 0 && !mesh.isMqttBridgeRunning());
assert(mesh.isRs232BridgeRunning() && mesh.bridgesPreventSleep());
const unsigned uart_tx_before = mesh.bridge->sent;
mesh.routeRx(&packet); mesh.serviceBridges();
assert(mesh.bridge->sent == uart_tx_before + 1);
cli.set("espnow.enabled on", reply); assert(strcmp(reply, "OK") == 0);
cli.set("mqtt.enabled on", reply); assert(strcmp(reply, "OK") == 0);
mesh.serviceBridges(); assert(mesh.isEspNowBridgeRunning());
// Browser OTA releases only WiFi users and leaves the enabled UART running.
const unsigned uart_before_ota = Serial2.begins;
cli.run("start ota ap", reply);
assert(mesh._cli.board.ota && !mesh.isMqttBridgeRunning() && !mesh.isEspNowBridgeRunning());
assert(mesh.isRs232BridgeRunning() && Serial2.begins == uart_before_ota);
for (unsigned tick = 0; tick < 5; ++tick) { fake_millis += 5001; mesh.serviceBridges(); }
assert(!mesh.isEspNowBridgeRunning() && mesh.isRs232BridgeRunning());
mesh._cli.board.allow_stop = false; cli.run("stop ota", reply);
assert(strcmp(reply, "ERR: upload active") == 0 && !mesh.isMqttBridgeRunning());
mesh._cli.board.allow_stop = true; cli.run("stop ota", reply); mesh.serviceBridges();
assert(!mesh._cli.board.ota && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning());
assert(mesh.isRs232BridgeRunning() && Serial2.begins == uart_before_ota);
// Wireless startup failure retries without stopping or reallocating UART.
cli.set("espnow.enabled off", reply); mesh.espnow_bridge.fail_starts = 2;
cli.set("espnow.enabled on", reply); mesh.serviceBridges();
const unsigned retry_starts = mesh.espnow_bridge.begins;
mesh.serviceBridges(); assert(mesh.espnow_bridge.begins == retry_starts);
fake_millis += 5001; mesh.serviceBridges(); assert(!mesh.isEspNowBridgeRunning());
fake_millis += 5001; mesh.serviceBridges(); assert(mesh.isEspNowBridgeRunning());
assert(mesh.isRs232BridgeRunning() && Serial2.begins == uart_before_ota);
// Every switch, parameter change and rollback leaves other transports intact.
for (unsigned cycle = 0; cycle < 100; ++cycle) {
cli.set("rs232.enabled off", reply);
assert(!mesh.bridge && mesh.isMqttBridgeRunning() && mesh.isEspNowBridgeRunning());
cli.set("rs232.enabled on", reply);
assert(mesh.isRs232BridgeRunning() && RS232Bridge::live == 1);
}
cli.set("rs232.enabled off", reply); cli.set("espnow.enabled off", reply); cli.set("mqtt.enabled off", reply);
assert(!mesh.bridgesPreventSleep() && RS232Bridge::live == 0);
cli.run("start ota", reply); cli.run("stop ota", reply); mesh.serviceBridges();
assert(!mesh.isRs232BridgeRunning() && !mesh.isMqttBridgeRunning() && !mesh.isEspNowBridgeRunning());
assert(mesh.sensors.yield_calls == 0);
puts("Three transport runtime checks passed");
}
+21 -3
View File
@@ -6,7 +6,7 @@
#include <helpers/ConfigSerializer.h>
struct NodePrefs : ConfigSerializer {
uint8_t bridge_enabled = 0, bridge_uart = 2, espnow_bridge_enabled = 0;
uint8_t bridge_enabled = 0, bridge_uart = 2, espnow_bridge_enabled = 0, rs232_bridge_enabled = 0;
uint16_t bridge_delay = 500;
uint8_t bridge_pkt_src = 1, bridge_channel = 1, bridge_format = 0;
uint32_t bridge_baud = 115200;
@@ -47,8 +47,14 @@ struct CLI {
}
bool import(FakeFS* fs) {
bool loaded = false, is_upgrade = false;
#ifdef WITH_MQTT_BRIDGE
bool node_prefs_needs_migration = false;
#endif
@IMPORT@
assert(loaded == is_upgrade);
#ifdef WITH_MQTT_BRIDGE
assert(node_prefs_needs_migration == loaded);
#endif
return loaded;
}
};
@@ -60,17 +66,27 @@ int main() {
"{name:\"imported node\",bridge:{en:1,uart:1}}",
"{name:\"imported node\",bridge:{en:1,uart:2}}",
"{name:\"imported node\",bridge:{en:0,uart:2}}",
"{name:\"imported node\",bridge:{en:1,uart:2,rs232:1}}",
}) {
CLI cli;
cli.prefs.espnow_bridge_enabled = 1; // An old unused flag cannot prove intent.
cli.prefs.rs232_bridge_enabled = 1; // Reused objects cannot donate UART intent.
FakeFS fs{json};
assert(cli.import(&fs) && cli.saves == 1);
assert(cli.import(&fs));
#ifdef WITH_MQTT_BRIDGE
assert(cli.saves == 0);
#else
assert(cli.saves == 1);
#endif
assert(strcmp(cli.prefs.node_name, "imported node") == 0);
#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(WITH_MQTT_BRIDGE) && defined(WITH_RS232_BRIDGE)
assert(cli.prefs.bridge_enabled == (strstr(json, "en:1") != nullptr));
assert(cli.prefs.rs232_bridge_enabled == 0 && cli.prefs.bridge_uart == 2);
#elif defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON)
const bool explicit_uart = strstr(json, "uart:1") || strstr(json, "uart:2");
const bool explicit_uart = strstr(json, "uart:2");
const bool enabled = explicit_uart && strstr(json, "en:1");
assert(cli.prefs.bridge_enabled == enabled && cli.prefs.bridge_uart == 2);
#else
@@ -85,8 +101,10 @@ int main() {
#else
assert(cli.prefs.espnow_bridge_enabled == 1);
#endif
#ifndef WITH_MQTT_BRIDGE
assert(cli.saved_uart == cli.prefs.bridge_uart && cli.saved_enabled == cli.prefs.bridge_enabled);
assert(cli.saved_secondary == cli.prefs.espnow_bridge_enabled);
#endif
}
// Malformed JSON may partially apply scalar fields before the parser rejects it.
CLI broken;
+7 -3
View File
@@ -29,7 +29,7 @@ template<typename T> T constrain(T value, int low, int high) {
}
struct NodePrefs {
uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0;
uint8_t bridge_enabled = 0, espnow_bridge_enabled = 0, rs232_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;
@@ -117,6 +117,9 @@ struct Callbacks {
virtual bool isBridgeRunning() const = 0;
virtual bool setBridgeState(bool) = 0;
virtual bool restartBridge() = 0;
virtual bool isRs232BridgeRunning() const = 0;
virtual bool setRs232BridgeState(bool) = 0;
virtual bool restartRs232Bridge() = 0;
#ifdef WITH_ESPNOW_BRIDGE
virtual bool isEspNowBridgeRunning() = 0;
virtual bool setEspNowBridgeState(bool) = 0;
@@ -186,6 +189,7 @@ public:
explicit CommonCLI(MyMesh& mesh)
: _prefs(&mesh._prefs), _callbacks(&mesh), _sensors(&mesh.sensors) {}
void savePrefs() { ++saves; stored = *_prefs; }
bool trySavePrefs() { savePrefs(); return true; }
void load(FakeFile file) {
@INITIAL_UART@
@LOAD_UART@
@@ -212,7 +216,7 @@ static void check_upgrade(bool has_tail, uint8_t saved_uart, uint8_t enabled) {
CommonCLI cli(mesh);
cli.load(FakeFile{has_tail, saved_uart});
#if defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON)
const bool normal_upgrade = !has_tail || saved_uart == 0;
const bool normal_upgrade = !has_tail || saved_uart != 2;
#else
const bool normal_upgrade = false;
#endif
@@ -241,7 +245,7 @@ int main() {
assert(Serial2.begins == begins + (mesh._prefs.bridge_enabled ? 1 : 0));
}
// Missing old tails and current normal-repeater zero sentinels fail safe.
// Legacy dedicated UART1 is intent, and normalizes to the board's UART2.
// Only a supported legacy UART proves intent; unsupported ports fail closed.
for (uint8_t enabled : {uint8_t{0}, uint8_t{1}}) {
check_upgrade(false, 0, enabled);
check_upgrade(true, 0, enabled);
+141 -63
View File
@@ -1,3 +1,4 @@
#include <fstream>
struct Capture {
std::vector<uint8_t> bytes;
std::map<const void*, size_t> offsets;
@@ -9,9 +10,39 @@ struct Capture {
size_t offset(const void* field) const { return offsets.at(field); }
};
int main() {
// The same executable can exchange actual serialized files with another profile.
// This catches role transitions which cannot be represented by a single macro set.
int main(int argc, char** argv) {
if (argc > 1) {
assert(argc == 7);
CommonCLI cli;
const bool writing = strcmp(argv[1], "write") == 0;
if (writing) {
cli.prefs.bridge_enabled = atoi(argv[3]);
cli.prefs.espnow_bridge_enabled = atoi(argv[4]);
cli.prefs.rs232_bridge_enabled = atoi(argv[5]);
cli.prefs.bridge_uart = 2;
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto& image = cli.fs.files.at("/com_prefs");
std::ofstream stream(argv[2], std::ios::binary);
stream.write(reinterpret_cast<const char*>(image.data()), image.size());
assert(stream.good());
} else {
std::ifstream stream(argv[2], std::ios::binary);
assert(stream.good());
cli.fs.files["/com_prefs"] = std::vector<uint8_t>(
std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>());
cli.loadPrefsInt(&cli.fs, "/com_prefs");
assert(cli.prefs.bridge_enabled == atoi(argv[3]));
assert(cli.prefs.espnow_bridge_enabled == atoi(argv[4]));
assert(cli.prefs.rs232_bridge_enabled == atoi(argv[5]));
assert(cli.prefs.bridge_uart == atoi(argv[6]));
}
return 0;
}
CommonCLI writer;
strcpy(writer.prefs.node_name, "MKE combined repeater");
strcpy(writer.prefs.node_name, "Combined repeater");
strcpy(writer.prefs.password, "test-password");
writer.prefs.bridge_uart = 2;
writer.prefs.bridge_baud = 57600;
@@ -22,80 +53,134 @@ int main() {
const size_t uart_offset = initial.offset(&writer.prefs.bridge_uart);
const size_t enabled_offset = initial.offset(&writer.prefs.bridge_enabled);
const size_t channel_offset = initial.offset(&writer.prefs.ota_channel);
const size_t esp_marker_offset = channel_offset + 1;
const size_t rs_intent_offset = initial.offset(&writer.prefs.rs232_bridge_enabled);
const size_t rs_marker_offset = channel_offset + 3;
assert(initial.bytes.size() == 877 && channel_offset == 873);
assert(rs_intent_offset == channel_offset + 2);
#ifdef ESPNOW_BRIDGE_MERGED
assert(initial.bytes.size() == 875 && initial.bytes.back() == 0xA1);
const size_t marker_offset = channel_offset + 1;
assert(marker_offset == initial.bytes.size() - 1);
assert(initial.bytes[esp_marker_offset] == 0xA1);
#else
assert(initial.bytes.size() == 874 && channel_offset == initial.bytes.size() - 1);
assert(initial.bytes[esp_marker_offset] == 0);
#endif
#ifdef WITH_RS232_BRIDGE
assert(initial.bytes[rs_marker_offset] == 0xB1);
#else
assert(initial.bytes[rs_marker_offset] == 0);
#endif
assert(initial.offset(writer.prefs.node_name) == 4);
assert(initial.offset(&writer.prefs.freq) == 72);
assert(initial.offset(&writer.prefs.bridge_enabled) == 127);
assert(enabled_offset == 127);
for (uint8_t primary : {uint8_t{0}, uint8_t{1}}) {
for (uint8_t secondary : {uint8_t{0}, uint8_t{1}}) {
writer.prefs.bridge_enabled = primary;
writer.prefs.espnow_bridge_enabled = secondary;
writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common);
assert(writer._common_save_succeeded);
const auto image = writer.fs.files.at("/com_prefs");
Capture checked;
assert(writeCommonPrefsImage(checked, &writer.prefs));
assert(checked.bytes == image); // Checked and ordinary writers agree.
CommonCLI reader;
reader.fs.files["/com_prefs"] = image;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
for (uint8_t uart_intent : {uint8_t{0}, uint8_t{1}}) {
writer.prefs.bridge_enabled = primary;
writer.prefs.espnow_bridge_enabled = secondary;
writer.prefs.rs232_bridge_enabled = uart_intent;
writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common);
assert(writer._common_save_succeeded);
const auto image = writer.fs.files.at("/com_prefs");
Capture checked;
assert(writeCommonPrefsImage(checked, &writer.prefs));
assert(checked.bytes == image); // Checked and ordinary writers agree.
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);
assert(reader.prefs.bridge_enabled == secondary);
#else
assert(reader.prefs.bridge_enabled == primary);
assert(reader.prefs.bridge_enabled == primary);
#endif
assert(reader.prefs.espnow_bridge_enabled == secondary);
assert(reader.prefs.espnow_bridge_enabled == secondary);
#ifdef WITH_RS232_BRIDGE
assert(reader.prefs.bridge_uart == 2);
assert(reader.prefs.bridge_uart == 2);
#ifdef WITH_MQTT_BRIDGE
assert(reader.prefs.rs232_bridge_enabled == uart_intent);
#else
assert(reader.prefs.bridge_uart == 0);
assert(reader.prefs.rs232_bridge_enabled == primary);
#endif
assert(reader.prefs.bridge_baud == 57600 && reader.prefs.bridge_channel == 6);
assert(strcmp(reader.prefs.node_name, writer.prefs.node_name) == 0);
assert(strcmp(reader.prefs.bridge_secret, "test-secret") == 0);
#else
assert(reader.prefs.bridge_uart == 0 && reader.prefs.rs232_bridge_enabled == 0);
#endif
assert(reader.prefs.bridge_baud == 57600 && reader.prefs.bridge_channel == 6);
assert(strcmp(reader.prefs.node_name, writer.prefs.node_name) == 0);
assert(strcmp(reader.prefs.bridge_secret, "test-secret") == 0);
}
}
}
writer.prefs.bridge_enabled = 1;
writer.prefs.espnow_bridge_enabled = 1;
Capture on;
assert(writeCommonPrefsImage(on, &writer.prefs));
writer.prefs.rs232_bridge_enabled = 1;
writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common);
assert(writer._common_save_succeeded);
const auto on = writer.fs.files.at("/com_prefs");
#ifdef RS232_BRIDGE_MERGED
// Missing UART tails and current no-bridge zero sentinels both fail safe.
for (bool missing_tail : {false, true}) {
auto old = on.bytes;
if (missing_tail) old.resize(uart_offset);
else old[uart_offset] = 0;
assert(old[enabled_offset] == 1);
#ifdef WITH_RS232_BRIDGE
// Unmarked previous MQTT files cannot prove UART intent, even when they
// contain UART2 and primary enabled. Dedicated UART profiles retain context.
for (size_t length : {uart_offset, esp_marker_offset, rs_intent_offset}) {
auto old = on; old.resize(length);
CommonCLI reader;
reader.fs.files["/com_prefs"] = old;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.bridge_enabled == 0 && reader.prefs.bridge_uart == 2);
assert(reader._com_prefs_needs_upgrade);
#ifdef WITH_MQTT_BRIDGE
assert(reader.prefs.bridge_enabled == 1 && reader.prefs.rs232_bridge_enabled == 0);
#elif defined(RS232_BRIDGE_MERGED) && !defined(RS232_BRIDGE_DEFAULT_ON)
const bool legacy_uart_present = length > uart_offset;
assert(reader.prefs.bridge_enabled == legacy_uart_present);
assert(reader.prefs.rs232_bridge_enabled == legacy_uart_present);
#endif
assert(reader.prefs.bridge_uart == 2 && reader._com_prefs_needs_upgrade);
}
// Older dedicated MKE builds selected Serial2 but persisted the UART1 fallback.
auto dedicated = on.bytes;
dedicated[uart_offset] = 1;
// A partial new tail, invalid marker or invalid boolean must never fall back
// to a different transport's primary setting. Short reads fail closed too.
for (unsigned invalid = 0; invalid <= 255; ++invalid) {
if (invalid == 0xB1) continue;
CommonCLI reader;
auto corrupt = on; corrupt[rs_marker_offset] = static_cast<uint8_t>(invalid);
reader.fs.files["/com_prefs"] = corrupt;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.rs232_bridge_enabled == 0 && reader._com_prefs_needs_upgrade);
#ifdef WITH_MQTT_BRIDGE
assert(reader.prefs.bridge_enabled == 1);
#else
assert(reader.prefs.bridge_enabled == 0);
#endif
}
for (unsigned invalid = 2; invalid <= 255; ++invalid) {
CommonCLI reader;
auto corrupt = on; corrupt[rs_intent_offset] = static_cast<uint8_t>(invalid);
reader.fs.files["/com_prefs"] = corrupt;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.rs232_bridge_enabled == 0);
}
for (size_t fault : {rs_intent_offset, rs_marker_offset}) {
CommonCLI reader;
reader.fs.files["/com_prefs"] = on;
reader.fs.fail_read_after = static_cast<int>(fault);
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.rs232_bridge_enabled == 0);
}
CommonCLI torn;
torn.fs.files["/com_prefs"] = {on.begin(), on.begin() + rs_marker_offset};
torn.loadPrefsInt(&torn.fs, "/com_prefs");
assert(torn.prefs.rs232_bridge_enabled == 0);
#if defined(RS232_BRIDGE_MERGED) && !defined(WITH_MQTT_BRIDGE)
// Old Serial2 builds saved the generic UART1 fallback; known UART2 remains
// the safe resolved port, without losing a valid new independent intent.
auto dedicated = on; dedicated[uart_offset] = 1;
CommonCLI legacy_reader;
legacy_reader.fs.files["/com_prefs"] = dedicated;
legacy_reader.loadPrefsInt(&legacy_reader.fs, "/com_prefs");
assert(legacy_reader.prefs.bridge_enabled == 1 && legacy_reader.prefs.bridge_uart == 2);
#endif
#endif
#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;
old[marker_offset] = static_cast<uint8_t>(invalid);
auto old = on; old[esp_marker_offset] = static_cast<uint8_t>(invalid);
CommonCLI reader;
reader.fs.files["/com_prefs"] = old;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
@@ -107,30 +192,23 @@ int main() {
#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;
old.resize(marker_offset);
auto old = on; old.resize(esp_marker_offset);
CommonCLI reader;
reader.fs.files["/com_prefs"] = old;
reader.loadPrefsInt(&reader.fs, "/com_prefs");
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.fs.files["/com_prefs"] = on;
reader.fs.fail_read_after = static_cast<int>(esp_marker_offset);
reader.loadPrefsInt(&reader.fs, "/com_prefs");
assert(reader.prefs.espnow_bridge_enabled == 0);
// A torn append must retain the previous durable settings image.
writer.savePrefs(&writer.fs, PrefsSaveRouting::Scope::Common);
assert(writer._common_save_succeeded);
const auto previous = writer.fs.files.at("/com_prefs");
writer.fs.fail_write_after = static_cast<int>(marker_offset);
writer.prefs.espnow_bridge_enabled = 0;
assert(!writer.trySavePrefs());
assert(writer.fs.files.at("/com_prefs") == previous);
#endif
// Every append boundary must commit atomically for observer and plain writers.
for (size_t fault : {esp_marker_offset, rs_intent_offset, rs_marker_offset}) {
const auto previous = writer.fs.files.at("/com_prefs");
writer.fs.fail_write_after = static_cast<int>(fault);
writer.prefs.rs232_bridge_enabled = 0;
assert(!writer.trySavePrefs());
assert(writer.fs.files.at("/com_prefs") == previous);
writer.fs.fail_write_after = -1;
}
}
+30
View File
@@ -341,6 +341,23 @@ require("RAK_4631_repeater", "build_flags", "WITH_RS232_BRIDGE_ALT=Serial1")
require("RAK_4631_repeater", "build_flags", "WITH_RS232_BRIDGE_UART=2")
reject("wio-e5_repeater", "build_flags", "WITH_RS232_BRIDGE=")
# Full source substitution must preserve the normal repeater UART rather
# than retiring its legacy bridge while compiling an MQTT-only recipe.
for env_name, rx, tx in (
("Heltec_v3_repeater_observer_mqtt", 5, 6),
("Heltec_WSL3_repeater_observer_mqtt", 5, 6),
("RAK_3112_repeater_observer_mqtt", 5, 6),
("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_", 34, 25),
):
require(env_name, "build_flags", "WITH_MQTT_BRIDGE=1")
require(env_name, "build_flags", "WITH_RS232_BRIDGE=Serial2")
require(env_name, "build_flags", "WITH_RS232_BRIDGE_UART=2")
require(env_name, "build_flags", f"WITH_RS232_BRIDGE_RX={rx}")
require(env_name, "build_flags", f"WITH_RS232_BRIDGE_TX={tx}")
require(env_name, "build_flags", "RS232_BRIDGE_MERGED=1")
require(env_name, "build_src_filter", "helpers/bridges/RS232Bridge.cpp")
reject(env_name, "build_flags", "RS232_BRIDGE_DEFAULT_ON")
# MKE keeps its ordinary role and UART wiring while exposing the existing
# persistent bridge controls. The merged marker selects the disabled default;
# the explicit legacy bridge remains enabled for deliberate direct builds.
@@ -1606,6 +1623,19 @@ if is_redundant_bulk_build_target wio-e5-repeater_bridge_rs232; then
fail "capacity-constrained Wio-E5 RS232 bridge was incorrectly merged"
fi
(
BUILD_PROFILE_FOR_TARGET=full
for uart_target in Heltec_v3_repeater Heltec_WSL3_repeater RAK_3112_repeater \
LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_; do
BUILD_EXPECTATIONS=()
declare_build_capability_contract "$uart_target" ESP32_PLATFORM
[[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.rs232=_ZN11RS232Bridge5beginEv"* ]] \
|| fail "$uart_target Full contract omitted the linked UART driver"
[[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.espnow=_ZN12ESPNowBridge5beginEv"* ]] \
|| fail "$uart_target Full contract omitted the linked ESP-NOW driver"
done
)
# A WiFi base's final -UENABLE_OTA must not win over the Full Companion's
# source-only OTA overlay. That mismatch compiles out both the TCP terminal and
# seeder while still leaving a superficially valid binary.
+5
View File
@@ -49,6 +49,7 @@ COMMON_QUERY_EXCEPTIONS = {
'bootloader.ver': 'Installed bootloader identity',
'bridge.running': 'Live bridge state; set bridge.enabled controls intent',
'espnow.running': 'Live ESP-NOW bridge state; set espnow.enabled controls intent',
'rs232.running': 'Live UART bridge state; set rs232.enabled controls intent',
'bridge.type': 'Build-selected bridge transport',
'password': 'Written by the password command, not set password',
'public.key': 'Derived from identity; written through set prv.key',
@@ -87,6 +88,10 @@ class CLISettingsContractTest(unittest.TestCase):
'rs232_gps': ['WITH_BRIDGE', 'WITH_RS232_BRIDGE', 'ENV_INCLUDE_GPS'],
'rs232_espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE',
'WITH_RS232_BRIDGE', 'WITH_ESPNOW_BRIDGE'],
'mqtt_rs232_espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE',
'WITH_MQTT_BRIDGE', 'WITH_RS232_BRIDGE', 'WITH_ESPNOW_BRIDGE'],
'mqtt_rs232': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE',
'WITH_MQTT_BRIDGE', 'WITH_RS232_BRIDGE'],
'espnow': ['ESP_PLATFORM', 'ESP32_PLATFORM', 'WITH_BRIDGE', 'WITH_ESPNOW_BRIDGE', 'MESH_PRIMARY_ESPNOW'],
'lr2021': ['USE_LR2021'],
}
+1 -1
View File
@@ -86,7 +86,7 @@ int main() {
auto original=cli.fs.files["/com_prefs"];
// Appending preferences must not move the established preamble bytes.
const size_t preamble_offset=866;
assert(original.size()==874);
assert(original.size()==877);
assert(original[preamble_offset]==48 && original[preamble_offset+1]==0);
assert(original[preamble_offset+2]==1);
assert(original[preamble_offset+3]==0x50 && original[preamble_offset+4]==0x01);
+1 -1
View File
@@ -159,7 +159,7 @@ int main() {
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto baseline = cli.fs.files["/com_prefs"];
assert(baseline.size() == 874);
assert(baseline.size() == 877);
const size_t debug_offset = 871, trace_offset = 872;
const size_t logging_offset = 863, preamble_offset = 866;
const size_t espnow_offset = 868, gps_offset = 869;
+34
View File
@@ -1284,6 +1284,40 @@ assert(!picker.runtimeDirections(staleBridgeMetadata.profiles.find(profile =>
profile.target === bridgeTargets[0]), {mode: 'espnow'}).some(section => section.title === 'ESP-NOW bridge'));
console.log('combined UART/ESP-NOW picker modes, independent commands and historical compatibility passed');
// MQTT, ESP-NOW and UART coexist in the same qualified Full artifact. UART
// instructions must never use MQTT's historical ESP-NOW bridge alias.
const tripleTargets = ['Heltec_v3_repeater', 'Heltec_WSL3_repeater',
'RAK_3112_repeater', 'LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_']
.map(target => target + '-full-usb-wifi');
const tripleCatalog = picker.buildCatalog([
release(bridgeFamily, '2026-10-05T00:00:00Z', tripleTargets.map(target =>
asset(target + '-ota-' + bridgeFamily + '.bin'))),
], {
familyTag: bridgeFamily,
profiles: Object.fromEntries(tripleTargets.map(target => [target, {
platform: 'ESP32_PLATFORM', rs232: true, mqtt: true, espnowBridge: true,
updateMethods: ['wifi', 'lora'],
}])),
});
for (const profile of tripleCatalog.profiles) {
assert.deepStrictEqual(picker.profileFieldValues(profile, 'mode'),
['standard', 'rs232', 'espnow']);
const directions = picker.runtimeDirections(profile, {mode: 'rs232'});
const uart = directions.find(section => /^RS232 bridge/.test(section.title));
const wireless = directions.find(section => section.title === 'ESP-NOW bridge');
const mqtt = directions.find(section => section.title === 'MQTT broker connections');
assert(uart && wireless && mqtt);
assert.deepStrictEqual(uart.actions.map(action => action.commands), [
['set rs232.enabled on', 'get rs232.running'], ['set rs232.enabled off'],
['set rs232.enabled off', 'set bridge.baud 115200', 'set rs232.enabled on'],
]);
assert(commands([wireless]).includes('set espnow.enabled on'));
assert(commands([mqtt]).includes('set mqtt.enabled on'));
assert(!commands([uart, wireless, mqtt]).some(command => command.includes('bridge.enabled')));
}
assert.strictEqual(tripleCatalog.profiles.length, tripleTargets.length);
console.log('three-transport Full picker uses independent UART, MQTT and ESP-NOW controls');
// Partition expansion uses the packager's lookup and published utility assets,
// not hardware labels or guessed slugs (several historical names differ).
const migrationFamily = currentControls.familyTag;
+15
View File
@@ -173,6 +173,21 @@ class FirmwareRamTest(unittest.TestCase):
}, "v4_companion")
self.assertEqual(default, expanded)
def test_combined_uart_reserves_heap_beside_mqtt_before_enable(self):
defines = {"WITH_MQTT_BRIDGE": 1, "WIFI_OTA_SEEDER": 1}
mqtt = ram.requirements("ESP32_PLATFORM", defines, "v3_repeater")
combined = ram.requirements("ESP32_PLATFORM", {
**defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1,
}, "v3_repeater")
self.assertEqual(combined["required_heap_bytes"] - mqtt["required_heap_bytes"], 8192)
self.assertEqual(combined["components"]["uart_bridge_and_driver"], 8192)
# A selectable alternate port does not create two UART owners.
alternate = ram.requirements("ESP32_PLATFORM", {
**defines, "WITH_RS232_BRIDGE": "Serial2", "RS232_BRIDGE_MERGED": 1,
"WITH_RS232_BRIDGE_ALT": "Serial1",
}, "v3_repeater")
self.assertEqual(alternate, combined)
def test_published_image_tables_match_elf_and_use_its_own_reservations(self):
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "reference.elf"
+36 -4
View File
@@ -20,6 +20,38 @@ def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM",
class ReleaseFullSelectionTest(unittest.TestCase):
def test_full_uart_source_substitution_requires_linked_driver_proof(self):
for base in ("Heltec_v3_repeater", "Heltec_WSL3_repeater", "RAK_3112_repeater",
"LilyGo_TLora_V2_1_1_6_repeater"):
for suffix in ("", "_observer_mqtt", "_observer_mqtt_"):
for fault in ("missing_capability", "missing_proof", "false_proof", "unverified"):
image = record(base + suffix, capabilities=("bridge.rs232", "bridge.espnow"))
manifest = image["manifest"]
if fault == "missing_capability":
manifest["capabilities"].remove("bridge.rs232")
elif fault == "missing_proof":
manifest["verification"] = []
elif fault == "false_proof":
manifest["verification"][0]["present"] = False
else:
manifest["verified"] = False
with self.subTest(base=base, suffix=suffix, fault=fault):
with self.assertRaisesRegex(ValueError, "verified RS232 bridge"):
select_ordinary_full_records([image])
def test_new_combined_full_displaces_stale_plain_and_both_dedicated_bridges(self):
for base in ("Heltec_v3_repeater", "Heltec_WSL3_repeater", "RAK_3112_repeater",
"LilyGo_TLora_V2_1_1_6_repeater"):
stale = record(base, capabilities=("bridge.espnow",))
primary = record(base + "_observer_mqtt_",
capabilities=("bridge.rs232", "bridge.espnow"))
for profile in ("standard", "full"):
old = [record(base + "_bridge_" + bridge, profile)
for bridge in ("rs232", "espnow")]
for inputs in ([stale, primary] + old, old + [primary, stale]):
with self.subTest(base=base, profile=profile):
self.assertEqual(select_ordinary_full_records(inputs), [primary])
def test_meshtower_sd_primary_filters_stale_internal_images_without_relabeling(self):
primary = "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"
variants = []
@@ -63,7 +95,7 @@ class ReleaseFullSelectionTest(unittest.TestCase):
inputs = [
record("Heltec_v3_repeater_observer_mqtt"),
record("Heltec_v3_repeater_bridge_espnow"),
record("Heltec_v3_repeater", capabilities=("bridge.espnow",)),
record("Heltec_v3_repeater", capabilities=("bridge.espnow", "bridge.rs232")),
record("Heltec_v3_repeater", "standard"),
record("Heltec_v3_room_server"),
record("Station_G2_repeater"),
@@ -71,7 +103,7 @@ class ReleaseFullSelectionTest(unittest.TestCase):
record("Tbeam_SX1262_repeater_bridge_espnow"),
record("Tbeam_SX1262_repeater_observer_mqtt", capabilities=("bridge.espnow",)),
record("LilyGo_TLora_V2_1_1_6_repeater"),
record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_"),
record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_", capabilities=("bridge.rs232",)),
record("Meshadventurer_sx1262_repeater"),
record("Meshadventurer_sx1262_repeater_bridge_espnow"),
record("RAK_4631_repeater", platform="NRF52_PLATFORM"),
@@ -158,7 +190,7 @@ class ReleaseFullSelectionTest(unittest.TestCase):
self.assertEqual(select_ordinary_full_records(ordered), ordered)
def test_qualified_combined_plain_image_replaces_only_its_matching_espnow_image(self):
primary = record("Heltec_v3_repeater", capabilities=("bridge.espnow",))
primary = record("Heltec_v3_repeater", capabilities=("bridge.espnow", "bridge.rs232"))
other_board = record("heltec_v4_tft_repeater_bridge_espnow", capabilities=("bridge.espnow",))
for profile in ("full", "standard"):
legacy = record("Heltec_v3_repeater_bridge_espnow", profile, capabilities=("bridge.espnow",))
@@ -221,7 +253,7 @@ class ReleaseFullSelectionTest(unittest.TestCase):
def test_combined_observer_with_trailing_underscore_replaces_matching_espnow(self):
observer = record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_",
capabilities=("bridge.espnow",))
capabilities=("bridge.espnow", "bridge.rs232"))
legacy = record("LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow",
capabilities=("bridge.espnow",))
for inputs in ([observer, legacy], [legacy, observer]):
+12 -11
View File
@@ -398,7 +398,7 @@ class Nrf52VariantContractsTest(unittest.TestCase):
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
self.assertIn("bool isBridgeRunning() const override", header)
self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x01", implementation)
self.assertIn("if (isRs232BridgeRunning()) reply_data[8] |= 0x01", implementation)
self.assertIn("if (isBridgeRunning()) reply_data[8] |= 0x03", implementation)
boot_failure = implementation[
implementation.index("if (!bridge || !beginRS232Bridge())"):
@@ -406,7 +406,7 @@ class Nrf52VariantContractsTest(unittest.TestCase):
"if (!bridge || !beginRS232Bridge())"
))
]
self.assertIn("setBridgeState(false)", boot_failure)
self.assertIn("setRs232BridgeState(false)", boot_failure)
self.assertNotIn("_prefs.bridge_enabled", boot_failure)
self.assertIn("parseOnOffStrict(&config[15], enable)", cli)
self.assertIn("parseUnsignedIntegerStrict(&config[12], baud)", cli)
@@ -417,7 +417,7 @@ class Nrf52VariantContractsTest(unittest.TestCase):
self.assertIn('_prefs->bridge_pkt_src ? "rx" : "tx"', cli)
self.assertIn("!defined(RS232_BRIDGE_DEFAULT_ON)", cli)
def test_default_on_rs232_profile_skips_default_off_tail_migration(self) -> None:
def test_default_on_rs232_profile_retains_legacy_alias_and_checks_new_intent(self) -> None:
cli = (ROOT / "src/helpers/CommonCLI.cpp").read_text()
xiao = (ROOT / "variants/xiao_nrf52/platformio.ini").read_text()
dedicated = ini_section(
@@ -426,17 +426,18 @@ class Nrf52VariantContractsTest(unittest.TestCase):
self.assertIn("extends = env:solarxiao_30S_repeater", dedicated)
self.assertRegex(dedicated, r"-D\s+RS232_BRIDGE_DEFAULT_ON=1\b")
guard = (
"#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \\\n"
" && !defined(RS232_BRIDGE_DEFAULT_ON)"
)
# Declaration, persisted-tail detection, and migration must share the
# same guard. A broader use breaks dedicated default-on bridge builds.
# Dedicated compatibility profiles retain their old primary UART alias;
# every current UART profile checks the independent marker before a
# newer MQTT image can donate intent. Executable nRF52/default-on and
# cross-profile round trips are covered by test_rs232_runtime.py.
loader_start = cli.index("void CommonCLI::loadPrefsInt(")
loader_end = cli.index("static bool writeCommonPrefsImage(", loader_start)
binary_loader = cli[loader_start:loader_end]
self.assertEqual(binary_loader.count(guard), 3)
self.assertEqual(binary_loader.count("has_runtime_bridge_uart"), 3)
self.assertTrue("has_runtime_rs232_intent = rs232_profile == 0xB1" in binary_loader)
self.assertTrue("&& rs232_intent <= 1" in binary_loader)
self.assertTrue("if (!has_runtime_rs232_intent)" in binary_loader)
self.assertTrue("legacy_uart_intent" in binary_loader)
self.assertTrue("_prefs->bridge_enabled = _prefs->rs232_bridge_enabled;" in binary_loader)
def test_gat562_30s_gps_enable_and_buzzer_pins_are_distinct(self) -> None:
recipe = (
+4 -1
View File
@@ -66,7 +66,10 @@ void testRoundTripsAndLayout() {
const size_t channel_offset = original.offset(&cli.prefs.ota_channel);
const size_t trace_offset = original.offset(&cli.prefs.trace_when_repeat_off);
assert(channel_offset == trace_offset + sizeof(cli.prefs.trace_when_repeat_off));
assert(channel_offset + sizeof(cli.prefs.ota_channel) == original.bytes.size());
assert(channel_offset + sizeof(cli.prefs.ota_channel) + 3 == original.bytes.size());
assert(original.bytes[channel_offset + 1] == 0); // reserved ESP-NOW profile
assert(original.offset(&cli.prefs.rs232_bridge_enabled) == channel_offset + 2);
assert(original.bytes[channel_offset + 2] == 0 && original.bytes.back() == 0);
// The established common core remains at its public file offsets.
assert(original.offset(cli.prefs.node_name) == 4);
assert(original.offset(&cli.prefs.freq) == 72);
+1 -1
View File
@@ -21,7 +21,7 @@ int main() {
cli.savePrefs(&cli.fs, PrefsSaveRouting::Scope::Common);
assert(cli._common_save_succeeded);
const auto baseline = cli.fs.files["/com_prefs"];
assert(baseline.size() == 874);
assert(baseline.size() == 877);
const size_t trace_offset = 872, debug_offset = 871, gps_offset = 869;
assert(baseline[trace_offset] == 0 && baseline[debug_offset] == 1);
assert(baseline[866] == 48 && baseline[867] == 0 && baseline[868] == 1);
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Execute the production ESP32 UART begin/end and bridge state checks."""
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
FIXTURE = r'''
#include <cassert>
#include <cstdint>
#define ESP32 1
#define BRIDGE_DEBUG_PRINTLN(...) ((void)0)
struct NodePrefs { uint32_t bridge_baud = 57600; };
struct Stream { virtual ~Stream() = default; };
struct HardwareSerial : Stream {
bool pins_ok = true, begin_ok = true, installed = false;
unsigned pin_calls = 0, begin_calls = 0, end_calls = 0;
int16_t rx = -1, tx = -1;
uint32_t baud = 0;
bool setPins(int16_t selected_rx, int16_t selected_tx) {
++pin_calls; rx = selected_rx; tx = selected_tx; return pins_ok;
}
void begin(uint32_t selected_baud) { ++begin_calls; baud = selected_baud; installed = begin_ok; }
explicit operator bool() const { return installed; }
void end() { ++end_calls; installed = false; }
};
struct BridgeBase {
bool _initialized = false;
@RUNNING@
};
struct RS232Bridge : BridgeBase {
NodePrefs* _prefs;
Stream* _serial;
int16_t _rx_pin, _tx_pin;
RS232Bridge(NodePrefs& prefs, HardwareSerial& serial)
: _prefs(&prefs), _serial(&serial), _rx_pin(5), _tx_pin(6) {}
void begin();
void end();
};
@BEGIN@
@END@
int main() {
NodePrefs prefs;
HardwareSerial serial;
RS232Bridge bridge(prefs, serial);
assert(!bridge.isRunning());
serial.pins_ok = false;
bridge.begin();
assert(!bridge.isRunning() && serial.pin_calls == 1 && serial.begin_calls == 0);
serial.pins_ok = true; serial.begin_ok = false;
bridge.begin();
assert(!bridge.isRunning() && !serial.installed && serial.begin_calls == 1);
serial.begin_ok = true;
bridge.begin();
assert(bridge.isRunning() && serial.installed && serial.begin_calls == 2);
assert(serial.rx == 5 && serial.tx == 6 && serial.baud == 57600);
bridge.end();
assert(!bridge.isRunning() && !serial.installed && serial.end_calls == 1);
// A failed restart must clear its former state, and a later retry can recover.
bridge.begin(); assert(bridge.isRunning());
serial.pins_ok = false;
bridge.begin(); assert(!bridge.isRunning());
bridge.end(); serial.pins_ok = true; prefs.bridge_baud = 19200;
bridge.begin(); assert(bridge.isRunning() && serial.baud == 19200);
for (unsigned cycle = 0; cycle < 100; ++cycle) {
bridge.end(); assert(!bridge.isRunning());
bridge.begin(); assert(bridge.isRunning());
}
bridge.end(); assert(!bridge.isRunning());
}
'''
class RS232DriverLifecycleTests(unittest.TestCase):
def test_failed_pins_or_driver_start_never_report_running_and_retry_recovers(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
driver = (ROOT / "src/helpers/bridges/RS232Bridge.cpp").read_text(encoding="ascii")
base = (ROOT / "src/helpers/bridges/BridgeBase.cpp").read_text(encoding="ascii")
code = FIXTURE.replace("@RUNNING@", extract_braced(base, "bool BridgeBase::isRunning() const")
.replace("BridgeBase::", "").replace(" override", ""))
code = code.replace("@BEGIN@", extract_braced(driver, "void RS232Bridge::begin()"))
code = code.replace("@END@", extract_braced(driver, "void RS232Bridge::end()"))
with tempfile.TemporaryDirectory(prefix="rs232-driver-lifecycle-") as directory:
work = Path(directory)
source, binary = work / "main.cpp", work / "test"
source.write_text(code, encoding="ascii")
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
str(source), "-o", str(binary)], capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
if __name__ == "__main__":
unittest.main()
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""Execute real UART/MQTT/ESP-NOW repeater lifecycle and CLI independently."""
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
class RS232MQTTRuntimeTests(unittest.TestCase):
def test_independent_transports_route_retry_restart_and_survive_browser_ota(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")
observer = (ROOT / "src/helpers/CommonCLI_Observer.cpp").read_text(encoding="ascii")
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii")
implementation = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii")
flags = ["-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2",
"-DWITH_RS232_BRIDGE_RX=5", "-DWITH_RS232_BRIDGE_TX=6",
"-DRS232_BRIDGE_MERGED=1", "-DWITH_MQTT_BRIDGE=1", "-DWITH_ESPNOW_BRIDGE=1", "-DWITH_BRIDGE=1",
"-DENV_INCLUDE_GPS=1", "-DLIGHTWEIGHT_WIFI_OTA=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1"]
def preprocess(source, extra=()):
return subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, *extra, "-"],
input=source, capture_output=True, text=True, check=True).stdout
processed_header = preprocess(re.sub(r'^\s*#include[^\n]*', '', header, flags=re.M))
lifecycle = "\n".join(extract_braced(processed_header, signature) for signature in (
"AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {",
"RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()",
"bool isBridgeRunning() const override", "bool rs232BridgeEnabled() const", "bool isRs232BridgeRunning()",
"bool setRs232BridgeState(bool enable)", "bool restartRs232Bridge()",
"bool startSharedEspNowBridgeIfReady()", "bool isEspNowBridgeRunning()",
"bool setEspNowBridgeState(bool enable)", "bool restartEspNowBridge()",
"bool isMqttBridgeRunning()", "bool setMqttBridgeState(bool enable)",
"bool restartMqttBridge()", "bool setBridgeState(bool enable)", "bool restartBridge()",
))
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)
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 = preprocess("\n".join(extract_braced(cli, signature) for signature in (
"void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd(")))
sets = "\nelse ".join(extract_braced(commands, signature) for signature in (
'if (strncmp(config, "mqtt.enabled ", 13) == 0)',
'if (strncmp(config, "rs232.enabled ", 14) == 0)',
'if (memcmp(config, "espnow.enabled ", 15) == 0)',
'if (memcmp(config, "bridge.enabled ", 15) == 0)',
'if (memcmp(config, "bridge.baud ", 12) == 0)',
'if (memcmp(config, "bridge.uart ", 12) == 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(commands, signature) for signature in (
'if (configKeyEquals(config, "bridge.type"))',
'if (configKeyEquals(config, "rs232.enabled"))',
'if (configKeyEquals(config, "rs232.running"))',
'if (configKeyEquals(config, "bridge.enabled"))',
'if (configKeyEquals(config, "bridge.running"))',
'if (configKeyEquals(config, "espnow.enabled"))',
'if (configKeyEquals(config, "espnow.running"))',
'if (configKeyEquals(config, "bridge.baud"))',
'if (configKeyEquals(config, "bridge.uart"))',
))
gets += "\nelse " + "\nelse ".join(extract_braced(observer, signature) for signature in (
'if (strcmp(config, "mqtt.enabled") == 0)', 'if (strcmp(config, "mqtt.running") == 0)'))
ota_start = cli.index(' } else if (memcmp(command, "start ota", 9)')
ota_end = cli.index(' } else if (memcmp(command, "clock", 5)', ota_start)
code = (ROOT / "test/fixtures/rs232_mqtt_runtime.cpp").read_text(encoding="ascii")
for key, value in {
"LIFECYCLE": lifecycle, "DEFAULTS": implementation[defaults_start:defaults_end],
"FILTER": extract_braced(header, "void configureBridgeFilter(AbstractBridge* active_bridge)"),
"BOOT": implementation[boot_start:boot_end], "ROUTE_RX": routing["logRx"],
"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],
"PARSERS": "\n".join(extract_braced(cli, signature) for signature in (
"static bool parseOnOffStrict(", "static bool configKeyEquals(")),
}.items():
code = code.replace("@" + key + "@", value)
with tempfile.TemporaryDirectory(prefix="rs232-mqtt-runtime-") as temporary:
work = Path(temporary)
source, binary = work / "main.cpp", work / "test"
source.write_text(code, 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("Three transport runtime checks passed", result.stdout)
def test_uart_and_mqtt_without_espnow_preserve_primary_mqtt_alias(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")
callback_header = (ROOT / "src/helpers/CommonCLI.h").read_text(encoding="ascii")
header = (ROOT / "examples/simple_repeater/MyMesh.h").read_text(encoding="ascii")
implementation = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii")
flags = ["-DWITH_RS232_BRIDGE=Serial2", "-DWITH_RS232_BRIDGE_UART=2",
"-DWITH_RS232_BRIDGE_RX=5", "-DWITH_RS232_BRIDGE_TX=6", "-DRS232_BRIDGE_MERGED=1",
"-DWITH_MQTT_BRIDGE=1", "-DWITH_BRIDGE=1", "-DENV_INCLUDE_GPS=1",
"-DLIGHTWEIGHT_WIFI_OTA=1", "-DMESH_ENABLE_FLOOD_RULE_ENGINE=1"]
def preprocess(source):
return subprocess.run([compiler, "-E", "-P", "-x", "c++", *flags, "-"],
input=source, capture_output=True, text=True, check=True).stdout
processed_header = preprocess(re.sub(r'^\s*#include[^\n]*', '', header, flags=re.M))
lifecycle = "\n".join(extract_braced(processed_header, signature) for signature in (
"AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {",
"RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()", "bool endRS232Bridge()",
"bool rs232BridgeEnabled() const", "bool isBridgeRunning() const override",
"bool isRs232BridgeRunning()", "bool setRs232BridgeState(bool enable)",
"bool restartRs232Bridge()", "bool isMqttBridgeRunning()", "bool setBridgeState(bool enable)",
"bool restartBridge()"))
code = (ROOT / "test/fixtures/rs232_mqtt_runtime.cpp").read_text(encoding="ascii")
code = code[:code.index("int main() {")]
for signature in ("virtual bool isEspNowBridgeRunning() = 0;",
"virtual bool setEspNowBridgeState(bool) = 0;",
"virtual bool restartEspNowBridge() = 0;"):
code = code.replace(signature, "")
for short, full in (("virtual bool setMqttBridgeState(bool) = 0;", "virtual bool setMqttBridgeState(bool enable)"),
("virtual bool restartMqttBridge() = 0;", "virtual bool restartMqttBridge()")):
code = code.replace(short, extract_braced(callback_header, full))
commands = preprocess("\n".join(extract_braced(cli, signature) for signature in (
"void CommonCLI::handleSetCmd(", "void CommonCLI::handleGetCmd(")))
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)
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)
route = {}
for name in ("logRx", "logTx"):
start = implementation.index("#ifdef WITH_MQTT_BRIDGE\n", implementation.index("void MyMesh::" + name + "("))
route[name] = implementation[start:implementation.index(" if (_logging)", start)]
ota_start = cli.index(' } else if (memcmp(command, "start ota", 9)')
defaults_start = implementation.index("// bridge defaults")
substitutions = {
"LIFECYCLE": lifecycle, "BOOT": implementation[boot_start:boot_end],
"DEFAULTS": implementation[defaults_start:implementation.index("// GPS defaults", defaults_start)],
"FILTER": extract_braced(header, "void configureBridgeFilter(AbstractBridge* active_bridge)"),
"ROUTE_RX": route["logRx"], "ROUTE_TX": route["logTx"],
"SERVICE": implementation[service_start:service_end], "PENDING": implementation[pending_start:pending_end],
"SET_BRANCHES": "\nelse ".join(extract_braced(commands, signature) for signature in (
'if (strncmp(config, "mqtt.enabled ", 13) == 0)', 'if (strncmp(config, "rs232.enabled ", 14) == 0)',
'if (memcmp(config, "bridge.enabled ", 15) == 0)', 'if (memcmp(config, "bridge.baud ", 12) == 0)',
'if (memcmp(config, "bridge.uart ", 12) == 0)')),
"GET_BRANCHES": extract_braced(commands, 'if (configKeyEquals(config, "bridge.type"))'),
"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)],
}
for key, value in substitutions.items():
code = code.replace("@" + key + "@", value)
code += r'''int main() {
assert(millisHasNowPassed(millis()));
MyMesh mesh; mesh.defaults(); mesh.boot(); CommonCLI cli(mesh);
expect_get(cli, "bridge.type", "> mqtt+rs232");
assert(mesh.isMqttBridgeRunning() && !mesh.isRs232BridgeRunning());
char reply[256] = {};
cli.set("rs232.enabled on", reply); assert(strcmp(reply, "OK") == 0);
assert(mesh.isRs232BridgeRunning() && mesh.isMqttBridgeRunning());
const unsigned starts = mesh.mqtt_bridge->begins;
cli.set("bridge.baud 57600", reply); assert(strcmp(reply, "OK") == 0);
assert(mesh.isRs232BridgeRunning() && mesh.mqtt_bridge->begins == starts);
cli.set("bridge.enabled off", reply); assert(strcmp(reply, "OK") == 0);
assert(!mesh._prefs.bridge_enabled && mesh._prefs.rs232_bridge_enabled);
assert(!mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning());
assert(mesh.isBridgeRunning() && mesh.bridgesPreventSleep());
cli.set("bridge.enabled on", reply); assert(strcmp(reply, "OK") == 0);
assert(mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning());
mesh::Packet packet;
mesh.routeRx(&packet); mesh.routeTx(&packet); mesh.serviceBridges();
assert(mesh.bridge->sent == 1 && mesh.bridge->loops == 1);
assert(mesh.mqtt_bridge->received == 1 && mesh.mqtt_bridge->sent == 1);
assert(mesh.filter_checks == 3);
const unsigned uart_starts = Serial2.begins;
cli.run("start ota ap", reply);
assert(mesh._cli.board.ota && !mesh.isMqttBridgeRunning() && mesh.isRs232BridgeRunning());
cli.run("stop ota", reply);
assert(!mesh._cli.board.ota && mesh.isMqttBridgeRunning() && Serial2.begins == uart_starts);
cli.set("rs232.enabled off", reply); cli.set("mqtt.enabled off", reply);
assert(!mesh.bridge && !mesh.isMqttBridgeRunning() && !mesh.bridgesPreventSleep());
cli.set("rs232.enabled on", reply); mesh.mqtt_bridge->fail_starts = 1;
cli.set("mqtt.enabled on", reply);
assert(strstr(reply, "setting saved") && !mesh.isMqttBridgeRunning());
assert(mesh.isRs232BridgeRunning() && mesh.bridgesPreventSleep());
cli.set("mqtt.enabled on", reply);
assert(strcmp(reply, "OK") == 0 && mesh.isMqttBridgeRunning());
}'''
with tempfile.TemporaryDirectory(prefix="rs232-mqtt-only-") as directory:
work = Path(directory)
source, binary = work / "main.cpp", work / "test"
source.write_text(code, encoding="ascii")
built = 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(built.returncode, 0, built.stdout + built.stderr)
checked = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr)
if __name__ == "__main__":
unittest.main()
+86 -14
View File
@@ -35,6 +35,11 @@ class RS232RuntimeTests(unittest.TestCase):
("merged_composite", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1",
"ESPNOW_BRIDGE_MERGED=1"]),
("mqtt_rs232", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1"]),
("mqtt_rs232_espnow", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1",
"WITH_ESPNOW_BRIDGE=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",
@@ -71,11 +76,21 @@ class RS232RuntimeTests(unittest.TestCase):
code += (ROOT / "test/fixtures/rs232_runtime/prefs.cpp").read_text(encoding="ascii")
for profile, macros in (
("ordinary_unmerged", []),
("rs232_dedicated", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2"]),
("rs232_default_on", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]),
("nrf52_rs232_default_on", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "RS232_BRIDGE_DEFAULT_ON=1"]),
("rs232_merged", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1"]),
("rs232_espnow_merged", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "WITH_ESPNOW_BRIDGE=1",
"ESPNOW_BRIDGE_MERGED=1"]),
("mqtt_rs232", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1"]),
("mqtt_rs232_espnow", ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2",
"RS232_BRIDGE_MERGED=1", "WITH_MQTT_BRIDGE=1",
"WITH_ESPNOW_BRIDGE=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",
@@ -89,7 +104,7 @@ class RS232RuntimeTests(unittest.TestCase):
source, binary = work / "main.cpp", work / "test"
source.write_text(code, encoding="ascii")
result = subprocess.run([
compiler, "-std=c++17", "-DESP32_PLATFORM=1", "-DENABLE_OTA=1",
compiler, "-std=c++17", "-D" + ("NRF52_PLATFORM=1" if profile.startswith("nrf52") else "ESP32_PLATFORM=1"), "-DENABLE_OTA=1",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
*["-D" + macro for macro in macros], "-I" + str(work),
"-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"),
@@ -100,27 +115,82 @@ class RS232RuntimeTests(unittest.TestCase):
result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_uart_intent_survives_cross_profile_upgrades_without_claiming_mqtt_intent(self):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++ compiler is required")
base = persistence_harness().replace("#define WITH_RS232_BRIDGE_UART 1", "")
code = base[:base.index("struct Capture {")]
code += (ROOT / "test/fixtures/rs232_runtime/prefs.cpp").read_text(encoding="ascii")
common = ["WITH_RS232_BRIDGE=Serial2", "WITH_RS232_BRIDGE_UART=2", "RS232_BRIDGE_MERGED=1"]
profiles = {"normal": [*common, "WITH_ESPNOW_BRIDGE=1", "ESPNOW_BRIDGE_MERGED=1"],
"dedicated": common[:2],
"triple": [*common, "WITH_ESPNOW_BRIDGE=1", "WITH_MQTT_BRIDGE=1"],
"mqtt_uart": [*common, "WITH_MQTT_BRIDGE=1"]}
with tempfile.TemporaryDirectory(prefix="rs232-profile-upgrade-") as directory:
work = Path(directory)
transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text(encoding="ascii")
(work / "ContactFileTransaction.h").write_text(transaction.replace(
'#include "IdentityStore.h"', '#include <helpers/IdentityStore.h>'), encoding="ascii")
source = work / "main.cpp"
source.write_text(code, encoding="ascii")
for name, macros in profiles.items():
result = subprocess.run([compiler, "-std=c++17", "-DESP32_PLATFORM=1", "-DENABLE_OTA=1",
"-fsanitize=address,undefined", "-fno-sanitize-recover=all", "-fno-pie", "-no-pie",
*["-D" + macro for macro in macros], "-I" + str(work),
"-I" + str(ROOT / "test/fixtures/radio_profiles/mocks"),
"-I" + str(ROOT / "src"), "-I" + str(ROOT / "src/helpers"),
str(source), "-o", str(work / name)], capture_output=True, text=True, timeout=60)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def run(profile, operation, image, primary, espnow, uart, port=2):
result = subprocess.run([str(work / profile), operation, str(image), str(primary),
str(espnow), str(uart), str(port)], capture_output=True, text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for origin in ("triple", "mqtt_uart"):
for uart in (0, 1):
image = work / f"{origin}-{uart}.bin"
run(origin, "write", image, 1, 0, uart)
self.assertEqual(len(image.read_bytes()), 877)
for target in ("normal", "dedicated"):
with self.subTest(origin=origin, target=target, uart=uart):
run(target, "read", image, uart, 0, uart)
# Old MQTT files persisted primary enabled plus UART2.
# Without the new independent marker this remains MQTT only.
old = work / "old-mqtt.bin"
old.write_bytes(image.read_bytes()[:874])
run(origin, "read", old, 1, 0, 0)
for origin in ("normal", "dedicated"):
for uart in (0, 1):
image = work / f"{origin}-{uart}.bin"
run(origin, "write", image, uart, 0, uart)
for target in ("triple", "mqtt_uart"):
with self.subTest(origin=origin, target=target, uart=uart):
run(target, "read", image, uart, 0, uart)
image = work / "corrupt-triple.bin"
run("triple", "write", image, 1, 0, 1)
saved = image.read_bytes()
for tail in (saved[:875] + bytes([1]), saved[:876], saved[:876] + bytes([0])):
image.write_bytes(tail)
for target in ("normal", "dedicated"):
run(target, "read", image, 0, 0, 0)
for target in ("triple", "mqtt_uart"):
run(target, "read", image, 1, 0, 0)
def test_production_defaults_upgrade_and_runtime_controls(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")
implementation = (ROOT / "examples/simple_repeater/MyMesh.cpp").read_text(encoding="ascii")
guard = (
"#if defined(WITH_RS232_BRIDGE) && defined(RS232_BRIDGE_MERGED) \\\n"
" && !defined(RS232_BRIDGE_DEFAULT_ON)"
)
declaration_start = cli.index(guard, cli.index('if (file) {'))
declaration = cli[declaration_start:cli.index("#endif", declaration_start) + len("#endif")]
esp_guard = "#if defined(ESPNOW_BRIDGE_MERGED) && !defined(ESPNOW_BRIDGE_DEFAULT_ON)"
esp_declaration = cli.index(esp_guard, cli.index('if (file) {'))
declaration += "\n" + cli[esp_declaration:cli.index("#endif", esp_declaration) + len("#endif")]
declaration_start = cli.index("#if defined(ESPNOW_BRIDGE_MERGED)", cli.index("mesh::hilStartupTrace(\"prefs_image_open_ready\")"))
declaration_end = cli.index(" // Every supported layout", declaration_start)
declaration = cli[declaration_start:declaration_end]
read_start = cli.index("if (file.available() >= (int)sizeof(_prefs->bridge_uart))")
read_end = cli.index("if (file.available() >= (int)sizeof(_prefs->bridge_format))", read_start)
# The fixture supplies a file positioned at the appended UART field.
uart_read = cli[read_start:read_end] + "}\n"
marker_read_start = cli.index(esp_guard, cli.index("if (file.available() >= (int)sizeof(_prefs->ota_channel))"))
uart_read += cli[marker_read_start:cli.index("#endif", marker_read_start) + len("#endif")]
# The old image mock has just its UART byte, so marker reads return
# zero; execute the real marker guard rather than inventing a fallback.
marker_start = cli.index("uint8_t bridge_profile = 0;", cli.index("if (file.available() >= (int)sizeof(_prefs->ota_channel))"))
uart_read += "uint8_t bridge_profile = 0;\n" + extract_braced(cli[marker_start:], "if (file.read(&bridge_profile,")
migrate_start = cli.index("// sanitise bad bridge pref values")
migrate_end = cli.index("if (!mesh::bridge::isValidEspNowFormat", migrate_start)
migration = cli[migrate_start:migrate_end]
@@ -137,7 +207,9 @@ class RS232RuntimeTests(unittest.TestCase):
lifecycle = "\n".join(extract_braced(header, signature) for signature in (
"AbstractBridge* activeBridge() {", "const AbstractBridge* activeBridge() const {",
"RS232Bridge* createRS232Bridge()", "bool beginRS232Bridge()",
"bool endRS232Bridge()", "bool isBridgeRunning() const override",
"bool endRS232Bridge()", "bool rs232BridgeEnabled() const",
"bool isRs232BridgeRunning() const override", "bool setRs232BridgeState(bool enable)",
"bool restartRs232Bridge()", "bool isBridgeRunning() const override",
"bool setBridgeState(bool enable) override", "bool restartBridge() override",
))
lifecycle += "\n#ifdef WITH_ESPNOW_BRIDGE\n" + "\n".join(
+29 -5
View File
@@ -37,7 +37,10 @@ STUBS = r'''
#define WITH_ESPNOW_BRIDGE 1
#define MESH_DEBUG_PRINTLN(...) ((void)0)
constexpr int WL_CONNECTED = 3, WL_DISCONNECTED = 6, HTTP_GET = 0;
constexpr int WIFI_AP = 2;
enum wifi_mode_t { WIFI_OFF = 0, WIFI_STA = 1, WIFI_AP = 2 };
constexpr wifi_mode_t WIFI_MODE_NULL = WIFI_OFF;
int esp_wifi_stop();
int esp_wifi_deinit();
constexpr int ESP_OK = 0;
constexpr int LISTEN = 10;
@@ -65,7 +68,20 @@ struct MockWiFi {
IPAddress station_ip, ap_ip, configured_ap_ip;
int ap_protocol = 15;
int status() const { return station_status; }
int getMode() const { return sdk_ap_active ? WIFI_AP : 0; }
wifi_mode_t getMode() const {
return started_cache && sdk_started ? (sdk_ap_active ? WIFI_AP : WIFI_STA) : WIFI_OFF;
}
int channel() const { return initialized_cache && sdk_initialized ? 1 : 0; }
bool mode(wifi_mode_t requested) {
if (requested == getMode()) return true;
if (requested == WIFI_OFF) {
esp_wifi_stop(); esp_wifi_deinit();
initialized_cache = started_cache = false;
} else {
sdk_initialized = sdk_started = initialized_cache = started_cache = true;
}
return true;
}
IPAddress localIP() const { return station_ip; }
IPAddress softAPIP() const { return ap_ip; }
bool softAPConfig(IPAddress ip, IPAddress, IPAddress) {
@@ -99,6 +115,11 @@ int esp_wifi_stop() {
return 0;
}
int esp_wifi_deinit() { ++wifi_deinit_calls; sdk_initialized = false; return 0; }
int esp_wifi_get_mode(wifi_mode_t* mode) {
if (!sdk_initialized) return -1;
*mode = sdk_ap_active ? WIFI_AP : WIFI_STA;
return ESP_OK;
}
int server_count = 0, server_begin_calls = 0;
struct AsyncWebServerRequest {
@@ -268,10 +289,12 @@ class WifiOtaLifecycleTests(unittest.TestCase):
source = folder / "lifecycle.cpp"
binary = folder / ("lifecycle.exe" if os.name == "nt" else "lifecycle")
source.write_text(production_harness())
subprocess.run([
built = subprocess.run([
os.environ.get("CXX", "g++"), "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-I", str(ROOT / "src"), str(source), "-o", str(binary),
], check=True, capture_output=True, text=True, timeout=60)
], capture_output=True, text=True, timeout=60)
if built.returncode:
raise RuntimeError(built.stdout + built.stderr)
cls.observations = {}
for scenario in SCENARIOS:
result = subprocess.run([str(binary), scenario], check=True,
@@ -317,7 +340,8 @@ class WifiOtaLifecycleTests(unittest.TestCase):
self.assertEqual(observation["deinit_calls"], count)
self.assertEqual(observation["sdk_initialized"], 1 - count)
self.assertEqual(observation["sdk_started"], 1 - count)
self.assertTrue(observation["initialized_cache"] and observation["started_cache"])
self.assertEqual(observation["initialized_cache"], 1 - count)
self.assertEqual(observation["started_cache"], 1 - count)
self.assertEqual(observation["endpoint_usable"], 1 - count)
if count == 0:
if scenario == "cleanup_web":