From 3975bc0b01c5727ea93c23e84d958c52099664c3 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 1 Sep 2026 16:31:28 -0700 Subject: [PATCH] feat: abstract MQTT network transport --- examples/simple_repeater/MyMesh.cpp | 26 +- examples/simple_room_server/MyMesh.cpp | 26 +- src/helpers/AlertFaultPolicy.h | 53 +++- src/helpers/AlertReporter.cpp | 13 +- src/helpers/CommonCLI_Observer.cpp | 85 +++-- src/helpers/ESP32Board.cpp | 17 +- src/helpers/NetworkInterface.cpp | 296 ++++++++++++++++++ src/helpers/NetworkInterface.h | 44 +++ src/helpers/NetworkPolicy.h | 32 ++ src/helpers/SNMPAgent.cpp | 9 +- src/helpers/SNMPAgent.h | 3 +- src/helpers/bridges/MQTTBridge.cpp | 250 ++++----------- src/helpers/bridges/MQTTBridge.h | 39 +-- src/helpers/ethernet/ch390/CH390Config.h | 31 ++ .../ethernet/ch390/CH390EthernetInterface.cpp | 19 +- test/README.md | 1 + .../test_alert_fault_policy.cpp | 11 + .../test_network_policy.cpp | 36 +++ variants/thinknode_m7/platformio.ini | 46 ++- 19 files changed, 731 insertions(+), 306 deletions(-) create mode 100644 src/helpers/NetworkInterface.cpp create mode 100644 src/helpers/NetworkInterface.h create mode 100644 src/helpers/NetworkPolicy.h create mode 100644 src/helpers/ethernet/ch390/CH390Config.h create mode 100644 test/test_network_policy/test_network_policy.cpp diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 75d9fb4e..48322c6a 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1,7 +1,12 @@ #include "MyMesh.h" #include +#include #include // for qsort() #include +#include +#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET) +#include +#endif #if defined(WITH_MQTT_NEIGHBORS) #include // 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," diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index e6bed7d6..90d5ea4b 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,11 @@ #include "MyMesh.h" #include +#include #include +#include +#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET) +#include +#endif #if defined(WITH_MQTT_NEIGHBORS) #include // 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," diff --git a/src/helpers/AlertFaultPolicy.h b/src/helpers/AlertFaultPolicy.h index d15aaf1f..1dbbac49 100644 --- a/src/helpers/AlertFaultPolicy.h +++ b/src/helpers/AlertFaultPolicy.h @@ -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, diff --git a/src/helpers/AlertReporter.cpp b/src/helpers/AlertReporter.cpp index b55ac90c..171d81c8 100644 --- a/src/helpers/AlertReporter.cpp +++ b/src/helpers/AlertReporter.cpp @@ -6,6 +6,7 @@ #include #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); } diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 9b2b2d30..28ac768b 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -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 +#include #include #ifdef ESP_PLATFORM #include @@ -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 diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 120203ab..5452c50c 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -1,6 +1,7 @@ #ifdef ESP_PLATFORM #include "ESP32Board.h" +#include "NetworkInterface.h" #include #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; } diff --git a/src/helpers/NetworkInterface.cpp b/src/helpers/NetworkInterface.cpp new file mode 100644 index 00000000..1d4d2d39 --- /dev/null +++ b/src/helpers/NetworkInterface.cpp @@ -0,0 +1,296 @@ +#include "NetworkInterface.h" + +#if defined(ESP_PLATFORM) + +#include "MQTTConnectionPolicy.h" + +#include +#include +#include + +#include +#include + +#if defined(NETWORK_USE_ETHERNET) +#include "ethernet/ch390/CH390Config.h" +#endif + +namespace { + +class NetworkInterfaceBase : public NetworkInterface { + protected: + std::atomic _outage_bits{AlertFaultPolicy::packOutageSnapshot({false, 0, 0})}; + std::atomic _connected_at{0}; + std::atomic _last_disconnect_time{0}; + std::atomic _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 diff --git a/src/helpers/NetworkInterface.h b/src/helpers/NetworkInterface.h new file mode 100644 index 00000000..a4e631a1 --- /dev/null +++ b/src/helpers/NetworkInterface.h @@ -0,0 +1,44 @@ +#pragma once + +#include "NetworkPolicy.h" + +#if defined(ESP_PLATFORM) + +#include +#include +#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 diff --git a/src/helpers/NetworkPolicy.h b/src/helpers/NetworkPolicy.h new file mode 100644 index 00000000..731ed349 --- /dev/null +++ b/src/helpers/NetworkPolicy.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +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 diff --git a/src/helpers/SNMPAgent.cpp b/src/helpers/SNMPAgent.cpp index a70716f5..ddfb2fe7 100644 --- a/src/helpers/SNMPAgent.cpp +++ b/src/helpers/SNMPAgent.cpp @@ -1,7 +1,9 @@ #ifdef WITH_SNMP #include "SNMPAgent.h" +#include "NetworkInterface.h" #include +#include #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(); } diff --git a/src/helpers/SNMPAgent.h b/src/helpers/SNMPAgent.h index c1bfe0f5..2f792634 100644 --- a/src/helpers/SNMPAgent.h +++ b/src/helpers/SNMPAgent.h @@ -2,7 +2,6 @@ #ifdef WITH_SNMP -#include #include #include @@ -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: diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index 39c8f05b..8b48401f 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -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; } diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index e2d27253..5038db18 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -2,8 +2,8 @@ #include "MeshCore.h" #include "helpers/bridges/BridgeBase.h" +#include "helpers/NetworkInterface.h" #include -#include #include #include #include @@ -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 _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(); diff --git a/src/helpers/ethernet/ch390/CH390Config.h b/src/helpers/ethernet/ch390/CH390Config.h new file mode 100644 index 00000000..1b3a5bd9 --- /dev/null +++ b/src/helpers/ethernet/ch390/CH390Config.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +/** + * 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; +} diff --git a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp index 7f696245..ed262bfb 100644 --- a/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp +++ b/src/helpers/ethernet/ch390/CH390EthernetInterface.cpp @@ -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); diff --git a/test/README.md b/test/README.md index 647b6daf..0cba8fff 100644 --- a/test/README.md +++ b/test/README.md @@ -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 | diff --git a/test/test_alert_fault_policy/test_alert_fault_policy.cpp b/test/test_alert_fault_policy/test_alert_fault_policy.cpp index 42ba27d7..77bdecbd 100644 --- a/test/test_alert_fault_policy/test_alert_fault_policy.cpp +++ b/test/test_alert_fault_policy/test_alert_fault_policy.cpp @@ -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); diff --git a/test/test_network_policy/test_network_policy.cpp b/test/test_network_policy/test_network_policy.cpp new file mode 100644 index 00000000..201c7451 --- /dev/null +++ b/test/test_network_policy/test_network_policy.cpp @@ -0,0 +1,36 @@ +#include + +#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(); +} diff --git a/variants/thinknode_m7/platformio.ini b/variants/thinknode_m7/platformio.ini index 7da14112..2ccf6f5c 100644 --- a/variants/thinknode_m7/platformio.ini +++ b/variants/thinknode_m7/platformio.ini @@ -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 = + + 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}