feat: abstract MQTT network transport

This commit is contained in:
agessaman committed 2026-09-01 16:31:28 -07:00
1 parent f8fc35a11a
commit 03c0ad81fe
19 files changed
+731 -306

No files matched your search

+22 -4
View File
@@ -1,7 +1,12 @@
#include "MyMesh.h"
#include <algorithm>
#include <climits>
#include <stdlib.h> // for qsort()
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkInterface.h>
#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET)
#include <WiFi.h>
#endif
#if defined(WITH_MQTT_NEIGHBORS)
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
#endif
@@ -1218,11 +1223,13 @@ void MyMesh::begin(FILESYSTEM *fs) {
#if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP)
// First-boot setup portal: raised only when no WiFi has ever been configured,
// so an OTA onto a deployed (configured) node can never open an AP.
#if !defined(NETWORK_USE_ETHERNET)
if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) {
char wc_reply[160];
startWebConfig(false, wc_reply);
Serial.println(wc_reply);
}
#endif
#endif
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
@@ -1467,6 +1474,11 @@ void MyMesh::clearStats() {
#ifdef WITH_WEBCONFIG
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
#if defined(NETWORK_USE_ETHERNET)
(void)force_ap;
strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI");
return true;
#else
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
@@ -1490,6 +1502,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
_webconfig->startLanMode(reply); // reports "WiFi not connected" if down
}
return true;
#endif
}
bool MyMesh::stopWebConfig(char* reply) {
@@ -1523,12 +1536,17 @@ void MyMesh::onConfigBatchEnd() {
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
char ip[20] = "";
int wifi_rssi = 0;
if (WiFi.status() == WL_CONNECTED) {
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
wifi_rssi = WiFi.RSSI();
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
NetworkInterface& network = activeNetworkInterface();
if (network.isConnected()) {
strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1);
const int signal = network.rssi();
wifi_rssi = signal == INT_MIN ? 0 : signal;
}
#if !defined(NETWORK_USE_ETHERNET)
else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
#endif
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
+22 -4
View File
@@ -1,6 +1,11 @@
#include "MyMesh.h"
#include <algorithm>
#include <climits>
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkInterface.h>
#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET)
#include <WiFi.h>
#endif
#if defined(WITH_MQTT_NEIGHBORS)
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
#endif
@@ -1020,11 +1025,13 @@ void MyMesh::begin(FILESYSTEM *fs) {
#if defined(WITH_WEBCONFIG) && !defined(WEBCONFIG_NO_AUTO_AP)
// First-boot setup portal: raised only when no WiFi has ever been configured,
// so an OTA onto a deployed (configured) node can never open an AP.
#if !defined(NETWORK_USE_ETHERNET)
if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) {
char wc_reply[160];
startWebConfig(false, wc_reply);
Serial.println(wc_reply);
}
#endif
#endif
}
@@ -1269,6 +1276,11 @@ void MyMesh::formatPacketStatsReply(char *reply) {
#ifdef WITH_WEBCONFIG
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
#if defined(NETWORK_USE_ETHERNET)
(void)force_ap;
strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI");
return true;
#else
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
@@ -1292,6 +1304,7 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
_webconfig->startLanMode(reply); // reports "WiFi not connected" if down
}
return true;
#endif
}
bool MyMesh::stopWebConfig(char* reply) {
@@ -1325,12 +1338,17 @@ void MyMesh::onConfigBatchEnd() {
void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
char ip[20] = "";
int wifi_rssi = 0;
if (WiFi.status() == WL_CONNECTED) {
strncpy(ip, WiFi.localIP().toString().c_str(), sizeof(ip) - 1);
wifi_rssi = WiFi.RSSI();
} else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
NetworkInterface& network = activeNetworkInterface();
if (network.isConnected()) {
strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1);
const int signal = network.rssi();
wifi_rssi = signal == INT_MIN ? 0 : signal;
}
#if !defined(NETWORK_USE_ETHERNET)
else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
#endif
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
+36 -17
View File
@@ -220,39 +220,58 @@ static inline void formatAge(uint32_t age_ms, char* out, size_t out_size) {
}
}
static inline void formatWifiDown(char* out, size_t out_size, uint32_t duration_ms,
uint8_t reason) {
static inline void formatNetworkDown(char* out, size_t out_size, const char* medium,
uint32_t duration_ms, uint8_t reason) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
if (reason != 0) {
snprintf(out, out_size, "WiFi down %s (reason %u)", age, (unsigned)reason);
snprintf(out, out_size, "%s down %s (reason %u)", medium, age, (unsigned)reason);
} else {
snprintf(out, out_size, "WiFi down %s", age);
snprintf(out, out_size, "%s down %s", medium, age);
}
}
static inline void formatNetworkRecovered(char* out, size_t out_size,
const char* medium,
uint32_t duration_ms) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
snprintf(out, out_size, "%s recovered after %s", medium, age);
}
// Compatibility helpers retained for existing callers and native tests.
static inline void formatWifiDown(char* out, size_t out_size,
uint32_t duration_ms, uint8_t reason) {
formatNetworkDown(out, out_size, "WiFi", duration_ms, reason);
}
static inline void formatWifiRecovered(char* out, size_t out_size,
uint32_t duration_ms) {
if (!out || out_size == 0) return;
char age[16];
formatAge(duration_ms, age, sizeof(age));
snprintf(out, out_size, "WiFi recovered after %s", age);
formatNetworkRecovered(out, out_size, "WiFi", duration_ms);
}
static inline bool formatNetworkAlert(char* out, size_t out_size,
const char* medium, const TickResult& r,
const OutageSnapshot& snap) {
const char* label = (medium && *medium) ? medium : "Network";
if (r.action == Action::FireDown) {
formatNetworkDown(out, out_size, label, r.duration_ms, snap.reason);
return true;
}
if (r.action == Action::FireRecovered) {
formatNetworkRecovered(out, out_size, label, r.duration_ms);
return true;
}
return false;
}
// Production formatting entry: the same (TickResult, OutageSnapshot) pair
// AlertReporter feeds after tick(). Returns false when there is no message.
static inline bool formatWifiAlert(char* out, size_t out_size, const TickResult& r,
const OutageSnapshot& snap) {
if (r.action == Action::FireDown) {
formatWifiDown(out, out_size, r.duration_ms, snap.reason);
return true;
}
if (r.action == Action::FireRecovered) {
formatWifiRecovered(out, out_size, r.duration_ms);
return true;
}
return false;
return formatNetworkAlert(out, out_size, "WiFi", r, snap);
}
static inline void formatMqttDown(char* out, size_t out_size, int slot_1based,
+11 -2
View File
@@ -6,6 +6,7 @@
#include <stdio.h>
#ifdef WITH_MQTT_BRIDGE
#include "AlertFaultPolicy.h"
#include "NetworkInterface.h"
#endif
// Header layout for PAYLOAD_TYPE_GRP_TXT before encryption:
@@ -217,13 +218,21 @@ void AlertReporter::onLoop(unsigned long now_ms) {
min_interval_ms);
if (r.action == AlertFaultPolicy::Action::FireDown) {
char text[80];
AlertFaultPolicy::formatWifiAlert(text, sizeof(text), r, snap);
AlertFaultPolicy::formatNetworkAlert(
text, sizeof(text),
strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0
? "Ethernet" : "WiFi",
r, snap);
if (sendChannel(text)) {
AlertFaultPolicy::commitDown(_wifi, now, snap.started_ms);
}
} else if (r.action == AlertFaultPolicy::Action::FireRecovered) {
char text[80];
AlertFaultPolicy::formatWifiAlert(text, sizeof(text), r, snap);
AlertFaultPolicy::formatNetworkAlert(
text, sizeof(text),
strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0
? "Ethernet" : "WiFi",
r, snap);
sendChannel(text);
AlertFaultPolicy::commitRecovered(_wifi);
}
+58 -27
View File
@@ -15,7 +15,9 @@
#include "TxtDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]()
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
#include "NetworkInterface.h"
#include <Utils.h>
#include <climits>
#include <new>
#ifdef ESP_PLATFORM
#include <WiFi.h>
@@ -414,8 +416,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
// runs on the Arduino loop task, shared with mesh/radio processing and the
// web config batch, so a synchronous wait of up to 30 s would stall the
// node. The sync runs in the background; verify with `get mqtt.ntp.diag`.
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "OK - saved (WiFi not connected; NTP sync pending)");
if (!activeNetworkInterface().isConnected()) {
snprintf(reply, 160, "OK - saved (%s not connected; NTP sync pending)",
activeNetworkInterface().mediumName());
} else if (!_callbacks->isMqttBridgeRunning()) {
strcpy(reply, "OK - saved (MQTT bridge not running)");
} else if (_callbacks->syncMqttNtp()) {
@@ -457,7 +460,8 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
_mqtt_prefs.wifi_power_save = ps_value;
if (!persistObserverPrefs(reply)) return true;
#ifdef ESP_PLATFORM
if (WiFi.status() == WL_CONNECTED) {
if (strcmp(activeNetworkInterface().mediumName(), "wifi") == 0 &&
activeNetworkInterface().isConnected()) {
wifi_ps_type_t ps_mode = (ps_value == 1) ? WIFI_PS_NONE :
(ps_value == 2) ? WIFI_PS_MAX_MODEM : WIFI_PS_MIN_MODEM;
esp_err_t ps_result = esp_wifi_set_ps(ps_mode);
@@ -958,8 +962,9 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
#ifdef ESP_PLATFORM
// Connectivity probe across all configured NTP servers; never updates the clock.
// Serial console (sender_timestamp == 0) gets a detailed table; LoRa gets a compact list.
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "Error: WiFi not connected");
if (!activeNetworkInterface().isConnected()) {
snprintf(reply, 160, "Error: %s not connected",
activeNetworkInterface().mediumName());
} else if (!_callbacks->isMqttBridgeRunning()) {
strcpy(reply, "Error: MQTT bridge not running");
} else if (!_callbacks->runMqttNtpDiag(reply, 160, sender_timestamp == 0)) {
@@ -1033,20 +1038,28 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
} else {
strcpy(reply, _mqtt_prefs.wifi_password[0] ? "> ******** (serial only)" : "> (not set)");
}
} else if (memcmp(config, "wifi.status", 11) == 0) {
wl_status_t status = WiFi.status();
const char* status_str;
switch (status) {
case WL_CONNECTED: status_str = "connected"; break;
case WL_NO_SSID_AVAIL: status_str = "no_ssid"; break;
case WL_CONNECT_FAILED: status_str = "connect_failed"; break;
case WL_CONNECTION_LOST: status_str = "connection_lost"; break;
case WL_DISCONNECTED: status_str = "disconnected"; break;
case 255: status_str = "not_started"; break;
default: status_str = "unknown"; break;
} else if (memcmp(config, "link.status", 11) == 0 ||
memcmp(config, "wifi.status", 11) == 0) {
NetworkInterface& network = activeNetworkInterface();
const bool wifi_alias = config[0] == 'w';
if (wifi_alias && strcmp(network.mediumName(), "wifi") != 0) {
snprintf(reply, 160, "> n/a (%s selected; use get link.status)",
network.mediumName());
return true;
}
if (status == WL_CONNECTED) {
sprintf(reply, "> %s, IP: %s, RSSI: %d dBm", status_str, WiFi.localIP().toString().c_str(), WiFi.RSSI());
const bool connected = network.isConnected();
if (connected) {
const int signal = network.rssi();
if (wifi_alias) {
snprintf(reply, 160, "> %s, IP: %s, RSSI: %d dBm",
network.statusName(), network.localIP().toString().c_str(), signal);
} else if (signal == INT_MIN) {
snprintf(reply, 160, "> %s: connected, IP: %s", network.mediumName(),
network.localIP().toString().c_str());
} else {
snprintf(reply, 160, "> %s: connected, IP: %s, RSSI: %d dBm",
network.mediumName(), network.localIP().toString().c_str(), signal);
}
#ifdef WITH_MQTT_BRIDGE
unsigned long connect_at = MQTTBridge::getWifiConnectedAtMillis();
if (connect_at != 0) {
@@ -1074,19 +1087,35 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf
#endif
} else {
#ifdef WITH_MQTT_BRIDGE
uint8_t reason = MQTTBridge::getLastWifiDisconnectReason();
uint8_t reason = network.lastDisconnectReason();
if (reason != 0) {
const char* desc = MQTTBridge::wifiReasonStr(reason);
if (desc) {
sprintf(reply, "> %s: %s (reason: %d)", status_str, desc, reason);
if (wifi_alias) {
sprintf(reply, "> %s: %s (reason: %d)", network.statusName(), desc, reason);
} else {
sprintf(reply, "> %s: %s (reason: %d)", network.mediumName(), desc, reason);
}
} else {
sprintf(reply, "> %s: reason %d", status_str, reason);
if (wifi_alias) {
sprintf(reply, "> %s: reason %d", network.statusName(), reason);
} else {
sprintf(reply, "> %s: reason %d", network.mediumName(), reason);
}
}
} else {
sprintf(reply, "> %s (code: %d)", status_str, status);
if (wifi_alias) {
sprintf(reply, "> %s (code: %d)", network.statusName(), network.statusCode());
} else {
sprintf(reply, "> %s: %s", network.mediumName(), network.statusName());
}
}
#else
sprintf(reply, "> %s (code: %d)", status_str, status);
if (wifi_alias) {
sprintf(reply, "> %s (code: %d)", network.statusName(), network.statusCode());
} else {
sprintf(reply, "> %s: %s", network.mediumName(), network.statusName());
}
#endif
}
} else if (memcmp(config, "wifi.powersave", 14) == 0) {
@@ -1150,8 +1179,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
#ifdef WITH_MQTT_BRIDGE
if (memcmp(command, "tls.bundletest ", 15) == 0) {
#ifdef ESP_PLATFORM
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "ERR: WiFi not connected");
if (!activeNetworkInterface().isConnected()) {
snprintf(reply, 160, "ERR: %s not connected",
activeNetworkInterface().mediumName());
} else {
size_t bundle_len = 0;
if (rootca_crt_bundle_start != nullptr &&
@@ -1196,8 +1226,9 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command,
// ota check -> report available build, do not flash
// ota update -> download and flash, then reboot
#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "ERR: WiFi not connected");
if (!activeNetworkInterface().isConnected()) {
snprintf(reply, 160, "ERR: %s not connected",
activeNetworkInterface().mediumName());
} else if (memcmp(command, "ota check", 9) == 0) {
// Check is synchronous so its result lands in this reply, and runs with the
// MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only
+10 -7
View File
@@ -1,6 +1,7 @@
#ifdef ESP_PLATFORM
#include "ESP32Board.h"
#include "NetworkInterface.h"
#include <target.h>
#if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only
@@ -14,15 +15,16 @@
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
inhibit_sleep = true; // prevent sleep during OTA
// If the device is already on a WiFi network (e.g. an observer joined in STA
// mode), serve ElegantOTA on the station IP so it's reachable from the LAN
// without joining a separate AP. Otherwise raise the MeshCore-OTA SoftAP.
// If the device is already on its selected network, serve ElegantOTA on that
// address so it is reachable without joining a separate AP. Otherwise raise
// the MeshCore-OTA SoftAP.
// force_ap ("start ota ap") always raises the SoftAP, so the OTA UI stays
// reachable even when the joined network applies client isolation and the
// station IP can't be reached.
IPAddress ip;
if (!force_ap && WiFi.status() == WL_CONNECTED) {
ip = WiFi.localIP();
if (NetworkPolicy::startOtaUsesSelectedNetwork(
force_ap, activeNetworkInterface().isConnected())) {
ip = activeNetworkInterface().localIP();
} else {
WiFi.softAP("MeshCore-OTA", NULL);
ip = WiFi.softAPIP();
@@ -200,8 +202,9 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
strcpy(reply, "ERR: OTA not configured (build via build.sh)");
return false;
#else
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "ERR: WiFi not connected");
if (!activeNetworkInterface().isConnected()) {
snprintf(reply, 160, "ERR: %s not connected",
activeNetworkInterface().mediumName());
return false;
}
+296
View File
@@ -0,0 +1,296 @@
#include "NetworkInterface.h"
#if defined(ESP_PLATFORM)
#include "MQTTConnectionPolicy.h"
#include <atomic>
#include <climits>
#include <cstring>
#include <WiFi.h>
#include <esp_wifi.h>
#if defined(NETWORK_USE_ETHERNET)
#include "ethernet/ch390/CH390Config.h"
#endif
namespace {
class NetworkInterfaceBase : public NetworkInterface {
protected:
std::atomic<uint64_t> _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})};
std::atomic<unsigned long> _connected_at{0};
std::atomic<unsigned long> _last_disconnect_time{0};
std::atomic<uint8_t> _last_disconnect_reason{0};
bool _status_initialized = false;
bool _last_connected = false;
unsigned long _last_status_check = 0;
AlertFaultPolicy::OutageSnapshot outage() const {
return AlertFaultPolicy::unpackOutageSnapshot(
_outage_bits.load(std::memory_order_acquire));
}
void setOutage(AlertFaultPolicy::OutageSnapshot snapshot) {
_outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snapshot),
std::memory_order_release);
}
void noteConnected(unsigned long now_ms) {
if (_connected_at.load(std::memory_order_relaxed) == 0) {
_connected_at.store(now_ms, std::memory_order_relaxed);
}
setOutage(AlertFaultPolicy::applyWifiGotIp(outage()));
}
void noteDisconnected(unsigned long now_ms, uint8_t reason) {
_last_disconnect_reason.store(reason, std::memory_order_relaxed);
_last_disconnect_time.store(now_ms, std::memory_order_relaxed);
setOutage(AlertFaultPolicy::applyWifiDisconnectEvent(
(uint32_t)now_ms, reason, outage()));
}
public:
unsigned long connectedAtMillis() const override {
return _connected_at.load(std::memory_order_relaxed);
}
uint8_t lastDisconnectReason() const override {
return _last_disconnect_reason.load(std::memory_order_relaxed);
}
unsigned long lastDisconnectTime() const override {
return _last_disconnect_time.load(std::memory_order_relaxed);
}
AlertFaultPolicy::OutageSnapshot outageSnapshot() const override {
return outage();
}
};
class WiFiNetworkInterface final : public NetworkInterfaceBase {
bool _event_registered = false;
char _ssid[33] = {};
char _password[65] = {};
unsigned long _last_reconnect_attempt = 0;
uint8_t _reconnect_backoff_attempt = 0;
void applyPowerPrefs(uint8_t wifi_power_save) {
wifi_ps_type_t ps_mode = wifi_power_save == 2 ? WIFI_PS_MAX_MODEM : WIFI_PS_NONE;
esp_wifi_set_ps(ps_mode);
#ifdef MQTT_WIFI_TX_POWER
WiFi.setTxPower(MQTT_WIFI_TX_POWER);
#else
WiFi.setTxPower(WIFI_POWER_11dBm);
#endif
}
public:
const char* mediumName() const override { return "wifi"; }
const char* statusName() const override {
switch (WiFi.status()) {
case WL_CONNECTED: return "connected";
case WL_NO_SSID_AVAIL: return "no_ssid";
case WL_CONNECT_FAILED: return "connect_failed";
case WL_CONNECTION_LOST: return "connection_lost";
case WL_DISCONNECTED: return "disconnected";
case 255: return "not_started";
default: return "unknown";
}
}
int statusCode() const override { return (int)WiFi.status(); }
bool configValid(const char* wifi_ssid) const override {
return wifi_ssid && wifi_ssid[0] != '\0';
}
bool begin(const char* wifi_ssid, const char* wifi_password) override {
if (!configValid(wifi_ssid)) return false;
strncpy(_ssid, wifi_ssid, sizeof(_ssid) - 1);
_ssid[sizeof(_ssid) - 1] = '\0';
strncpy(_password, wifi_password ? wifi_password : "", sizeof(_password) - 1);
_password[sizeof(_password) - 1] = '\0';
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.setAutoConnect(true);
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
noteConnected(millis());
_reconnect_backoff_attempt = 0;
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
noteDisconnected(millis(), info.wifi_sta_disconnected.reason);
break;
default:
break;
}
});
_event_registered = true;
}
// Preserve the existing restart behavior: MQTT stop leaves the station up,
// and begin() must not force a disconnect that races the first DNS lookup.
if (!isConnected()) {
WiFi.begin(_ssid, _password);
} else {
noteConnected(millis());
}
return true;
}
NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override {
const bool connected = isConnected();
if (connected && connectedAtMillis() == 0) noteConnected(now_ms);
if (!_status_initialized) {
_last_connected = connected;
_status_initialized = true;
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, connected, outage(), false));
}
if ((uint32_t)(now_ms - _last_status_check) <= 10000) {
if (connected && outage().down) noteConnected(now_ms);
return NetworkTransition::None;
}
_last_status_check = now_ms;
if (connected) {
const bool transitioned = !_last_connected;
if (transitioned) {
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, true, outage(), true));
_connected_at.store(now_ms, std::memory_order_relaxed);
_reconnect_backoff_attempt = 0;
applyPowerPrefs(wifi_power_save);
}
_last_connected = true;
return transitioned ? NetworkTransition::Up : NetworkTransition::None;
}
AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus(
now_ms, false, outage(), true);
setOutage(snapshot);
const bool transitioned = _last_connected;
if (transitioned) {
_connected_at.store(0, std::memory_order_relaxed);
} else if (snapshot.down && MQTTConnectionPolicy::wifiReconnectDue(
now_ms, snapshot.started_ms, (uint32_t)_last_reconnect_attempt,
_reconnect_backoff_attempt)) {
_last_reconnect_attempt = now_ms;
_reconnect_backoff_attempt =
MQTTConnectionPolicy::nextWifiBackoffAttempt(_reconnect_backoff_attempt);
WiFi.disconnect();
WiFi.begin(_ssid, _password);
}
_last_connected = false;
return transitioned ? NetworkTransition::Down : NetworkTransition::None;
}
bool isConnected() const override { return WiFi.status() == WL_CONNECTED; }
IPAddress localIP() const override { return WiFi.localIP(); }
int rssi() const override { return isConnected() ? WiFi.RSSI() : INT_MIN; }
bool resolveHost(const char* hostname, IPAddress& address) const override {
return WiFi.hostByName(hostname, address);
}
};
#if defined(NETWORK_USE_ETHERNET)
class EthernetNetworkInterface final : public NetworkInterfaceBase {
bool _started = false;
bool _event_registered = false;
public:
const char* mediumName() const override { return "ethernet"; }
const char* statusName() const override {
return isConnected() ? "connected" : "disconnected";
}
int statusCode() const override { return isConnected() ? 1 : 0; }
bool configValid(const char*) const override { return true; }
bool begin(const char*, const char*) override {
if (_started) return true;
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) {
switch (event) {
case ARDUINO_EVENT_ETH_GOT_IP:
noteConnected(millis());
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
// Ethernet has no 802.11 reason code; zero means unavailable.
noteDisconnected(millis(), 0);
_connected_at.store(0, std::memory_order_relaxed);
break;
default:
break;
}
});
_event_registered = true;
}
_started = beginConfiguredCH390();
if (_started && isConnected()) noteConnected(millis());
return _started;
}
NetworkTransition maintain(uint32_t now_ms, uint8_t) override {
const bool connected = isConnected();
if (!_status_initialized) {
_last_connected = connected;
_status_initialized = true;
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, connected, outage(), false));
if (connected) noteConnected(now_ms);
return NetworkTransition::None;
}
if (connected == _last_connected) {
if (connected && outage().down) noteConnected(now_ms);
return NetworkTransition::None;
}
_last_connected = connected;
if (connected) {
_connected_at.store(now_ms, std::memory_order_relaxed);
setOutage(AlertFaultPolicy::applyWifiStatus(
now_ms, true, outage(), true));
return NetworkTransition::Up;
}
const bool outage_was_down = outage().down;
_connected_at.store(0, std::memory_order_relaxed);
AlertFaultPolicy::OutageSnapshot snapshot = AlertFaultPolicy::applyWifiStatus(
now_ms, false, outage(), true);
setOutage(snapshot);
if (!outage_was_down) {
_last_disconnect_time.store(now_ms, std::memory_order_relaxed);
}
return NetworkTransition::Down;
}
bool isConnected() const override { return _started && CH390.isConnected(); }
IPAddress localIP() const override { return CH390.localIP(); }
int rssi() const override { return INT_MIN; }
bool resolveHost(const char* hostname, IPAddress& address) const override {
// Arduino's hostByName is a thin wrapper over the process-wide lwIP resolver;
// DNS follows the selected esp_netif even though this entry point is named WiFi.
return WiFi.hostByName(hostname, address);
}
};
#endif
} // namespace
NetworkInterface& activeNetworkInterface() {
#if defined(NETWORK_USE_ETHERNET)
static EthernetNetworkInterface network;
#else
static WiFiNetworkInterface network;
#endif
return network;
}
#endif
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include "NetworkPolicy.h"
#if defined(ESP_PLATFORM)
#include <Arduino.h>
#include <IPAddress.h>
#include "AlertFaultPolicy.h"
/**
* Physical network selected for IP-based services.
*
* The interface owns link bring-up and medium-specific maintenance. MQTT, NTP,
* OTA, and other socket users only consume connectivity and addressing. Normal
* MQTT shutdown deliberately does not stop this interface because an OTA
* download runs after the broker clients have been released.
*/
class NetworkInterface {
public:
virtual ~NetworkInterface() = default;
virtual const char* mediumName() const = 0;
virtual const char* statusName() const = 0;
virtual int statusCode() const = 0;
virtual bool configValid(const char* wifi_ssid) const = 0;
virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0;
virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0;
virtual bool isConnected() const = 0;
virtual IPAddress localIP() const = 0;
virtual int rssi() const = 0; // INT_MIN when the selected medium has no RSSI.
virtual bool resolveHost(const char* hostname, IPAddress& address) const = 0;
virtual unsigned long connectedAtMillis() const = 0;
virtual uint8_t lastDisconnectReason() const = 0;
virtual unsigned long lastDisconnectTime() const = 0;
virtual AlertFaultPolicy::OutageSnapshot outageSnapshot() const = 0;
};
/** Build-selected singleton. Wi-Fi is the compatibility default. */
NetworkInterface& activeNetworkInterface();
#endif
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <stdint.h>
enum class NetworkTransition : uint8_t {
None,
Up,
Down,
};
namespace NetworkPolicy {
struct MQTTTransitionActions {
bool disconnect_slots;
bool retry_disconnected_slots_now;
};
static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) {
return {
transition == NetworkTransition::Down,
transition == NetworkTransition::Up,
};
}
// `start ota` uses the selected LAN only when reachable and not explicitly
// forced to SoftAP. Manifest OTA has no fallback and checks connectivity itself.
static constexpr bool startOtaUsesSelectedNetwork(bool force_ap,
bool network_connected) {
return !force_ap && network_connected;
}
} // namespace NetworkPolicy
+5 -4
View File
@@ -1,7 +1,9 @@
#ifdef WITH_SNMP
#include "SNMPAgent.h"
#include "NetworkInterface.h"
#include <esp_heap_caps.h>
#include <climits>
#define SNMP_PORT 161
@@ -67,7 +69,7 @@ void MeshSNMPAgent::begin(const char* community) {
void MeshSNMPAgent::loop() {
if (!_running) return;
// Update memory and network stats locally (we're on Core 0 with WiFi)
// Update memory and selected-network stats locally on Core 0.
_free_heap = (int)ESP.getFreeHeap();
_max_alloc = (int)ESP.getMaxAllocHeap();
_internal_free = (int)heap_caps_get_free_size(MALLOC_CAP_INTERNAL);
@@ -77,9 +79,8 @@ void MeshSNMPAgent::loop() {
_psram_free = 0;
#endif
if (WiFi.isConnected()) {
_wifi_rssi = (int)WiFi.RSSI();
}
const int signal = activeNetworkInterface().rssi();
_wifi_rssi = signal == INT_MIN ? 0 : signal;
_snmp.loop();
}
+1 -2
View File
@@ -2,7 +2,6 @@
#ifdef WITH_SNMP
#include <WiFi.h>
#include <WiFiUdp.h>
#include <SNMP_Agent.h>
@@ -15,7 +14,7 @@
// .2.x.0 = radio (packets, RSSI, SNR, noise floor, air time)
// .3.x.0 = mqtt (connected slots, queue depth, skipped publishes)
// .4.x.0 = memory (free heap, max alloc, internal free, PSRAM free)
// .5.x.0 = network (WiFi RSSI)
// .5.x.0 = network (RSSI, or 0 when the selected medium has no RSSI)
class MeshSNMPAgent {
public:
+62 -188
View File
@@ -100,16 +100,8 @@ void MQTTBridge::getEffectiveMqttOrigin(const NodePrefs* np, const MQTTPrefs* ob
applyEffectiveOrigin(np, obs, buf, buf_size);
}
// Helper function to check if WiFi credentials are valid
static bool isWiFiConfigValid(const MQTTPrefs* obs) {
// Check if WiFi SSID is configured (not empty)
if (!obs || strlen(obs->wifi_ssid) == 0) {
return false;
}
// WiFi password can be empty for open networks, so we don't check it
return true;
static bool isNetworkConfigValid(const MQTTPrefs* obs) {
return obs && activeNetworkInterface().configValid(obs->wifi_ssid);
}
#ifdef WITH_MQTT_BRIDGE
@@ -122,7 +114,7 @@ static bool customEndpointComplete(const char* host, uint16_t port) {
}
bool MQTTBridge::isConfigValid(const MQTTPrefs* obs) {
if (!obs || !isWiFiConfigValid(obs)) return false;
if (!obs || !isNetworkConfigValid(obs)) return false;
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
const char* preset_name = obs->mqtt_slot_preset[i];
if (preset_name[0] == '\0' || strcmp(preset_name, MQTT_PRESET_NONE) == 0) continue;
@@ -210,13 +202,6 @@ void* MQTTBridge::JsonScratchAllocator::reallocate(void* ptr, size_t new_size) {
return psram_realloc(ptr, new_size);
}
// Time (millis()) when WiFi was last seen connected; 0 when disconnected. Used for get wifi.status uptime.
static unsigned long s_wifi_connected_at = 0;
// Last WiFi disconnect reason (from ESP-IDF event). Used for get wifi.status diagnostics.
static uint8_t s_wifi_disconnect_reason = 0;
static unsigned long s_wifi_disconnect_time = 0;
#ifdef MQTT_MEMORY_DEBUG
// #region agent log
static void agentLogHeap(const char* location, const char* message, const char* hypothesisId,
@@ -236,7 +221,7 @@ static void agentLogHeap(const char* location, const char* message, const char*
static MQTTBridge* s_mqtt_bridge_instance = nullptr;
unsigned long MQTTBridge::getWifiConnectedAtMillis() {
return s_wifi_connected_at;
return activeNetworkInterface().connectedAtMillis();
}
#if defined(WITH_MQTT_NEIGHBORS)
@@ -427,8 +412,12 @@ int MQTTBridge::getMaxActiveSlots() {
#endif
}
uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }
uint8_t MQTTBridge::getLastWifiDisconnectReason() {
return activeNetworkInterface().lastDisconnectReason();
}
unsigned long MQTTBridge::getLastWifiDisconnectTime() {
return activeNetworkInterface().lastDisconnectTime();
}
unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0;
@@ -635,6 +624,7 @@ static inline uint32_t mqttStopTimeoutForSlots(int slots) {
MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity)
: BridgeBase(prefs, mgr, rtc),
_obs(obs),
_network(&activeNetworkInterface()),
_queue_count(0),
_last_status_publish(0), _last_status_retry(0), _status_interval(300000),
_ntp_client(_ntp_udp, effectiveNtpPrimary(obs), 0, 60000), _last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
@@ -661,8 +651,6 @@ MQTTBridge::MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mg
#ifdef WITH_SNMP
_snmp_agent(nullptr),
#endif
_last_wifi_check(0), _last_wifi_status(WL_DISCONNECTED), _wifi_status_initialized(false),
_wifi_outage_bits{0}, _last_wifi_reconnect_attempt(0), _wifi_reconnect_backoff_attempt(0),
_last_slot_reconnect_ms(0)
#ifdef ESP_PLATFORM
, _packet_queue_handle(nullptr), _mqtt_task_handle(nullptr),
@@ -857,9 +845,10 @@ void MQTTBridge::begin() {
_max_active_slots = getMaxActiveSlots();
MQTT_DEBUG_PRINTLN("Max active slots: %d", _max_active_slots);
// Check if WiFi credentials are configured first
if (!isWiFiConfigValid(_obs)) {
MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - WiFi credentials not configured");
// Ethernet needs no credentials; Wi-Fi preserves the existing SSID gate.
if (!isNetworkConfigValid(_obs)) {
MQTT_DEBUG_PRINTLN("MQTT Bridge initialization skipped - %s is not configured",
_network->mediumName());
return;
}
@@ -1043,11 +1032,8 @@ void MQTTBridge::begin() {
MQTT_DEBUG_PRINTLN("MQTT task created on Core %d", MQTT_TASK_CORE);
#else
// Non-ESP32: Initialize WiFi directly (no task)
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.setAutoConnect(true);
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
// Non-ESP32: initialize the selected network directly (no task).
_network->begin(_obs->wifi_ssid, _obs->wifi_password);
// NOTE: Slot setup deferred until after NTP sync in loop()
#endif
@@ -1237,75 +1223,31 @@ void MQTTBridge::mqttTask(void* parameter) {
vTaskDelete(nullptr);
}
void MQTTBridge::initializeWiFiInTask() {
MQTT_DEBUG_PRINTLN("Initializing WiFi in MQTT task...");
void MQTTBridge::initializeNetworkInTask() {
MQTT_DEBUG_PRINTLN("Initializing %s network in MQTT task...", _network->mediumName());
// Initialize WiFi
WiFi.mode(WIFI_STA);
// Enable automatic reconnection - ESP32 will handle reconnection automatically
WiFi.setAutoReconnect(true);
WiFi.setAutoConnect(true);
// Set up WiFi event handlers for better diagnostics and immediate disconnection
// detection. Register ONCE — the bridge is reused across restarts (e.g. stopped
// for `ota check`/`ota update`, or `set mqtt…` reconfigure) and WiFi.onEvent()
// never removes prior callbacks, so re-registering leaks handlers and duplicates
// every log line.
if (!_wifi_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) {
switch(event) {
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str());
setWifiOutage(AlertFaultPolicy::applyWifiGotIp(wifiOutage()));
_wifi_reconnect_backoff_attempt = 0;
// Set flag to trigger NTP sync from loop() instead of doing it here
if (!_ntp_synced && !_ntp_sync_pending) {
_ntp_sync_pending = true;
}
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: {
const uint8_t reason = info.wifi_sta_disconnected.reason;
const unsigned long t = millis();
s_wifi_disconnect_reason = reason;
s_wifi_disconnect_time = t;
setWifiOutage(AlertFaultPolicy::applyWifiDisconnectEvent(
(uint32_t)t, reason, wifiOutage()));
MQTT_DEBUG_PRINTLN("WiFi disconnected: reason %d", s_wifi_disconnect_reason);
break;
}
default:
break;
}
});
_wifi_event_registered = true;
}
// Only (re)start the WiFi association if it isn't already up. end() leaves the
// STA link connected, so on a restart (e.g. after `ota check`) calling
// WiFi.begin() again forces a needless disconnect/reconnect — which also races
// the MQTT task's first DNS lookup (getaddrinfo fails until WiFi/DNS recovers).
// When already connected, the deferred slot setup still fires in mqttTaskLoop()
// because _ntp_synced persists across end() (only _slots_setup_done is reset).
if (WiFi.status() != WL_CONNECTED) {
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
} else if (!_ntp_synced && !_ntp_sync_pending) {
_ntp_sync_pending = true; // already connected but never synced — kick NTP now
// begin() is idempotent and deliberately leaves an already-up link alone.
// MQTT end()/begin() cycles therefore keep the transport alive for OTA and do
// not race the first DNS lookup after a bridge restart.
if (!_network->begin(_obs->wifi_ssid, _obs->wifi_password)) {
MQTT_DEBUG_PRINTLN("%s network initialization failed", _network->mediumName());
} else if (_network->isConnected() && !_ntp_synced && !_ntp_sync_pending) {
_ntp_sync_pending = true;
}
// NOTE: Slot setup is deferred until after NTP sync in mqttTaskLoop().
// JWT-auth slots need valid timestamps for token creation, and connecting
// before NTP sync just wastes heap on TLS handshakes that will be rejected.
MQTT_DEBUG_PRINTLN("WiFi initialization started in task");
MQTT_DEBUG_PRINTLN("%s network initialization started in task", _network->mediumName());
}
// ---------------------------------------------------------------------------
// mqttTaskLoop() - main loop running on Core 0
// ---------------------------------------------------------------------------
void MQTTBridge::mqttTaskLoop() {
// Initialize WiFi first
initializeWiFiInTask();
// Initialize the selected physical network first.
initializeNetworkInTask();
// Wait a bit for WiFi to start connecting
vTaskDelay(pdMS_TO_TICKS(1000));
@@ -1362,9 +1304,9 @@ void MQTTBridge::mqttTaskLoop() {
}
#endif
bool wifi_just_connected = handleWiFiConnection(now);
if (wifi_just_connected) {
// WiFi recovered — reset last_reconnect_attempt for disconnected slots so they
bool network_just_connected = handleNetworkConnection(now);
if (network_just_connected) {
// The uplink recovered — reset last_reconnect_attempt for disconnected slots so they
// retry immediately rather than waiting up to 5 min for backoff timers to expire.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) {
@@ -1373,14 +1315,18 @@ void MQTTBridge::mqttTaskLoop() {
}
}
// Check for pending NTP sync (triggered from WiFi event handler)
if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
// A connected observation is enough to schedule NTP; physical event callbacks
// stay encapsulated in the selected network adapter.
if (!_ntp_synced && _network->isConnected() && !_ntp_sync_pending) {
_ntp_sync_pending = true;
}
if (_ntp_sync_pending && _network->isConnected()) {
_ntp_sync_pending = false;
syncTimeWithNTP();
}
// Retry NTP every 30s if initial sync failed (slots can't start without valid time)
if (!_ntp_synced && WiFi.status() == WL_CONNECTED) {
if (!_ntp_synced && _network->isConnected()) {
static unsigned long last_ntp_retry = 0;
if (now - last_ntp_retry >= 30000) {
last_ntp_retry = now;
@@ -1495,7 +1441,7 @@ void MQTTBridge::mqttTaskLoop() {
#ifdef WITH_SNMP
// SNMP agent loop — process incoming UDP requests
if (_snmp_agent) {
if (!_snmp_agent->isRunning() && WiFi.isConnected() && _obs->snmp_enabled) {
if (!_snmp_agent->isRunning() && _network->isConnected() && _obs->snmp_enabled) {
_snmp_agent->begin(_obs->snmp_community);
MQTT_DEBUG_PRINTLN("SNMP agent started on port 161 (community: %s)", _obs->snmp_community);
}
@@ -1517,7 +1463,7 @@ void MQTTBridge::mqttTaskLoop() {
// Periodic NTP refresh (every hour) — lightweight, non-blocking.
// Uses async SNTP instead of the heavy syncTimeWithNTP() which blocks Core 0
// for up to 20+ seconds with DNS lookups, UDP sockets, and retry loops.
if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) {
if (_network->isConnected() && now - _last_ntp_sync > 3600000) {
refreshNTP();
}
@@ -2062,7 +2008,7 @@ void MQTTBridge::maintainSlotConnections() {
if (!_identity) return;
// Check WiFi status first
if (WiFi.status() != WL_CONNECTED) return;
if (!_network->isConnected()) return;
unsigned long now_millis = millis();
unsigned long current_time = time(nullptr);
@@ -2792,98 +2738,26 @@ void MQTTBridge::checkConfigurationMismatch() {
}
}
bool MQTTBridge::handleWiFiConnection(unsigned long now) {
wl_status_t current_wifi_status = WiFi.status();
bool transitioned_to_connected = false;
if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) {
s_wifi_connected_at = now;
}
if (!_wifi_status_initialized) {
_last_wifi_status = current_wifi_status;
_wifi_status_initialized = true;
setWifiOutage(AlertFaultPolicy::applyWifiStatus(
(uint32_t)now, current_wifi_status == WL_CONNECTED, wifiOutage(), false));
}
if (now - _last_wifi_check <= 10000) {
// Events own the snapshot between 10 s polls. If STA is associated again
// and GOT_IP was missed, still close the outage so a flap contained
// between polls does not look like one continuous downtime.
if (current_wifi_status == WL_CONNECTED) {
AlertFaultPolicy::OutageSnapshot snap = wifiOutage();
if (snap.down) {
setWifiOutage(AlertFaultPolicy::applyWifiGotIp(snap));
bool MQTTBridge::handleNetworkConnection(unsigned long now) {
const NetworkTransition transition =
_network->maintain((uint32_t)now, _obs->wifi_power_save);
const NetworkPolicy::MQTTTransitionActions actions =
NetworkPolicy::mqttActions(transition);
if (actions.disconnect_slots) {
// Broker ownership stays in the bridge. The physical adapter reports the
// edge; the bridge explicitly closes every slot instead of waiting for
// eventual socket timeouts.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].client && _slots[i].connected) {
_slots[i].client->disconnect();
}
}
return false;
}
_last_wifi_check = now;
if (current_wifi_status == WL_CONNECTED) {
if (_last_wifi_status != WL_CONNECTED) {
transitioned_to_connected = true;
setWifiOutage(AlertFaultPolicy::applyWifiStatus(
(uint32_t)now, true, wifiOutage(), true));
s_wifi_connected_at = now;
_wifi_reconnect_backoff_attempt = 0;
#ifdef ESP_PLATFORM
wifi_ps_type_t ps_mode;
uint8_t ps_pref = _obs->wifi_power_save;
if (ps_pref == 1) {
ps_mode = WIFI_PS_NONE;
} else if (ps_pref == 2) {
ps_mode = WIFI_PS_MAX_MODEM;
} else {
ps_mode = WIFI_PS_NONE; // default: no power save; eliminates DTIM wake latency on mains-powered bridges
}
esp_wifi_set_ps(ps_mode);
#ifdef MQTT_WIFI_TX_POWER
WiFi.setTxPower(MQTT_WIFI_TX_POWER);
#else
WiFi.setTxPower(WIFI_POWER_11dBm);
#endif
#endif
}
if (s_wifi_connected_at == 0) {
s_wifi_connected_at = now;
}
_last_wifi_status = WL_CONNECTED;
} else {
const bool last_connected = (_last_wifi_status == WL_CONNECTED);
AlertFaultPolicy::OutageSnapshot snap = AlertFaultPolicy::applyWifiStatus(
(uint32_t)now, false, wifiOutage(), true);
setWifiOutage(snap);
if (last_connected) {
s_wifi_connected_at = 0;
// Disconnect all slot clients when WiFi drops
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].client && _slots[i].connected) {
_slots[i].client->disconnect();
}
}
} else if (snap.down) {
// Backoff ladder + wrap-safe timing live in MQTTConnectionPolicy (Phase 6),
// exercised by host tests. Behavior is unchanged: both the link-down
// duration and the since-last-attempt interval must clear the current rung
// (elapsedMs is the wrap-safe form of the old ULONG_MAX branch).
if (MQTTConnectionPolicy::wifiReconnectDue(
(uint32_t)now, snap.started_ms,
(uint32_t)_last_wifi_reconnect_attempt,
_wifi_reconnect_backoff_attempt)) {
_last_wifi_reconnect_attempt = now;
_wifi_reconnect_backoff_attempt =
MQTTConnectionPolicy::nextWifiBackoffAttempt(_wifi_reconnect_backoff_attempt);
WiFi.disconnect();
WiFi.begin(_obs->wifi_ssid, _obs->wifi_password);
}
}
_last_wifi_status = current_wifi_status;
}
return transitioned_to_connected;
return actions.retry_disconnected_slots_now;
}
bool MQTTBridge::isReady() const {
return _initialized && isWiFiConfigValid(_obs);
return _initialized && isNetworkConfigValid(_obs);
}
bool MQTTBridge::isIATAValid() const {
@@ -2943,10 +2817,10 @@ void MQTTBridge::loop() {
return;
#else
unsigned long now = millis();
if (handleWiFiConnection(now) && !_ntp_synced) {
if (handleNetworkConnection(now) && !_ntp_synced) {
syncTimeWithNTP();
}
if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
if (_ntp_sync_pending && _network->isConnected()) {
_ntp_sync_pending = false;
syncTimeWithNTP();
}
@@ -2986,7 +2860,7 @@ void MQTTBridge::loop() {
checkConfigurationMismatch();
// Periodic NTP refresh (every hour) — lightweight, non-blocking.
if (WiFi.status() == WL_CONNECTED && millis() - _last_ntp_sync > 3600000) {
if (_network->isConnected() && millis() - _last_ntp_sync > 3600000) {
refreshNTP();
}
@@ -3977,8 +3851,8 @@ void MQTTBridge::refreshNTP() {
}
bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
if (!WiFi.isConnected()) {
MQTT_DEBUG_PRINTLN("Cannot sync time - WiFi not connected");
if (!_network->isConnected()) {
MQTT_DEBUG_PRINTLN("Cannot sync time - %s not connected", _network->mediumName());
return false;
}
@@ -4026,7 +3900,7 @@ bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only) {
// Skipping is what keeps the credit honest; the name that answered is the name
// recorded.
IPAddress resolved_ip;
if (!WiFi.hostByName(server, resolved_ip)) {
if (!_network->resolveHost(server, resolved_ip)) {
MQTT_DEBUG_PRINTLN("NTP: %s does not resolve — skipping, not attempting a send", server);
continue;
}
+11 -28
View File
@@ -2,8 +2,8 @@
#include "MeshCore.h"
#include "helpers/bridges/BridgeBase.h"
#include "helpers/NetworkInterface.h"
#include <PsychicMqttClient.h>
#include <WiFi.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <Timezone.h>
@@ -219,11 +219,6 @@ private:
bool _ntp_synced;
bool _ntp_sync_pending; // Flag to trigger NTP sync from loop() instead of event handler
bool _slots_setup_done; // Deferred: slots set up after NTP sync
// WiFi.onEvent() handler registered once and never removed by end(); the bridge
// object is reused across restarts, so re-registering would leak handlers and
// duplicate every connect/disconnect log line. Inline-initialised so it survives
// construction and is NOT reset by end().
bool _wifi_event_registered = false;
int _max_active_slots; // Runtime limit: 5 with PSRAM, 2 without
// Pending slot reconfigure: set from CLI (Core 1), processed by MQTT task (Core 0)
@@ -406,25 +401,8 @@ private:
unsigned long _last_config_warning; // Throttle configuration mismatch warnings
static const unsigned long CONFIG_WARNING_INTERVAL = 300000; // Log every 5 minutes max
// WiFi connection state and exponential backoff
unsigned long _last_wifi_check;
wl_status_t _last_wifi_status;
bool _wifi_status_initialized;
// Packed OutageSnapshot; Core 0 (event + MQTT task) stores, Core 1 loads.
std::atomic<uint64_t> _wifi_outage_bits;
unsigned long _last_wifi_reconnect_attempt;
uint8_t _wifi_reconnect_backoff_attempt; // 0..5 → 15s, 30s, 60s, 120s, 300s; reset on connect
unsigned long _last_slot_reconnect_ms; // guards against concurrent TLS handshakes (15 s inter-slot gap)
AlertFaultPolicy::OutageSnapshot wifiOutage() const {
return AlertFaultPolicy::unpackOutageSnapshot(
_wifi_outage_bits.load(std::memory_order_acquire));
}
void setWifiOutage(AlertFaultPolicy::OutageSnapshot snap) {
_wifi_outage_bits.store(AlertFaultPolicy::packOutageSnapshot(snap),
std::memory_order_release);
}
// Optional pointers for collecting stats internally (set by mesh if available)
mesh::Dispatcher* _dispatcher; // For air times and errors
mesh::Radio* _radio; // For noise floor
@@ -483,13 +461,13 @@ private:
void processPacketQueue();
bool publishStatus(); // Returns true if status was successfully published
bool handleWiFiConnection(unsigned long now);
bool handleNetworkConnection(unsigned long now);
// FreeRTOS task function (runs on Core 0)
#ifdef ESP_PLATFORM
static void mqttTask(void* parameter);
void mqttTaskLoop(); // Main loop for MQTT task
void initializeWiFiInTask(); // WiFi initialization moved to task
void initializeNetworkInTask(); // Selected-link initialization moved to task
#endif
bool publishPacket(mesh::Packet* packet, bool is_tx, bool& has_eligible_target,
const uint8_t* raw_data = nullptr, int raw_len = 0,
@@ -547,6 +525,7 @@ private:
// Observer config (MQTT/WiFi/timezone/SNMP/alert), persisted to /mqtt.json.
// _prefs (held by BridgeBase) still provides upstream fields (freq/sf/node_name…).
MQTTPrefs* _obs = nullptr;
NetworkInterface* _network = nullptr;
public:
MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity);
@@ -655,13 +634,17 @@ public:
static unsigned long getWifiConnectedAtMillis();
/**
* Current WiFi outage snapshot for AlertReporter: down, started_ms, and the
* Current selected-network outage snapshot for AlertReporter: down,
* started_ms, and the
* initiating disconnect reason. Distinct from getLastWifiDisconnectTime() /
* getLastWifiDisconnectReason(), which follow the most recent ESP-IDF
* DISCONNECTED event and are overwritten by STA-backoff WiFi.disconnect()
* (reason 8 / ASSOC_LEAVE).
*/
AlertFaultPolicy::OutageSnapshot getWifiOutageSnapshot() const { return wifiOutage(); }
AlertFaultPolicy::OutageSnapshot getWifiOutageSnapshot() const {
return _network ? _network->outageSnapshot()
: AlertFaultPolicy::OutageSnapshot{false, 0, 0};
}
/**
* Per-slot outage accessors used by AlertReporter to detect prolonged
@@ -723,7 +706,7 @@ public:
uint16_t filter_mask;
};
static bool getSlotStatusSnapshot(int slot_index, SlotStatusSnapshot* out);
/** True when WiFi is set and at least one MQTT slot can run (preset + custom host if needed). */
/** True when the selected network is configured and at least one MQTT slot can run. */
static bool isConfigValid(const MQTTPrefs* obs);
static void formatSlotDiagReply(char* buf, size_t bufsize, int slot_index);
static uint8_t getLastWifiDisconnectReason();
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <ESP32_CH390.h>
/**
* Bring up the repository's CH390 lwIP interface from the board build flags.
* Shared by the companion transport wrapper and the observer network adapter so
* pin and static-IP behavior cannot drift between the two paths.
*/
static inline bool beginConfiguredCH390() {
ch390_config_t config = CH390_DEFAULT_CONFIG();
config.spi_miso_gpio = ETH_MISO_PIN;
config.spi_mosi_gpio = ETH_MOSI_PIN;
config.spi_sck_gpio = ETH_SCLK_PIN;
config.spi_cs_gpio = ETH_CS_PIN;
config.int_gpio = ETH_INT_PIN;
if (!CH390.begin(config)) return false;
#if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)
IPAddress ip(ETHERNET_STATIC_IP);
IPAddress gateway(ETHERNET_STATIC_GATEWAY);
IPAddress subnet(ETHERNET_STATIC_SUBNET);
#if defined(ETHERNET_STATIC_DNS)
IPAddress dns(ETHERNET_STATIC_DNS);
CH390.config(ip, gateway, subnet, dns);
#else
CH390.config(ip, gateway, subnet);
#endif
#endif
return true;
}
@@ -1,4 +1,5 @@
#include "CH390EthernetInterface.h"
#include "CH390Config.h"
void onWiFiEvent(WiFiEvent_t event) {
switch(event){
@@ -29,26 +30,12 @@ bool CH390EthernetInterface::begin() {
// listen to ethernet events
WiFi.onEvent(onWiFiEvent);
// Init CH390
ch390_config_t config = CH390_DEFAULT_CONFIG();
config.spi_miso_gpio = ETH_MISO_PIN;
config.spi_mosi_gpio = ETH_MOSI_PIN;
config.spi_sck_gpio = ETH_SCLK_PIN;
config.spi_cs_gpio = ETH_CS_PIN;
config.int_gpio = ETH_INT_PIN;
if (!CH390.begin(config)) {
// Init CH390 using the same board configuration as the observer uplink.
if (!beginConfiguredCH390()) {
ETHERNET_DEBUG_PRINTLN("Failed to initialize CH390 hardware.");
return false;
}
// Setup Static IP if build flags are present
#if defined(ETHERNET_STATIC_IP) && defined(ETHERNET_STATIC_GATEWAY) && defined(ETHERNET_STATIC_SUBNET)
IPAddress ip(ETHERNET_STATIC_IP);
IPAddress gw(ETHERNET_STATIC_GATEWAY);
IPAddress sn(ETHERNET_STATIC_SUBNET);
CH390.config(ip, gw, sn);
#endif
// Start Server
server.begin(ETHERNET_TCP_PORT);
ETHERNET_DEBUG_PRINTLN("listening on TCP port: %d", ETHERNET_TCP_PORT);
+1
View File
@@ -29,6 +29,7 @@ does not reflect the GoogleTest count — run the built binary directly
| `test_topic_template` | `src/helpers/MQTTTopicTemplate.h` | `{iata}/{device}/{token}/{type}` expansion, overflow/NUL-termination, and a buffer-size fuzz |
| `test_mqtt_topic_router` | `src/helpers/MQTTTopicRouter.h` | complete preset/custom topic-routing contract; MeshRank all types except raw; required identifiers; invalid inputs/slots; exact buffer boundaries |
| `test_mqtt_connection_policy` | `src/helpers/MQTTConnectionPolicy.h` | reconnect guard/backoff/stagger and breaker transitions; stable reset; JWT lifetime/renewal policy; exact timing boundaries and 32-bit `millis()` rollover; WiFi current-outage start sticky across STA reconnect attempts |
| `test_network_policy` | `src/helpers/NetworkPolicy.h` | transport-neutral MQTT link-transition actions and `start ota` selected-LAN versus forced/fallback SoftAP choice |
| `test_alert_fault_policy` | `src/helpers/AlertFaultPolicy.h` | WiFi/MQTT fault edge detector; `OutageSnapshot` (down / started_ms / initiating reason) fed to tick and `formatWifiAlert`; reason-8 reconnects change neither duration nor initiating reason; flap between status polls; down at `millis()==0`; packed 64-bit cross-task word; rate-limit floor and first-fire; 5 s poll cadence and `millis()` rollover |
| `test_display_viewport` | `src/helpers/ui/DisplayViewport.h`, `src/helpers/ui/DisplayFrameSignature.h` | logical-to-physical portrait mapping; fractional span coverage; fitted-width conversion; preferred/fallback text scaling; stable visible-frame change detection |
| `test_mqtt_packet_queue_policy` | `src/helpers/MQTTPacketQueuePolicy.h` | queue-full eviction; stale-disconnect flush; adaptive drain limits; bounded QoS0 retries; exact timing boundaries and 32-bit `millis()` rollover |
@@ -350,6 +350,17 @@ TEST(AlertFaultPolicy, FormatWifiAlertUsesSnapshotReason) {
EXPECT_STREQ("WiFi down 47m", text);
}
TEST(AlertFaultPolicy, FormatNetworkAlertUsesSelectedMediumLabel) {
Alert::Fault f = OkFault();
const Alert::OutageSnapshot snap = Down(1000, 0);
const Alert::TickResult r = Alert::tick(
f, 1000 + kWifiThresh, snap, kWifiThresh, kMinInterval);
char text[80];
ASSERT_TRUE(Alert::formatNetworkAlert(
text, sizeof(text), "Ethernet", r, snap));
EXPECT_STREQ("Ethernet down 30m", text);
}
TEST(AlertFaultPolicy, FormatMqttSlotMessages) {
char text[100];
Alert::formatMqttDown(text, sizeof(text), 1, "analyzer-us", 30U * 60000U);
@@ -0,0 +1,36 @@
#include <gtest/gtest.h>
#include "helpers/NetworkPolicy.h"
TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down);
EXPECT_TRUE(actions.disconnect_slots);
EXPECT_FALSE(actions.retry_disconnected_slots_now);
}
TEST(NetworkPolicy, MqttUpRequestsImmediateRetryWithoutDisconnectingSlots) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up);
EXPECT_FALSE(actions.disconnect_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
}
TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None);
EXPECT_FALSE(actions.disconnect_slots);
EXPECT_FALSE(actions.retry_disconnected_slots_now);
}
TEST(NetworkPolicy, StartOtaUsesReachableSelectedNetworkByDefault) {
EXPECT_TRUE(NetworkPolicy::startOtaUsesSelectedNetwork(false, true));
EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(false, false));
}
TEST(NetworkPolicy, StartOtaForceApOverridesAReachableSelectedNetwork) {
EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, true));
EXPECT_FALSE(NetworkPolicy::startOtaUsesSelectedNetwork(true, false));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+39 -7
View File
@@ -37,22 +37,31 @@ build_src_filter = ${esp32_base.build_src_filter}
lib_deps = ${esp32_base.lib_deps}
stevemarple/MicroNMEA @ ^2.0.6
[ThinkNode_M7_ethernet]
[ThinkNode_M7_ch390]
build_flags =
-D ETHERNET_ENABLED
-D ETHERNET_USE_CH390
-D ETHERNET_CLASS=CH390EthernetInterface
-D ETH_MISO_PIN=14
-D ETH_MOSI_PIN=48
-D ETH_SCLK_PIN=47
-D ETH_CS_PIN=21
-D ETH_INT_PIN=45
-D ETHERNET_DEBUG_LOGGING=1
lib_deps =
https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d
; Existing companion/CLI transport overlay. MQTT Ethernet observers consume the
; CH390 fragment above without ETHERNET_ENABLED, whose meaning in the simple
; repeater/room entry points is the legacy nRF52 Ethernet CLI.
[ThinkNode_M7_ethernet]
build_flags =
${ThinkNode_M7_ch390.build_flags}
-D ETHERNET_ENABLED
-D ETHERNET_CLASS=CH390EthernetInterface
build_src_filter =
+<helpers/ethernet/*.cpp>
+<helpers/ethernet/ch390/>
lib_deps =
https://github.com/liamcottle/ESP32-CH390.git#47b401f1de546118b03c18b5689dedec45871f2d
${ThinkNode_M7_ch390.lib_deps}
[env:ThinkNode_M7_repeater]
extends = ThinkNode_M7
@@ -180,9 +189,10 @@ extends = ThinkNode_M7
build_src_filter = ${ThinkNode_M7.build_src_filter}
+<../examples/kiss_modem/>
; MQTT observer envs are WiFi-only: the bridge's link management is bound to the
; WiFi station API, so the onboard CH390 cannot carry MQTT yet. The board has
; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h).
; Wi-Fi remains the default observer transport. Ethernet twins below select the
; onboard CH390 through NETWORK_USE_ETHERNET without enabling the legacy CLI
; transport. The board has PSRAM, so MAX_NEIGHBOURS enables
; WITH_MQTT_NEIGHBORS (see MQTTBridge.h).
[env:ThinkNode_M7_repeater_observer_mqtt]
extends = ThinkNode_M7
extra_scripts =
@@ -227,6 +237,17 @@ lib_deps =
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:ThinkNode_M7_repeater_observer_mqtt_ethernet]
extends = env:ThinkNode_M7_repeater_observer_mqtt
build_flags =
${env:ThinkNode_M7_repeater_observer_mqtt.build_flags}
${ThinkNode_M7_ch390.build_flags}
-D NETWORK_USE_ETHERNET=1
build_src_filter = ${env:ThinkNode_M7_repeater_observer_mqtt.build_src_filter}
lib_deps =
${env:ThinkNode_M7_repeater_observer_mqtt.lib_deps}
${ThinkNode_M7_ch390.lib_deps}
[env:ThinkNode_M7_room_server_observer_mqtt]
extends = ThinkNode_M7
extra_scripts =
@@ -271,3 +292,14 @@ lib_deps =
JChristensen/Timezone
paulstoffregen/Time@1.6.1
0neblock/SNMP_Agent
[env:ThinkNode_M7_room_server_observer_mqtt_ethernet]
extends = env:ThinkNode_M7_room_server_observer_mqtt
build_flags =
${env:ThinkNode_M7_room_server_observer_mqtt.build_flags}
${ThinkNode_M7_ch390.build_flags}
-D NETWORK_USE_ETHERNET=1
build_src_filter = ${env:ThinkNode_M7_room_server_observer_mqtt.build_src_filter}
lib_deps =
${env:ThinkNode_M7_room_server_observer_mqtt.lib_deps}
${ThinkNode_M7_ch390.lib_deps}