Files
MeshCore/test/fixtures/rs232_mqtt_runtime.cpp
T
mikecarper 6b3f7df4d0 Suspend failed ESP32 MQTT and NTP uplinks for power saving
Share one bounded WiFi outage and recovery schedule across MQTT, automatic NTP, Companion station ownership and WebConfig. Preserve enabled settings and foreground access while allowing normal sleep after confirmed physical shutdown.

Honor failed SDK client stops before handing off WiFi, protect clean teardown and reconfiguration, and retry transient ESP-NOW channel restoration without repeated disconnects. Add production-method, SDK failure and real sleep regressions to CI.

Validation: native MQTT/NTP/clock/reconnect suites; production power and wrapper failure tests; browser, room HTTP, OTA and combined bridge regressions; Full Heltec V4 repeater, room and companion builds with capability, flash and linked-memory checks.
2026-10-12 14:57:01 -07:00

451 lines
20 KiB
C++

// 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;
bool stopping = false;
bool network_suspended = 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;
network_suspended = false;
}
void end() override { ++ends; running = stopping = network_suspended = false; }
void requestStop() { stopping = running; }
bool isStopping() const { return stopping; }
bool isRunning() const override { return running; }
bool isNetworkSuspended() const { return network_suspended; }
void setNetworkSuspended(bool paused) {
assert(running && !stopping);
network_suspended = paused;
}
void loop() override {
++loops;
if (stopping) { ++ends; running = stopping = network_suspended = false; }
}
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 requestMqttBridgeStop() = 0;
virtual bool isMqttBridgeStopping() = 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;
@OTA_STATE@
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");
fresh.mqtt_bridge->setNetworkSuspended(true);
assert(fresh.isMqttBridgeRunning() && fresh._prefs.bridge_enabled);
assert(fresh.bridgesPreventSleep()); // ESP-NOW retains its radio owner.
assert(fresh.setEspNowBridgeState(false));
assert(!fresh.bridgesPreventSleep()); // Paused MQTT is still configured.
assert(fresh.setRs232BridgeState(true));
assert(fresh.bridgesPreventSleep()); // An active UART remains independent.
assert(fresh.setRs232BridgeState(false));
assert(!fresh.bridgesPreventSleep());
fresh.mqtt_bridge->setNetworkSuspended(false);
assert(fresh.bridgesPreventSleep()); // The recovery probe owns the radio.
char pause_reply[256] = {};
cli.set("espnow.enabled off", pause_reply);
assert(strcmp(pause_reply, "OK") == 0);
fresh.mqtt_bridge->setNetworkSuspended(true);
assert(fresh.requestMqttBridgeStop());
assert(fresh.isMqttBridgeStopping() && fresh.bridgesPreventSleep());
fresh.serviceBridges();
assert(!fresh.isMqttBridgeRunning() && !fresh.bridgesPreventSleep());
assert(fresh.setBridgeState(true));
assert(fresh.isMqttBridgeRunning() && fresh.bridgesPreventSleep());
cli.set("espnow.enabled on", pause_reply);
assert(strcmp(pause_reply, "OK") == 0 && fresh.isEspNowBridgeRunning());
}
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 == 1); // Core 1 services stop acknowledgement only.
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());
// A failed intent save must not signal an uncancellable worker stop.
cli.fail_save = true;
cli.set("mqtt.enabled off", reply);
assert(strstr(reply, "not saved; unchanged") && mesh._prefs.bridge_enabled);
assert(mesh.isMqttBridgeRunning() && !mesh.isMqttBridgeStopping());
cli.fail_save = false;
cli.set("mqtt.enabled off", reply);
assert(strcmp(reply, "OK") == 0 && mesh.isMqttBridgeRunning());
assert(mesh.isMqttBridgeStopping() && !mesh._prefs.bridge_enabled);
expect_get(cli, "mqtt.running", "> on");
expect_get(cli, "mqtt.stopping", "> on");
const unsigned stopping_saves = cli.saves;
cli.set("mqtt.enabled on", reply);
assert(strstr(reply, "MQTT is stopping") && !mesh._prefs.bridge_enabled);
assert(cli.saves == stopping_saves && !mesh.setMqttBridgeState(true));
cli.set("mqtt.enabled off", reply); // a repeated request is accepted
assert(strcmp(reply, "OK") == 0 && mesh.isMqttBridgeStopping());
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);
assert(!mesh.isMqttBridgeRunning() && !mesh.isMqttBridgeStopping());
expect_get(cli, "mqtt.stopping", "> off");
cli.fail_save = true;
cli.set("mqtt.enabled on", reply);
assert(strstr(reply, "not saved; unchanged") && !mesh._prefs.bridge_enabled);
assert(!mesh.isMqttBridgeRunning());
cli.fail_save = false;
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.isMqttBridgeStopping() && mesh.bridgesPreventSleep());
mesh.serviceBridges();
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");
}