Merge pull request #53 from agessaman/feat/abstract-networking

Abstract the observer network link; Ethernet-preferred ThinkNode M7 observers
This commit is contained in:
Adam Gessaman authored and GitHub committed 2026-09-19 16:31:59 -07:00
commit d9f10155ef
44 files changed
+2769 -426

No files matched your search

+30 -8
View File
@@ -80,7 +80,8 @@ reboot
**9. Verify configuration**
```bash
get wifi.ssid
get wifi.status
get link.status
get link.diag
get bridge.enabled
get mqtt.rx
get mqtt.tx
@@ -224,11 +225,14 @@ pio run -e ThinkNode_M7_repeater_observer_mqtt
pio run -e ThinkNode_M7_room_server_observer_mqtt
```
**ThinkNode M7 — WiFi only:** the M7 has an onboard CH390 Ethernet controller, and
`ThinkNode_M7_companion_radio_ethernet` uses it, but the MQTT bridge's link
management is bound to the WiFi station API, so the observer envs uplink over WiFi.
See `UPSTREAM_BUGS.md` for the Ethernet gap. The board has PSRAM, so these builds
get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically.
**ThinkNode M7 — Ethernet preferred, WiFi fallback:** the observer envs use the onboard
CH390 Ethernet when it has a DHCP lease at boot, otherwise the stored WiFi network, and
switch between the two at runtime (`get link.status`, `get link.diag`). Nodes without a
cable behave like WiFi observers after a brief (about 1 s) Ethernet probe at boot. The DHCP hostname is
`meshcore-<node name>` on either medium. A first boot on Ethernet with no WiFi configured
opens WebConfig on the LAN with a one-time login code printed on serial (and shown on
the display), and requires replacing the admin password. The board has PSRAM, so these
builds get neighbors publication (`WITH_MQTT_NEIGHBORS`) automatically.
**TLora naming:** The env prefix `LilyGo_TLora_V2_1_1_6` is LilyGo’s **T-LoRa V2.1–1.6** board (SX1276); PlatformIO selects **`ttgo-lora32-v1`** (TTGO LoRa32 V1.0). **MQTT observer** envs extend a slim base **without** `sensor_base` so the image fits `min_spiffs`; **all other** `LilyGo_TLora_V2_1_1_6_*` targets still use optional I2C environmental sensors as before. The **`lilygo_tlora_c6`** variant is separate hardware (ESP32-C6).
@@ -546,12 +550,14 @@ These settings apply across all MQTT slots:
- `set mqtt.owner <64-hex-char-public-key>` - Set owner public key
- `set mqtt.email <email>` - Set owner email address
### WiFi Commands
### Network and WiFi Commands
#### Get Commands
- `get wifi.ssid` - Get WiFi SSID
- `get wifi.pwd` - Get WiFi password
- `get wifi.status` - Get WiFi connection status, IP, RSSI, and uptime
- `get link.status` - Get the selected network medium, connection status, IP, signal when available, and uptime
- `get link.diag` - Explain automatic Ethernet selection using controller initialization, link event, IP, WiFi fallback, route-lock, and reason state
- `get wifi.status` - WiFi-only compatibility alias; reports n/a when another medium is selected
- `get wifi.powersave` - Get WiFi power save mode (none/min/max)
#### Set Commands
@@ -863,6 +869,22 @@ the radio actually performs in that case.
### Connection Handling
- Automatic reconnection with exponential backoff per slot; a slot that stays down through
the full backoff ladder is retried on a slow periodic probe instead of hammering the broker
- Ethernet-preferred builds wait the full configured boot probe window for an Ethernet IP;
delayed or unavailable PHY carrier reporting does not shorten the DHCP deadline. The boot
selection log includes the measured probe duration.
- CH390 startup initializes Arduino's shared network event runtime without associating WiFi;
this keeps the framework's DNS and TLS hostname paths safe when Ethernet wins directly.
- Ethernet/WiFi transitions are logged. A lost or changed route closes every live MQTT
transport with a bounded disconnect that keeps the client task; a slot that was still
connecting is stopped instead, so its attempt cannot complete against the old route and
report a connection that no longer exists. When the same link returns, each slot gets one
immediate attempt at its current backoff rung (a tripped circuit breaker gets one immediate
probe); a switch to the other medium also clears backoff and breakers
- Each medium's DHCP DNS servers are remembered when it gets its lease and restored when it is
selected again. lwIP keeps one global resolver list, so without this a node that fell back to
WiFi and then failed back to Ethernet would keep asking the WiFi network's DNS server
- WiFi credentials changed at runtime (`set wifi.ssid` / `set wifi.pwd`) are used on the next
reconnect attempt without a reboot
- Packets are queued while a slot is disconnected and flushed when it recovers
### Raw Radio Data Capture
+2 -1
View File
@@ -85,8 +85,9 @@ This document provides an overview of CLI commands that can be sent to MeshCore
### Start an Over-The-Air (OTA) firmware update
**Usage:**
- `start ota` — serves the ElegantOTA web upload page on the station IP if joined to a Wi-Fi network, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot.
- `start ota` — serves the ElegantOTA web upload page on the selected Ethernet or Wi-Fi IP when connected, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot. The manual-OTA session stops after 15 minutes unless an upload is in progress.
- `start ota ap` — always raises the `MeshCore-OTA` Wi-Fi hotspot, even when joined to a network. Use this when the network applies client isolation and the station IP isn't reachable.
- `stop ota` — stops an idle manual-OTA web server, releases port 80, and re-enables automatic network switching. It refuses while a firmware upload is in progress.
---
+49 -14
View File
@@ -1,7 +1,13 @@
#include "MyMesh.h"
#include <algorithm>
#include <climits>
#include <stdlib.h> // for qsort()
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkLink.h>
#include <helpers/NetworkHostname.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#if defined(WITH_MQTT_NEIGHBORS)
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
#endif
@@ -1142,6 +1148,26 @@ void MyMesh::begin(FILESYSTEM *fs) {
}
#endif
#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM)
NetworkLink& boot_network = activeNetworkLink();
if (boot_network.isAutomatic()) {
char network_hostname[NetworkHostname::kBufferSize];
NetworkHostname::build(network_hostname, sizeof(network_hostname),
_prefs.node_name, self_id.pub_key, PUB_KEY_SIZE);
boot_network.setHostname(network_hostname);
Serial.printf("Network: hostname %s\n", network_hostname);
MQTTPrefs* obs = _cli.getObserverPrefs();
Serial.printf("Network: probing Ethernet for up to %lums\n",
(unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS);
const uint32_t ethernet_probe_started_at = millis();
boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password,
NETWORK_ETHERNET_BOOT_WAIT_MS);
Serial.printf("Network: selected %s (%s) after %lums\n",
boot_network.mediumName(), boot_network.statusName(),
(unsigned long)(millis() - ethernet_probe_started_at));
}
#endif
acl.load(_fs, self_id);
// TODO: key_store.begin();
region_map.load(_fs);
@@ -1216,9 +1242,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
#endif
#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 (_cli.getObserverPrefs()->wifi_ssid[0] == 0) {
// Existing Wi-Fi installs are complete by virtue of their stored SSID. An
// Ethernet-only install uses the explicit completion marker instead.
if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) {
char wc_reply[160];
startWebConfig(false, wc_reply);
Serial.println(wc_reply);
@@ -1479,15 +1505,18 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
}
if (force_ap) {
// The setup AP owns WiFi outright; refuse while the bridge holds the STA.
if (bridge && bridge->isRunning()) {
if (bridge && bridge->isRunning() &&
activeNetworkLink().medium() != NetworkMedium::Ethernet) {
strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first");
return true;
}
_webconfig->startSetupMode(reply);
} else if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) {
_webconfig->startSetupMode(reply); // unconfigured: same portal as first boot
} else if (activeNetworkLink().isConnected()) {
_webconfig->startLanMode(!mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply);
} else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) {
_webconfig->startSetupMode(reply);
} else {
_webconfig->startLanMode(reply); // reports "WiFi not connected" if down
strcpy(reply, "Err: selected network not connected");
}
return true;
}
@@ -1522,12 +1551,17 @@ void MyMesh::onConfigBatchEnd() {
// same sources as the REQ_TYPE_GET_STATUS reply and `get mqtt.stats`.
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) {
char wifi_rssi[12] = "null";
NetworkLink& network = activeNetworkLink();
const char* network_medium = network.mediumName();
if (network.isConnected()) {
strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1);
const int signal = network.rssi();
if (signal != INT_MIN) snprintf(wifi_rssi, sizeof(wifi_rssi), "%d", signal);
}
else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
network_medium = "wifi-ap";
}
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
@@ -1536,7 +1570,8 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
"\"tx_queue\":%d,\"wifi_rssi\":%s,\"network_medium\":\"%s\","
"\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
(unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(),
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
(unsigned long)ESP.getMaxAllocHeap(),
@@ -1547,7 +1582,7 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
(unsigned long)radio_driver.getPacketsRecvErrors(),
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, network_medium, ip,
bridge ? bridge->getQueueSize() : 0);
if (pos < 0 || pos >= (int)buf_size - 3) return; // truncated; snprintf terminated it
bool first = true;
+9
View File
@@ -517,6 +517,15 @@ public:
_wc_slot_restart_mask = 0;
}
void onConfigBatchEnd() override;
bool onInitialSetupComplete() override {
// The password command does not report its save; make it durable first.
if (!_cli.savePrefs(_fs)) return false;
MQTTPrefs* obs = _cli.getObserverPrefs();
obs->network_setup_complete = 1;
if (_cli.saveObserverPrefs(_fs)) return true;
obs->network_setup_complete = 0;
return false;
}
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
+3 -2
View File
@@ -174,15 +174,16 @@ void UITask::renderCurrScreen() {
}
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
const bool lan_setup = WebConfigServer::isLanSetup();
// setup portal active: show join instructions instead of the home screen
_display->setTextSize(1);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
_display->print("Observer WiFi Setup");
_display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup");
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->print(lan_setup ? "Login code:" : "Join WiFi:");
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 24);
_display->print(wc_ssid);
+2
View File
@@ -138,6 +138,8 @@ void setup() {
}
void loop() {
board.maintainOTAUpdate(millis());
// Handle Serial CLI
int len = strlen(command);
// `command` must stay NUL-terminated within its bounds. If it ever isn't,
+49 -14
View File
@@ -1,6 +1,12 @@
#include "MyMesh.h"
#include <algorithm>
#include <climits>
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkLink.h>
#include <helpers/NetworkHostname.h>
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#if defined(WITH_MQTT_NEIGHBORS)
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
#endif
@@ -943,6 +949,26 @@ void MyMesh::begin(FILESYSTEM *fs) {
// load persisted prefs
_cli.loadPrefs(_fs);
#if defined(WITH_MQTT_BRIDGE) && defined(ESP_PLATFORM)
NetworkLink& boot_network = activeNetworkLink();
if (boot_network.isAutomatic()) {
char network_hostname[NetworkHostname::kBufferSize];
NetworkHostname::build(network_hostname, sizeof(network_hostname),
_prefs.node_name, self_id.pub_key, PUB_KEY_SIZE);
boot_network.setHostname(network_hostname);
Serial.printf("Network: hostname %s\n", network_hostname);
MQTTPrefs* obs = _cli.getObserverPrefs();
Serial.printf("Network: probing Ethernet for up to %lums\n",
(unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS);
const uint32_t ethernet_probe_started_at = millis();
boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password,
NETWORK_ETHERNET_BOOT_WAIT_MS);
Serial.printf("Network: selected %s (%s) after %lums\n",
boot_network.mediumName(), boot_network.statusName(),
(unsigned long)(millis() - ethernet_probe_started_at));
}
#endif
acl.load(_fs, self_id);
region_map.load(_fs);
@@ -1018,9 +1044,9 @@ void MyMesh::begin(FILESYSTEM *fs) {
#endif
#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 (_cli.getObserverPrefs()->wifi_ssid[0] == 0) {
// Existing Wi-Fi installs are complete by virtue of their stored SSID. An
// Ethernet-only install uses the explicit completion marker instead.
if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) {
char wc_reply[160];
startWebConfig(false, wc_reply);
Serial.println(wc_reply);
@@ -1281,15 +1307,18 @@ bool MyMesh::startWebConfig(bool force_ap, char* reply) {
}
if (force_ap) {
// The setup AP owns WiFi outright; refuse while the bridge holds the STA.
if (bridge && bridge->isRunning()) {
if (bridge && bridge->isRunning() &&
activeNetworkLink().medium() != NetworkMedium::Ethernet) {
strcpy(reply, "Err: MQTT bridge is running - 'set bridge off' first");
return true;
}
_webconfig->startSetupMode(reply);
} else if (_cli.getObserverPrefs()->wifi_ssid[0] == 0) {
_webconfig->startSetupMode(reply); // unconfigured: same portal as first boot
} else if (activeNetworkLink().isConnected()) {
_webconfig->startLanMode(!mqttNetworkSetupComplete(_cli.getObserverPrefs()), reply);
} else if (!mqttNetworkSetupComplete(_cli.getObserverPrefs())) {
_webconfig->startSetupMode(reply);
} else {
_webconfig->startLanMode(reply); // reports "WiFi not connected" if down
strcpy(reply, "Err: selected network not connected");
}
return true;
}
@@ -1324,12 +1353,17 @@ void MyMesh::onConfigBatchEnd() {
// same sources as the stats CLI replies and `get mqtt.stats`.
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) {
char wifi_rssi[12] = "null";
NetworkLink& network = activeNetworkLink();
const char* network_medium = network.mediumName();
if (network.isConnected()) {
strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1);
const int signal = network.rssi();
if (signal != INT_MIN) snprintf(wifi_rssi, sizeof(wifi_rssi), "%d", signal);
}
else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
network_medium = "wifi-ap";
}
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
@@ -1338,7 +1372,8 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
"\"airtime_s\":%lu,\"rx_airtime_s\":%lu,"
"\"recv\":%lu,\"sent\":%lu,\"rx_err\":%lu,"
"\"sent_flood\":%lu,\"sent_direct\":%lu,\"recv_flood\":%lu,\"recv_direct\":%lu,"
"\"tx_queue\":%d,\"wifi_rssi\":%d,\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
"\"tx_queue\":%d,\"wifi_rssi\":%s,\"network_medium\":\"%s\","
"\"ip\":\"%s\",\"mqtt_queue\":%d,\"slots\":[",
(unsigned long)(uptime_millis / 1000), (unsigned)board.getBattMilliVolts(),
(unsigned long)ESP.getFreeHeap(), (unsigned long)ESP.getMinFreeHeap(),
(unsigned long)ESP.getMaxAllocHeap(),
@@ -1349,7 +1384,7 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
(unsigned long)radio_driver.getPacketsRecvErrors(),
(unsigned long)getNumSentFlood(), (unsigned long)getNumSentDirect(),
(unsigned long)getNumRecvFlood(), (unsigned long)getNumRecvDirect(),
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, ip,
(int)_mgr->getOutboundCount(0xFFFFFFFF), wifi_rssi, network_medium, ip,
bridge ? bridge->getQueueSize() : 0);
if (pos < 0 || pos >= (int)buf_size - 3) return; // truncated; snprintf terminated it
bool first = true;
+9
View File
@@ -507,6 +507,15 @@ public:
_wc_slot_restart_mask = 0;
}
void onConfigBatchEnd() override;
bool onInitialSetupComplete() override {
// The password command does not report its save; make it durable first.
if (!_cli.savePrefs(_fs)) return false;
MQTTPrefs* obs = _cli.getObserverPrefs();
obs->network_setup_complete = 1;
if (_cli.saveObserverPrefs(_fs)) return true;
obs->network_setup_complete = 0;
return false;
}
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
};
+3 -2
View File
@@ -155,15 +155,16 @@ void UITask::renderCurrScreen() {
}
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
const bool lan_setup = WebConfigServer::isLanSetup();
// setup portal active: show join instructions instead of the home screen
_display->setTextSize(1);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
_display->print("Observer WiFi Setup");
_display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup");
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->print(lan_setup ? "Login code:" : "Join WiFi:");
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 24);
_display->print(wc_ssid);
+2
View File
@@ -114,6 +114,8 @@ void setup() {
}
void loop() {
board.maintainOTAUpdate(millis());
int len = strlen(command);
// `command` must stay NUL-terminated within its bounds. If it ever isn't,
// strlen() above can return >= sizeof(command) and the loop below would then
+6
View File
@@ -497,6 +497,12 @@ GETTERS = {
"wifi.status": lambda c: (
"SSID: %s\nIP: 192.168.1.42\nRSSI: -58 dBm\nUptime: %dm"
% (c["wifi"]["ssid"] or "(not set)", int(time.time() - ST.start) // 60)),
"link.status": lambda c: (
"wifi: connected, IP: 192.168.1.42, RSSI: -58 dBm, Uptime: %dm"
% (int(time.time() - ST.start) // 60)),
"link.diag": lambda c: (
"why:ethernet-not-enabled selected:wifi\n"
"wifi:state:connected ip:192.168.1.42"),
"mqtt.status": lambda c: cli_mqtt_status(c),
"mqtt.presets": lambda c: "\n".join(
"%2d. %s%s" % (i + 1, n, "" if nd == "none" else " (needs %s)" % nd)
+3
View File
@@ -69,6 +69,9 @@ public:
virtual bool startOTAUpdate(const char* id, char reply[], bool force_ap) {
return startOTAUpdate(id, reply);
}
virtual bool stopOTAUpdate(char* reply) { (void)reply; return false; }
virtual bool isOTAUpdateInProgress() const { return false; }
virtual void maintainOTAUpdate(uint32_t now_ms) { (void)now_ms; }
// Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it.
// current_ver is the running firmware version string (used to skip if already up to date); when
// dry_run is true the build is only reported, not flashed. Observer (ESP32+WiFi) builds only.
+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 -4
View File
@@ -6,6 +6,7 @@
#include <stdio.h>
#ifdef WITH_MQTT_BRIDGE
#include "AlertFaultPolicy.h"
#include "NetworkLink.h"
#endif
// Header layout for PAYLOAD_TYPE_GRP_TXT before encryption:
@@ -207,23 +208,29 @@ void AlertReporter::onLoop(unsigned long now_ms) {
const uint32_t min_interval_ms =
AlertFaultPolicy::minIntervalMs(_obs->alert_min_interval_min);
// -------- WiFi fault --------
// -------- Primary network fault --------
if (_obs->alert_wifi_minutes > 0) {
if (_bridge != nullptr) {
const AlertFaultPolicy::OutageSnapshot snap = _bridge->getWifiOutageSnapshot();
const AlertFaultPolicy::OutageSnapshot snap =
_bridge->getNetworkOutageSnapshot();
const NetworkMedium alert_medium = _bridge->getNetworkAlertMedium();
const char* alert_label = alert_medium == NetworkMedium::Ethernet
? "Ethernet" : "WiFi";
AlertFaultPolicy::TickResult r = AlertFaultPolicy::tick(
_wifi, now, snap,
AlertFaultPolicy::thresholdMs(_obs->alert_wifi_minutes),
min_interval_ms);
if (r.action == AlertFaultPolicy::Action::FireDown) {
char text[80];
AlertFaultPolicy::formatWifiAlert(text, sizeof(text), r, snap);
AlertFaultPolicy::formatNetworkAlert(
text, sizeof(text), alert_label, 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), alert_label, r, snap);
sendChannel(text);
AlertFaultPolicy::commitRecovered(_wifi);
}
+17 -6
View File
@@ -1276,16 +1276,27 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
(int)mi.fordblks, (int)mi.uordblks, (int)mi.arena, (int)mi.ordblks,
_callbacks->getQueueSize());
#endif
} else if (memcmp(command, "start ota", 9) == 0) {
} else if (memcmp(command, "start ota", 9) == 0 &&
(command[9] == 0 || command[9] == ' ')) {
// Manual OTA: bring up the ElegantOTA web UI for a hand-uploaded binary.
// Plain "start ota" serves on the station IP when joined to WiFi, else
// raises the MeshCore-OTA SoftAP. "start ota ap" forces the SoftAP even
// when connected, so the UI is reachable when the network applies client
// isolation and the station IP can't be reached. (&& short-circuits keep
// the [10]/[11] reads in-bounds when command == "start ota".)
bool force_ap = (command[9] == ' ' && command[10] == 'a' && command[11] == 'p');
if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap)) {
strcpy(reply, "Error");
// isolation and the station IP can't be reached.
bool force_ap = command[9] == ' ' && strcmp(&command[10], "ap") == 0;
if (command[9] == ' ' && !force_ap) {
strcpy(reply, "ERR: usage start ota [ap]");
} else {
reply[0] = 0;
if (!_board->startOTAUpdate(_prefs->node_name, reply, force_ap) &&
reply[0] == 0) {
strcpy(reply, "Error");
}
}
} else if (strcmp(command, "stop ota") == 0) {
reply[0] = 0;
if (!_board->stopOTAUpdate(reply) && reply[0] == 0) {
strcpy(reply, "ERR: OTA web server not running");
}
} else if (memcmp(command, "clock", 5) == 0) {
uint32_t now = getRTCClock()->getCurrentTime();
+61 -28
View File
@@ -15,8 +15,10 @@
#include "TxtDataHelpers.h"
#include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]()
#include "MQTTObserverValidation.h" // pure input validators (host-testable)
#include "NetworkLink.h"
#include "WifiPowerSavePolicy.h" // one powersave value->mode/name mapping
#include <Utils.h>
#include <climits>
#include <new>
#ifdef ESP_PLATFORM
#include <WiFi.h>
@@ -415,8 +417,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 (!activeNetworkLink().isConnected()) {
snprintf(reply, 160, "OK - saved (%s not connected; NTP sync pending)",
activeNetworkLink().mediumName());
} else if (!_callbacks->isMqttBridgeRunning()) {
strcpy(reply, "OK - saved (MQTT bridge not running)");
} else if (_callbacks->syncMqttNtp()) {
@@ -447,8 +450,9 @@ bool CommonCLI::handleObserverSetCmd(uint32_t sender_timestamp, const char* conf
if (!persistObserverPrefs(reply)) return true;
const char* ps_name = WifiPowerSavePolicy::nameFor(ps_value);
#ifdef ESP_PLATFORM
if (WiFi.status() == WL_CONNECTED) {
// Same mapping the bridge applies on every association, so this cannot
if (strcmp(activeNetworkLink().mediumName(), "wifi") == 0 &&
activeNetworkLink().isConnected()) {
// Same mapping the link applies on every association, so this cannot
// drift back apart (see WifiPowerSavePolicy).
esp_err_t ps_result =
esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(ps_value));
@@ -946,8 +950,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 (!activeNetworkLink().isConnected()) {
snprintf(reply, 160, "Error: %s not connected",
activeNetworkLink().mediumName());
} else if (!_callbacks->isMqttBridgeRunning()) {
strcpy(reply, "Error: MQTT bridge not running");
} else if (!_callbacks->runMqttNtpDiag(reply, 160, sender_timestamp == 0)) {
@@ -1021,20 +1026,30 @@ 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 (strcmp(config, "link.diag") == 0) {
activeNetworkLink().formatDiagnostics(reply, 160);
} else if (memcmp(config, "link.status", 11) == 0 ||
memcmp(config, "wifi.status", 11) == 0) {
NetworkLink& network = activeNetworkLink();
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) {
@@ -1062,19 +1077,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) {
@@ -1136,8 +1167,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 (!activeNetworkLink().isConnected()) {
snprintf(reply, 160, "ERR: %s not connected",
activeNetworkLink().mediumName());
} else {
size_t bundle_len = 0;
if (rootca_crt_bundle_start != nullptr &&
@@ -1182,8 +1214,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 (!activeNetworkLink().isConnected()) {
snprintf(reply, 160, "ERR: %s not connected",
activeNetworkLink().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
+141 -28
View File
@@ -1,6 +1,7 @@
#ifdef ESP_PLATFORM
#include "ESP32Board.h"
#include "NetworkLink.h"
#include <target.h>
#if defined(ADMIN_PASSWORD) && !defined(DISABLE_WIFI_OTA) // Repeater or Room Server only
@@ -8,54 +9,161 @@
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <AsyncElegantOTA.h>
#include <Update.h>
#include <SPIFFS.h>
#include <new>
#include "esp32/HttpPort80Lease.h"
namespace {
AsyncWebServer* ota_server = nullptr;
bool ota_routes_registered = false;
bool ota_server_running = false;
bool ota_raised_ap = false;
bool ota_network_locked = false;
uint32_t ota_started_at = 0;
char ota_id_buf[60];
char ota_home_buf[90];
char ota_url_buf[80];
void otaRegisterRoutes() {
ota_server->on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(200, "text/html", ota_home_buf);
});
ota_server->on("/log", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(SPIFFS, "/packet_log", "text/plain");
});
AsyncElegantOTA.begin(ota_server);
ota_routes_registered = true;
}
void otaReleaseTransport() {
if (ota_server) ota_server->end();
if (ota_raised_ap) WiFi.softAPdisconnect(true);
ota_raised_ap = false;
ota_server_running = false;
ota_started_at = 0;
if (ota_network_locked) {
activeNetworkLink().unlockSwitching();
ota_network_locked = false;
}
HttpPort80Lease::release(HttpPort80Lease::Owner::Ota);
}
} // namespace
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
inhibit_sleep = true; // prevent sleep during OTA
if (ota_server_running) {
ota_started_at = millis();
snprintf(reply, 160, "Started: %s", ota_url_buf);
return true;
}
if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::Ota)) {
snprintf(reply, 160, "Error: port 80 is in use by %s",
HttpPort80Lease::ownerName());
return false;
}
// 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.
inhibit_sleep = true; // prevent sleep during OTA
activeNetworkLink().lockSwitching();
ota_network_locked = true;
// 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, activeNetworkLink().isConnected())) {
ip = activeNetworkLink().localIP();
} else {
WiFi.softAP("MeshCore-OTA", NULL);
ota_raised_ap = WiFi.softAP("MeshCore-OTA", NULL);
if (!ota_raised_ap) {
otaReleaseTransport();
inhibit_sleep = false;
strcpy(reply, "Error: failed to start OTA AP");
return false;
}
ip = WiFi.softAPIP();
}
sprintf(reply, "Started: http://%s/update", ip.toString().c_str());
snprintf(ota_id_buf, sizeof(ota_id_buf), "%s (%s)", id,
getManufacturerName());
snprintf(ota_home_buf, sizeof(ota_home_buf),
"<H2>Hi! I am a MeshCore Repeater. ID: %s</H2>", id);
snprintf(ota_url_buf, sizeof(ota_url_buf), "http://%s/update",
ip.toString().c_str());
if (!ota_server) {
ota_server = new (std::nothrow) AsyncWebServer(80);
if (!ota_server) {
otaReleaseTransport();
inhibit_sleep = false;
strcpy(reply, "Error: insufficient memory for OTA server");
return false;
}
}
AsyncElegantOTA.setID(ota_id_buf);
if (!ota_routes_registered) otaRegisterRoutes();
ota_server->begin();
if (ota_server->state() != LISTEN) {
otaReleaseTransport();
inhibit_sleep = false;
strcpy(reply, "Error: failed to bind OTA server to port 80");
return false;
}
ota_server_running = true;
ota_started_at = millis();
snprintf(reply, 160, "Started: %s", ota_url_buf);
MESH_DEBUG_PRINTLN("startOTAUpdate: %s", reply);
static char id_buf[60];
sprintf(id_buf, "%s (%s)", id, getManufacturerName());
static char home_buf[90];
sprintf(home_buf, "<H2>Hi! I am a MeshCore Repeater. ID: %s</H2>", id);
AsyncWebServer* server = new AsyncWebServer(80);
server->on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(200, "text/html", home_buf);
});
server->on("/log", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send(SPIFFS, "/packet_log", "text/plain");
});
AsyncElegantOTA.setID(id_buf);
AsyncElegantOTA.begin(server); // Start ElegantOTA
server->begin();
return true;
}
bool ESP32Board::stopOTAUpdate(char reply[]) {
if (!ota_server_running) return false;
if (Update.isRunning()) {
strcpy(reply, "Error: firmware upload is in progress");
return false;
}
otaReleaseTransport();
inhibit_sleep = false;
strcpy(reply, "OK - OTA web server stopped");
return true;
}
bool ESP32Board::isOTAUpdateInProgress() const {
return ota_server_running && Update.isRunning();
}
void ESP32Board::maintainOTAUpdate(uint32_t now_ms) {
if (!ota_server_running || Update.isRunning()) return;
if (!NetworkPolicy::manualOtaTimeoutDue(now_ms, ota_started_at, false)) return;
char reply[160];
if (stopOTAUpdate(reply)) MESH_DEBUG_PRINTLN("OTA: %s", reply);
}
#else
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
(void)id; (void)reply; (void)force_ap;
return false; // not supported
}
bool ESP32Board::stopOTAUpdate(char reply[]) {
(void)reply;
return false;
}
bool ESP32Board::isOTAUpdateInProgress() const {
return false;
}
void ESP32Board::maintainOTAUpdate(uint32_t now_ms) {
(void)now_ms;
}
#endif
// ---------------------------------------------------------------------------
@@ -182,16 +290,20 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep
// mesh-receive call chain (it overflows the loopTask canary). Run the work in a
// dedicated 24 KB-stack task and block here until it finishes. The big stack is
// freed when the task exits; on a successful update the chip reboots inside it.
NetworkLink& network = activeNetworkLink();
network.lockSwitching();
OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false };
TaskHandle_t handle = nullptr;
BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1);
if (ok != pdPASS) {
network.unlockSwitching();
strcpy(reply, "ERR: OTA task spawn failed");
return false;
}
while (!args.done) {
delay(50); // Arduino delay() yields to other tasks
}
network.unlockSwitching();
return args.result;
}
@@ -200,8 +312,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 (!activeNetworkLink().isConnected()) {
snprintf(reply, 160, "ERR: %s not connected",
activeNetworkLink().mediumName());
return false;
}
+3
View File
@@ -158,6 +158,9 @@ public:
}
bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override;
bool stopOTAUpdate(char* reply) override;
bool isOTAUpdateInProgress() const override;
void maintainOTAUpdate(uint32_t now_ms) override;
bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override;
// Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task
// spawned by otaFromManifest() — public only so that task entry point can call
+14
View File
@@ -91,6 +91,20 @@ static inline bool circuitBreakerProbeDue(uint32_t now, uint32_t last_attempt) {
return elapsedMs(now, last_attempt) >= kCircuitBreakerProbeMs;
}
// Move a disconnected slot's deadline to "due now" after the physical link
// returns. Preserve its earned backoff and breaker state: a normal reconnect
// gets one immediate attempt at its current rung, while a tripped breaker gets
// one immediate probe. Unsigned subtraction keeps this wrap-safe.
static inline uint32_t immediateRetryLastAttempt(uint32_t now,
bool circuit_breaker_tripped,
uint8_t reconnect_backoff,
uint8_t slot_index) {
const uint32_t delay = circuit_breaker_tripped
? kCircuitBreakerProbeMs
: reconnectDelayMs(reconnect_backoff, slot_index);
return now - delay;
}
// WiFi station reconnect backoff. The bridge drives its own STA reconnect loop
// separate from the per-slot MQTT reconnects, with a slightly longer first rung
// (15 s vs the slot ladder's 10 s). Extracted from handleWiFiConnection() so the
+1
View File
@@ -112,6 +112,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS;
prefs->display_flip = 0;
prefs->network_setup_complete = 0;
}
#endif // WITH_MQTT_BRIDGE
+11 -2
View File
@@ -40,22 +40,31 @@ class MQTTPrefsSerializer : public ConfigSerializer {
class WifiPrefs : public ConfigSerializer {
MQTTPrefs* _prefs;
int32_t _power_save;
int32_t _power_save, _setup_complete;
bool _seen_ssid = false, _seen_password = false, _seen_power_save = false;
bool _seen_setup_complete = false;
protected:
void structure() override {
defStrict("ssid", _prefs->wifi_ssid, sizeof(_prefs->wifi_ssid), _seen_ssid);
defStrict("password", _prefs->wifi_password, sizeof(_prefs->wifi_password), _seen_password);
defStrict("power_save", _power_save, _seen_power_save);
defStrict("setup_complete", _setup_complete, _seen_setup_complete);
}
public:
explicit WifiPrefs(MQTTPrefs* prefs) : _prefs(prefs), _power_save(prefs->wifi_power_save) {}
explicit WifiPrefs(MQTTPrefs* prefs)
: _prefs(prefs), _power_save(prefs->wifi_power_save),
_setup_complete(prefs->network_setup_complete) {}
bool apply(bool* repaired) {
if (_power_save < 0 || _power_save > WifiPowerSavePolicy::kMaxStoredValue) {
_power_save = 1;
*repaired = true;
}
if (_setup_complete < 0 || _setup_complete > 1) {
_setup_complete = 0;
*repaired = true;
}
_prefs->wifi_power_save = static_cast<uint8_t>(_power_save);
_prefs->network_setup_complete = static_cast<uint8_t>(_setup_complete);
return true;
}
};
+10
View File
@@ -126,8 +126,18 @@ struct MQTTPrefs {
// Rotate the panel 180 degrees from its compiled DISPLAY_ROTATION, for boards
// mounted the other way up. Runtime only, like display_timeout_secs.
uint8_t display_flip;
// Explicitly records completion of first-run network onboarding when a node
// is configured over Ethernet and therefore may intentionally have no Wi-Fi
// SSID. Existing Wi-Fi installations remain complete by the helper below.
uint8_t network_setup_complete;
};
static inline bool mqttNetworkSetupComplete(const MQTTPrefs* prefs) {
return prefs != nullptr &&
(prefs->network_setup_complete != 0 || prefs->wifi_ssid[0] != '\0');
}
static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60;
static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600;
+107
View File
@@ -0,0 +1,107 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
namespace NetworkHostname {
// ESP-IDF documents a 32-byte hostname limit. Keep one byte for the trailing
// NUL so the same buffer is safe through Arduino and lwIP APIs.
static constexpr size_t kMaxLength = 31;
static constexpr size_t kBufferSize = kMaxLength + 1;
static inline bool isAsciiAlphaNumeric(uint8_t ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') ||
(ch >= '0' && ch <= '9');
}
static inline char asciiLower(uint8_t ch) {
return ch >= 'A' && ch <= 'Z' ? static_cast<char>(ch + ('a' - 'A'))
: static_cast<char>(ch);
}
/**
* Build a DHCP-safe hostname from the MeshCore node name.
*
* The result is lowercase, starts with "meshcore-", contains only letters,
* digits and hyphens, and never exceeds ESP-IDF's practical 31-character
* payload limit. Six hex digits from the stable node identity are retained
* whenever non-ASCII bytes are removed, a fallback is needed, or the readable
* name must be shortened. This keeps lossy sanitization from assigning the
* same DHCP identity to unrelated nodes.
*/
static inline bool build(char* dest, size_t dest_size, const char* node_name,
const uint8_t* stable_id, size_t stable_id_size) {
if (!dest || dest_size == 0) return false;
dest[0] = '\0';
static constexpr char kPrefix[] = "meshcore-";
static constexpr char kFallback[] = "node";
static constexpr char kHex[] = "0123456789abcdef";
static constexpr size_t kPrefixLength = sizeof(kPrefix) - 1;
static constexpr size_t kSuffixLength = 7; // '-' plus six hex digits.
// NodePrefs::node_name currently holds at most 31 bytes. A larger scratch
// buffer keeps this helper safe and independently testable with other input.
char slug[64];
size_t slug_length = 0;
bool separator_pending = false;
bool removed_non_ascii = false;
if (node_name) {
for (size_t i = 0; node_name[i] != '\0' && slug_length < sizeof(slug) - 1;
++i) {
const uint8_t ch = static_cast<uint8_t>(node_name[i]);
if (isAsciiAlphaNumeric(ch)) {
if (separator_pending && slug_length > 0 &&
slug_length < sizeof(slug) - 1) {
slug[slug_length++] = '-';
}
if (slug_length < sizeof(slug) - 1) {
slug[slug_length++] = asciiLower(ch);
}
separator_pending = false;
} else {
if (ch >= 0x80) removed_non_ascii = true;
if (slug_length > 0) separator_pending = true;
}
}
}
const bool used_fallback = slug_length == 0;
if (slug_length == 0) {
for (size_t i = 0; i < sizeof(kFallback) - 1; ++i) {
slug[slug_length++] = kFallback[i];
}
}
slug[slug_length] = '\0';
size_t max_length = dest_size - 1;
if (max_length > kMaxLength) max_length = kMaxLength;
if (max_length == 0) return false;
const bool needs_truncation = kPrefixLength + slug_length > max_length;
const bool needs_identity = needs_truncation || removed_non_ascii || used_fallback;
const bool can_add_identity = needs_identity && stable_id &&
stable_id_size >= 3 && max_length > kPrefixLength + kSuffixLength;
const size_t suffix_length = can_add_identity ? kSuffixLength : 0;
const size_t prefix_length = kPrefixLength < max_length
? kPrefixLength : max_length;
size_t slug_budget = max_length - prefix_length - suffix_length;
if (slug_budget > slug_length) slug_budget = slug_length;
while (slug_budget > 0 && slug[slug_budget - 1] == '-') --slug_budget;
size_t out = 0;
for (size_t i = 0; i < prefix_length; ++i) dest[out++] = kPrefix[i];
for (size_t i = 0; i < slug_budget; ++i) dest[out++] = slug[i];
if (can_add_identity) {
dest[out++] = '-';
for (size_t i = 0; i < 3; ++i) {
dest[out++] = kHex[(stable_id[i] >> 4) & 0x0f];
dest[out++] = kHex[stable_id[i] & 0x0f];
}
}
dest[out] = '\0';
return true;
}
} // namespace NetworkHostname
+979
View File
@@ -0,0 +1,979 @@
#include "NetworkLink.h"
#if defined(ESP_PLATFORM)
#include "MQTTConnectionPolicy.h"
#include "WifiPowerSavePolicy.h"
#include <atomic>
#include <climits>
#include <cstring>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_netif.h>
#include <lwip/dns.h>
#if defined(NETWORK_PREFER_ETHERNET)
#include "ethernet/ch390/CH390Config.h"
// Arduino-ESP32's built-in ETH implementation calls this before bringing up
// Ethernet. It creates the shared Arduino network event group/task used by
// WiFiGenericClass::hostByName(), even when no Wi-Fi interface is started.
// ESP32-CH390 initializes esp_netif directly and omits this Arduino layer.
extern void tcpipInit();
#endif
// Same "MQTT: " prefix as MQTT_DEBUG_PRINTLN so existing log greps keep matching.
#if defined(MQTT_DEBUG)
#define NETWORK_DEBUG_PRINTLN(F, ...) do { if (Serial.availableForWrite() > 0) { Serial.printf("MQTT: " F "\n", ##__VA_ARGS__); } } while (0)
#else
#define NETWORK_DEBUG_PRINTLN(...) do {} while (0)
#endif
namespace {
class NetworkLinkBase : public NetworkLink {
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;
static constexpr int kDnsServers = 2;
// Only ever touched on the lwIP thread (see snapshotDns()), so no atomics.
ip_addr_t _dns_snapshot[kDnsServers] = {};
bool _dns_captured = false;
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();
}
private:
// Remember the resolver this link's DHCP lease installed, so switching back
// to it can put that resolver back.
//
// lwIP keeps ONE global server list, not one per interface: a DHCP lease on
// any medium calls dns_setserver() and overwrites whatever the other medium
// had. IDF 4.4 has no per-interface retention either — esp_netif_get_dns_info()
// reads the same globals — so the medium that leased last owns DNS for every
// socket. Without this, an Ethernet node that falls back to Wi-Fi and then
// fails back keeps asking the Wi-Fi network's DNS server. When the two are on
// different subnets that server is unreachable from Ethernet, and the node
// reads as connected while every broker and NTP hostname fails to resolve,
// until Ethernet's own DHCP renewal happens to fix it hours later.
//
// Both halves run through esp_netif_tcpip_exec() because lwIP's DNS functions
// belong to the TCP/IP thread, and these are called from the Arduino event
// task (Ethernet GOT_IP) and the MQTT task (Wi-Fi edge, and select()). That
// also serializes the snapshot itself, so it needs no atomics. Neither caller
// is the TCP/IP thread, so the call cannot deadlock on itself.
//
// The whole ip_addr_t is kept, not an IPv4 word: these builds compile lwIP
// with IPv6 on, and reinterpreting a v6 server as v4 would install four
// meaningless bytes as a resolver. (RDNSS is disabled here, so a v6 server
// should never appear — storing the tagged value means it stays correct if
// that ever changes.)
static esp_err_t snapshotDnsOnTcpipThread(void* ctx) {
NetworkLinkBase* self = static_cast<NetworkLinkBase*>(ctx);
bool any = false;
for (int i = 0; i < kDnsServers; i++) {
const ip_addr_t* server = dns_getserver(i);
self->_dns_snapshot[i] = (server != nullptr) ? *server : *IP_ADDR_ANY;
if (!ip_addr_isany_val(self->_dns_snapshot[i])) any = true;
}
// A lease that carried no resolver at all (static configuration, or a
// server that offered none) must not be remembered as "this medium's DNS is
// nothing" — restoring that would wipe a working resolver for no gain.
if (any) self->_dns_captured = true;
return ESP_OK;
}
static esp_err_t restoreDnsOnTcpipThread(void* ctx) {
NetworkLinkBase* self = static_cast<NetworkLinkBase*>(ctx);
if (!self->_dns_captured) return ESP_OK;
// Every slot is written, empty ones included: returning to a network with
// one server must not leave the other medium's second server behind as a
// fallback that only fails slowly.
for (int i = 0; i < kDnsServers; i++) {
dns_setserver(i, &self->_dns_snapshot[i]);
}
return ESP_OK;
}
public:
void snapshotDns() { esp_netif_tcpip_exec(snapshotDnsOnTcpipThread, this); }
// A medium that has never held a lease with a resolver leaves the current one
// alone rather than blanking it.
void restoreDns() { esp_netif_tcpip_exec(restoreDnsOnTcpipThread, this); }
};
class WiFiNetworkLink final : public NetworkLinkBase {
bool _event_registered = false;
char _hostname[32] = {};
char _ssid[33] = {};
char _password[65] = {};
unsigned long _last_reconnect_attempt = 0;
uint8_t _reconnect_backoff_attempt = 0;
// One mapping for startup, reconnect and CLI (see WifiPowerSavePolicy). The
// stored default is `none`; `min` means MIN_MODEM here exactly as the CLI
// says it does. Mapping the stored value locally is what let a node set to
// `min` silently run with power save off after its first reconnect.
void applyPowerPrefs(uint8_t wifi_power_save) {
static_assert((int)WifiPowerSavePolicy::kModeNone == (int)WIFI_PS_NONE, "wifi_ps_type_t drift");
static_assert((int)WifiPowerSavePolicy::kModeMinModem == (int)WIFI_PS_MIN_MODEM, "wifi_ps_type_t drift");
static_assert((int)WifiPowerSavePolicy::kModeMaxModem == (int)WIFI_PS_MAX_MODEM, "wifi_ps_type_t drift");
esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(wifi_power_save));
// Read back rather than logging what we asked for: this is the only place
// the mode is observable on a running node, and the setting used to change
// meaning between the CLI and this path.
wifi_ps_type_t applied = WIFI_PS_NONE;
esp_wifi_get_ps(&applied);
NETWORK_DEBUG_PRINTLN("WiFi power save: %s (mode=%d)",
WifiPowerSavePolicy::nameFor(wifi_power_save), (int)applied);
#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"; }
NetworkMedium medium() const override { return NetworkMedium::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';
}
void setHostname(const char* hostname) override {
strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1);
_hostname[sizeof(_hostname) - 1] = '\0';
}
void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override {
strncpy(_ssid, wifi_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';
}
bool begin(const char* wifi_ssid, const char* wifi_password) override {
if (!configValid(wifi_ssid)) return false;
updateWifiCredentials(wifi_ssid, wifi_password);
// Arduino-ESP32 applies this stored value when it creates the STA netif.
// It must be set before WiFi.mode()/begin() for the first DHCP exchange.
if (_hostname[0] != '\0') WiFi.setHostname(_hostname);
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t info) {
switch (event) {
case ARDUINO_EVENT_WIFI_STA_GOT_IP:
NETWORK_DEBUG_PRINTLN("WiFi connected: %s",
IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str());
noteConnected(millis());
_reconnect_backoff_attempt = 0;
break;
case ARDUINO_EVENT_WIFI_STA_DISCONNECTED:
noteDisconnected(millis(), info.wifi_sta_disconnected.reason);
NETWORK_DEBUG_PRINTLN("WiFi disconnected: reason %d",
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));
// Already associated when the link started (end()/begin() leaves STA up):
// there is no connect transition below to carry the setting, so apply it
// here or the node keeps running whatever mode was set before.
if (connected) {
applyPowerPrefs(wifi_power_save);
snapshotDns();
}
}
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);
snapshotDns();
}
_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 && _ssid[0] != '\0' && 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);
}
void formatDiagnostics(char* reply, size_t reply_size) const override {
snprintf(reply, reply_size,
"> why:ethernet-not-enabled selected:wifi\n"
"wifi:state:%s ip:%s",
statusName(),
localIP().toString().c_str());
}
};
#if defined(NETWORK_PREFER_ETHERNET)
class EthernetNetworkLink final : public NetworkLinkBase {
public:
enum class EventState : uint8_t {
None,
Started,
LinkDown,
LinkUp,
GotIp,
Stopped,
};
private:
std::atomic<bool> _started{false};
bool _event_registered = false;
char _hostname[32] = {};
std::atomic<uint8_t> _event_state{static_cast<uint8_t>(EventState::None)};
public:
const char* mediumName() const override { return "ethernet"; }
NetworkMedium medium() const override { return NetworkMedium::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; }
void setHostname(const char* hostname) override {
strncpy(_hostname, hostname ? hostname : "", sizeof(_hostname) - 1);
_hostname[sizeof(_hostname) - 1] = '\0';
}
bool begin(const char*, const char*) override {
if (_started.load(std::memory_order_acquire)) return true;
// DNS and WiFiClientSecure are transport-neutral sockets in this Arduino
// core, but their hostname path still uses WiFiGeneric's event group.
// Initialize that shared runtime without enabling or associating Wi-Fi.
tcpipInit();
if (!_event_registered) {
WiFi.onEvent([this](WiFiEvent_t event, WiFiEventInfo_t) {
switch (event) {
case ARDUINO_EVENT_ETH_START:
_event_state.store(static_cast<uint8_t>(EventState::Started),
std::memory_order_relaxed);
break;
case ARDUINO_EVENT_ETH_CONNECTED:
_event_state.store(static_cast<uint8_t>(EventState::LinkUp),
std::memory_order_relaxed);
break;
case ARDUINO_EVENT_ETH_GOT_IP:
_event_state.store(static_cast<uint8_t>(EventState::GotIp),
std::memory_order_relaxed);
noteConnected(millis());
// Taken here rather than at the maintain() edge: this is the moment
// the lease installed the resolver, before a Wi-Fi fallback lease
// can overwrite it.
snapshotDns();
break;
case ARDUINO_EVENT_ETH_DISCONNECTED:
_event_state.store(static_cast<uint8_t>(EventState::LinkDown),
std::memory_order_relaxed);
// Ethernet has no 802.11 reason code; zero means unavailable.
noteDisconnected(millis(), 0);
_connected_at.store(0, std::memory_order_relaxed);
break;
case ARDUINO_EVENT_ETH_STOP:
_event_state.store(static_cast<uint8_t>(EventState::Stopped),
std::memory_order_relaxed);
break;
default:
break;
}
});
_event_registered = true;
}
_event_state.store(static_cast<uint8_t>(EventState::None),
std::memory_order_relaxed);
const bool started = beginConfiguredCH390(_hostname);
_started.store(started, std::memory_order_release);
if (started && isConnected()) noteConnected(millis());
return started;
}
bool restart() {
CH390.end();
_started.store(false, std::memory_order_release);
// Preserve status/outage history across attempts. maintain() observes the
// resulting edge in this same task iteration, so a retry cannot reset a
// prolonged-down alert timer or hide a previously connected transition.
return begin(nullptr, nullptr);
}
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);
snapshotDns();
}
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));
snapshotDns();
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.load(std::memory_order_acquire) && 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);
}
void formatDiagnostics(char* reply, size_t reply_size) const override {
snprintf(reply, reply_size, "> ethernet:%s ip=%s",
statusName(), localIP().toString().c_str());
}
EventState eventState() const {
return static_cast<EventState>(
_event_state.load(std::memory_order_relaxed));
}
bool started() const { return _started.load(std::memory_order_acquire); }
bool sampleLink(bool& known) const {
known = false;
if (!_started.load(std::memory_order_acquire)) return false;
// IEEE 802.3 BMSR link status is latch-low. Read it twice so the second
// value is the current carrier state rather than a remembered link flap.
(void)CH390.readPHY(0x01);
const uint32_t bmsr = CH390.readPHY(0x01) & 0xffffu;
if (bmsr != 0 && bmsr != 0xffffu) {
known = true;
return (bmsr & (1u << 2)) != 0;
}
// A failed/unsupported direct PHY read can still use the driver's events.
const EventState state = eventState();
known = state == EventState::LinkDown || state == EventState::LinkUp ||
state == EventState::GotIp || state == EventState::Stopped;
return state == EventState::LinkUp || state == EventState::GotIp;
}
bool linkUp() const {
bool known = false;
return sampleLink(known);
}
const char* eventName() const {
switch (eventState()) {
case EventState::None: return "none";
case EventState::Started: return "started";
case EventState::LinkDown: return "link-down";
case EventState::LinkUp: return "link-up";
case EventState::GotIp: return "got-ip";
case EventState::Stopped: return "stopped";
}
return "unknown";
}
};
class AutomaticNetworkLink final : public NetworkLink {
EthernetNetworkLink _ethernet;
WiFiNetworkLink _wifi;
std::atomic<NetworkMedium> _selected{NetworkMedium::None};
std::atomic<bool> _ethernet_started{false};
std::atomic<bool> _wifi_started{false};
char _wifi_ssid[33] = {};
char _wifi_password[65] = {};
std::atomic<uint32_t> _ethernet_stable_since{0};
std::atomic<uint32_t> _selected_down_since{0};
std::atomic<uint32_t> _last_ethernet_init_attempt{0};
std::atomic<uint8_t> _ethernet_retry_attempt{0};
std::atomic<uint32_t> _ethernet_no_ip_since{0};
std::atomic<uint8_t> _switch_locks{0};
std::atomic<bool> _switch_in_progress{false};
std::atomic<bool> _ethernet_seen{false}; // held a lease at least once this boot
// Ethernet is the primary for alerts once it has carried traffic, or when it
// is the only configured medium; a Wi-Fi-only install keeps Wi-Fi alerts.
bool ethernetIsAlertPrimary() const {
return _ethernet_seen.load(std::memory_order_acquire) || !wifiConfigured();
}
NetworkLink& selectedInterface(NetworkMedium selected) {
return selected == NetworkMedium::Ethernet
? static_cast<NetworkLink&>(_ethernet)
: static_cast<NetworkLink&>(_wifi);
}
const NetworkLink& selectedInterface(NetworkMedium selected) const {
return selected == NetworkMedium::Ethernet
? static_cast<const NetworkLink&>(_ethernet)
: static_cast<const NetworkLink&>(_wifi);
}
bool wifiConfigured() const { return _wifi_ssid[0] != '\0'; }
void rememberWifi(const char* ssid, const char* password) {
strncpy(_wifi_ssid, ssid ? ssid : "", sizeof(_wifi_ssid) - 1);
_wifi_ssid[sizeof(_wifi_ssid) - 1] = '\0';
strncpy(_wifi_password, password ? password : "", sizeof(_wifi_password) - 1);
_wifi_password[sizeof(_wifi_password) - 1] = '\0';
}
void startWifiFallback() {
if (_wifi_started.load(std::memory_order_acquire) || !wifiConfigured()) return;
_wifi_started.store(_wifi.begin(_wifi_ssid, _wifi_password),
std::memory_order_release);
}
// Lock and mutation each publish one flag then read the other; seq_cst keeps
// both sides from missing each other.
bool beginUnlockedMutation() {
bool expected = false;
if (!_switch_in_progress.compare_exchange_strong(expected, true)) {
return false;
}
if (_switch_locks.load() != 0) {
_switch_in_progress.store(false, std::memory_order_release);
return false;
}
return true;
}
void endUnlockedMutation() {
_switch_in_progress.store(false, std::memory_order_release);
}
bool selectIfUnlocked(NetworkMedium medium) {
if (!beginUnlockedMutation()) return false;
select(medium);
endUnlockedMutation();
return true;
}
void startWifiFallbackIfUnlocked() {
if (!beginUnlockedMutation()) return;
startWifiFallback();
endUnlockedMutation();
}
void select(NetworkMedium medium) {
if (medium == NetworkMedium::Ethernet) {
// ESP-IDF gives Wi-Fi a higher default-route priority than Ethernet.
// Keep only the selected STA associated so sockets cannot silently stay
// on Wi-Fi after the manager has declared Ethernet active.
WiFi.setAutoReconnect(false);
WiFi.disconnect(false, false);
_wifi_started.store(false, std::memory_order_release);
}
_selected_down_since.store(0, std::memory_order_relaxed);
_selected.store(medium, std::memory_order_release);
// Put back the resolver this medium leased. lwIP's server list is global,
// so whichever medium leased last still owns it here (see snapshotDns()).
if (medium == NetworkMedium::Ethernet) {
_ethernet.restoreDns();
} else if (medium == NetworkMedium::WiFi) {
_wifi.restoreDns();
}
}
bool startOrRetryEthernet(uint32_t now_ms, bool restart) {
const bool started = restart ? _ethernet.restart()
: _ethernet.begin(nullptr, nullptr);
_last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed);
_ethernet_started.store(started, std::memory_order_release);
if (started) {
_ethernet_retry_attempt.store(0, std::memory_order_relaxed);
} else {
uint8_t attempt = _ethernet_retry_attempt.load(std::memory_order_relaxed);
if (attempt != UINT8_MAX) ++attempt;
_ethernet_retry_attempt.store(attempt, std::memory_order_relaxed);
}
return started;
}
public:
const char* mediumName() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet) return "ethernet";
if (selected == NetworkMedium::WiFi) return "wifi";
return "none";
}
NetworkMedium medium() const override {
return _selected.load(std::memory_order_acquire);
}
const char* statusName() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? "not_selected"
: selectedInterface(selected).statusName();
}
int statusCode() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? 0
: selectedInterface(selected).statusCode();
}
bool configValid(const char* wifi_ssid) const override {
// Hardware availability and stored credentials are configuration. Current
// link/DHCP state is runtime state and must not permanently suppress the
// MQTT task that monitors for a late cable or lease.
(void)wifi_ssid;
return true;
}
bool isAutomatic() const override { return true; }
void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
_wifi.updateWifiCredentials(_wifi_ssid, _wifi_password);
}
void setHostname(const char* hostname) override {
// Whichever medium wins now or during a later failover presents the same
// stable DHCP identity to the LAN.
_ethernet.setHostname(hostname);
_wifi.setHostname(hostname);
}
bool begin(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started.load(std::memory_order_acquire)) {
const uint32_t now_ms = millis();
const uint8_t attempt =
_ethernet_retry_attempt.load(std::memory_order_relaxed);
if (NetworkPolicy::ethernetInitRetryDue(
attempt, now_ms,
_last_ethernet_init_attempt.load(std::memory_order_relaxed)) &&
beginUnlockedMutation()) {
startOrRetryEthernet(now_ms, attempt != 0);
endUnlockedMutation();
}
}
// bootstrap() owns the initial choice. MQTT begin() is intentionally
// idempotent and cannot demote a boot-selected Ethernet link because of a
// momentary status sample between tasks.
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::None) {
if (_ethernet.isConnected()) {
select(NetworkMedium::Ethernet);
} else if (wifiConfigured()) {
startWifiFallback();
_selected.store(NetworkMedium::WiFi, std::memory_order_release);
}
} else if (selected == NetworkMedium::WiFi) {
startWifiFallback();
}
return _ethernet_started.load(std::memory_order_acquire) ||
_wifi_started.load(std::memory_order_acquire);
}
bool bootstrap(const char* wifi_ssid, const char* wifi_password,
uint32_t wait_ms) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started.load(std::memory_order_acquire)) {
startOrRetryEthernet(millis(), false);
}
const uint32_t started_at = millis();
for (;;) {
bool link_known = false;
const bool link_up = _ethernet.sampleLink(link_known);
if (!NetworkPolicy::ethernetBootProbePending(
_ethernet_started.load(std::memory_order_acquire),
_ethernet.isConnected(), link_known, link_up,
(uint32_t)(millis() - started_at), wait_ms)) {
break;
}
delay(25);
}
if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release);
const NetworkMedium initial = NetworkPolicy::bootSelection(
_ethernet.isConnected(), wifiConfigured());
if (initial == NetworkMedium::Ethernet) {
select(initial);
} else {
startWifiFallback();
_selected.store(initial, std::memory_order_release);
}
return initial != NetworkMedium::None;
}
NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override {
bool ethernet_started = _ethernet_started.load(std::memory_order_acquire);
const bool switching_locked =
_switch_locks.load(std::memory_order_acquire) != 0;
const bool ethernet_stopped =
ethernet_started &&
_ethernet.eventState() == EthernetNetworkLink::EventState::Stopped;
if (ethernet_stopped) {
_ethernet_started.store(false, std::memory_order_release);
ethernet_started = false;
if (_ethernet_retry_attempt.load(std::memory_order_relaxed) == 0) {
_ethernet_retry_attempt.store(1, std::memory_order_relaxed);
_last_ethernet_init_attempt.store(now_ms, std::memory_order_relaxed);
}
}
const EthernetNetworkLink::EventState ethernet_event =
_ethernet.eventState();
const bool ethernet_link_up =
ethernet_event == EthernetNetworkLink::EventState::LinkUp ||
ethernet_event == EthernetNetworkLink::EventState::GotIp;
if (ethernet_started && ethernet_link_up && !_ethernet.isConnected()) {
if (_ethernet_no_ip_since.load(std::memory_order_relaxed) == 0) {
uint32_t started_at = now_ms;
if (started_at == 0) started_at = 1;
_ethernet_no_ip_since.store(started_at, std::memory_order_relaxed);
}
} else {
_ethernet_no_ip_since.store(0, std::memory_order_relaxed);
}
// Restarts rebuild the CH390 netif, so they take the same mutation gate as
// a route switch: an OTA/WebConfig lock taken after the sample above wins.
if (!switching_locked && NetworkPolicy::ethernetNoIpRecoveryDue(
ethernet_started, ethernet_link_up, _ethernet.isConnected(),
now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed)) &&
beginUnlockedMutation()) {
ethernet_started = startOrRetryEthernet(now_ms, true);
_ethernet_no_ip_since.store(0, std::memory_order_relaxed);
endUnlockedMutation();
}
if (!ethernet_started && !switching_locked) {
const uint8_t attempt =
_ethernet_retry_attempt.load(std::memory_order_relaxed);
if (NetworkPolicy::ethernetInitRetryDue(
attempt, now_ms,
_last_ethernet_init_attempt.load(std::memory_order_relaxed)) &&
beginUnlockedMutation()) {
ethernet_started = startOrRetryEthernet(now_ms, true);
endUnlockedMutation();
}
}
const NetworkTransition ethernet_transition =
_ethernet.maintain(now_ms, wifi_power_save);
if (_ethernet.isConnected()) _ethernet_seen.store(true, std::memory_order_release);
const bool wifi_started = _wifi_started.load(std::memory_order_acquire);
const NetworkTransition wifi_transition = wifi_started
? _wifi.maintain(now_ms, wifi_power_save)
: NetworkTransition::None;
// Ethernet may recover while a Wi-Fi fallback is still associating. In
// that sequence the selected enum never changes, but Wi-Fi would win
// ESP-IDF's default-route priority once it came up. Tear the unused STA
// down even without a selection edge, and force MQTT to reconnect if it
// had already become reachable.
NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet && _ethernet.isConnected() &&
wifi_started) {
const bool wifi_had_route = _wifi.isConnected();
if (selectIfUnlocked(NetworkMedium::Ethernet) && wifi_had_route) {
return NetworkTransition::Switched;
}
}
if (_ethernet.isConnected()) {
if (_ethernet_stable_since.load(std::memory_order_relaxed) == 0) {
_ethernet_stable_since.store(now_ms, std::memory_order_relaxed);
}
} else {
_ethernet_stable_since.store(0, std::memory_order_relaxed);
}
const bool selected_connected = isConnected();
if (!selected_connected) {
if (_selected_down_since.load(std::memory_order_relaxed) == 0) {
_selected_down_since.store(now_ms, std::memory_order_relaxed);
}
} else {
_selected_down_since.store(0, std::memory_order_relaxed);
}
const uint32_t selected_down_since =
_selected_down_since.load(std::memory_order_relaxed);
const uint32_t selected_down_ms = selected_down_since == 0
? 0 : (uint32_t)(now_ms - selected_down_since);
selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet && !_ethernet.isConnected() &&
selected_down_ms >= NetworkPolicy::kEthernetDownGraceMs) {
startWifiFallbackIfUnlocked();
}
const uint32_t ethernet_stable_since =
_ethernet_stable_since.load(std::memory_order_relaxed);
const uint32_t ethernet_stable_ms = ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - ethernet_stable_since);
const bool selection_locked =
_switch_locks.load(std::memory_order_acquire) != 0;
const NetworkPolicy::AutomaticSelectionInput input = {
selected, _ethernet.isConnected(),
_wifi_started.load(std::memory_order_acquire) && _wifi.isConnected(),
wifiConfigured(), selection_locked,
ethernet_stable_ms, selected_down_ms};
const NetworkMedium next = NetworkPolicy::automaticSelection(input);
if (next != selected) {
if (selectIfUnlocked(next)) {
return selected == NetworkMedium::None ? NetworkTransition::Up
: NetworkTransition::Switched;
}
}
if (selected == NetworkMedium::Ethernet) return ethernet_transition;
if (selected == NetworkMedium::WiFi) return wifi_transition;
return NetworkTransition::None;
}
void lockSwitching() override {
uint8_t value = _switch_locks.load(std::memory_order_relaxed);
while (value != UINT8_MAX && !_switch_locks.compare_exchange_weak(
value, static_cast<uint8_t>(value + 1))) {}
while (_switch_in_progress.load()) delay(1);
}
void unlockSwitching() override {
uint8_t value = _switch_locks.load(std::memory_order_relaxed);
while (value != 0 && !_switch_locks.compare_exchange_weak(
value, static_cast<uint8_t>(value - 1),
std::memory_order_relaxed, std::memory_order_relaxed)) {}
}
bool isConnected() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected != NetworkMedium::None &&
selectedInterface(selected).isConnected();
}
IPAddress localIP() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? IPAddress()
: selectedInterface(selected).localIP();
}
int rssi() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? INT_MIN
: selectedInterface(selected).rssi();
}
bool resolveHost(const char* hostname, IPAddress& address) const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected != NetworkMedium::None &&
selectedInterface(selected).resolveHost(hostname, address);
}
void formatDiagnostics(char* reply, size_t reply_size) const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
const bool ethernet_started =
_ethernet_started.load(std::memory_order_acquire);
const bool wifi_started = _wifi_started.load(std::memory_order_acquire);
const bool ethernet_connected = _ethernet.isConnected();
bool ethernet_link_known = false;
bool ethernet_link_up = _ethernet.sampleLink(ethernet_link_known);
ethernet_link_known = ethernet_link_known || ethernet_connected;
ethernet_link_up = ethernet_link_up || ethernet_connected;
const bool switching_locked =
_switch_locks.load(std::memory_order_acquire) != 0;
const uint32_t now_ms = millis();
const uint32_t ethernet_stable_since =
_ethernet_stable_since.load(std::memory_order_relaxed);
const uint32_t ethernet_stable_ms = ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - ethernet_stable_since);
const uint8_t retry_attempt =
_ethernet_retry_attempt.load(std::memory_order_relaxed);
const uint32_t retry_delay =
NetworkPolicy::ethernetInitRetryDelayMs(retry_attempt);
const uint32_t since_attempt = (uint32_t)(
now_ms - _last_ethernet_init_attempt.load(std::memory_order_relaxed));
const uint32_t retry_in = !ethernet_started && retry_delay > since_attempt
? retry_delay - since_attempt : 0;
const NetworkDiagnosticReason reason =
NetworkPolicy::automaticDiagnosticReason(
ethernet_started, ethernet_link_known, ethernet_link_up,
ethernet_connected, selected, switching_locked,
ethernet_stable_ms);
snprintf(reply, reply_size,
"> why:%s selected:%s lock:%s\n"
"eth:init:%s retry:%u/%lums evt:%s link:%s ip:%s\n"
"wifi:cfg:%s started:%s link:%s",
NetworkPolicy::diagnosticReasonName(reason),
NetworkPolicy::mediumName(selected),
switching_locked ? "yes" : "no",
ethernet_started ? "ok" : "failed", retry_attempt,
(unsigned long)retry_in, _ethernet.eventName(),
ethernet_link_known ? (ethernet_link_up ? "up" : "down")
: "unknown",
_ethernet.localIP().toString().c_str(),
wifiConfigured() ? "yes" : "no", wifi_started ? "yes" : "no",
(wifi_started && _wifi.isConnected()) ? "up" : "down");
}
unsigned long connectedAtMillis() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).connectedAtMillis();
}
uint8_t lastDisconnectReason() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).lastDisconnectReason();
}
unsigned long lastDisconnectTime() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).lastDisconnectTime();
}
AlertFaultPolicy::OutageSnapshot outageSnapshot() const override {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? AlertFaultPolicy::OutageSnapshot{false, 0, 0}
: selectedInterface(selected).outageSnapshot();
}
NetworkMedium alertMedium() const override {
return ethernetIsAlertPrimary() ? NetworkMedium::Ethernet : NetworkMedium::WiFi;
}
AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const override {
return ethernetIsAlertPrimary() ? _ethernet.outageSnapshot() : _wifi.outageSnapshot();
}
};
#endif
} // namespace
NetworkLink& activeNetworkLink() {
#if defined(NETWORK_PREFER_ETHERNET)
static AutomaticNetworkLink network;
#else
static WiFiNetworkLink network;
#endif
return network;
}
#endif
+81
View File
@@ -0,0 +1,81 @@
#pragma once
#include "NetworkPolicy.h"
#if defined(ESP_PLATFORM)
#include <Arduino.h>
#include <IPAddress.h>
#include "AlertFaultPolicy.h"
#ifndef NETWORK_ETHERNET_BOOT_WAIT_MS
#define NETWORK_ETHERNET_BOOT_WAIT_MS 8000UL
#endif
/**
* 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 NetworkLink {
public:
virtual ~NetworkLink() = default;
virtual const char* mediumName() const = 0;
virtual NetworkMedium medium() const = 0;
virtual const char* statusName() const = 0;
virtual int statusCode() const = 0;
virtual bool configValid(const char* wifi_ssid) const = 0;
// Configure the DHCP hostname before begin()/bootstrap(). Implementations
// retain it for any later fallback interface start.
virtual void setHostname(const char* hostname) = 0;
virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0;
// Latest stored credentials; the next reconnect attempt uses them without a reboot.
virtual void updateWifiCredentials(const char* wifi_ssid, const char* wifi_password) {
(void)wifi_ssid;
(void)wifi_password;
}
virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0;
// Automatic selectors are boot-owned so first-run services can use the
// chosen link before MQTT starts. Compatibility Wi-Fi builds keep the
// original MQTT-task-owned begin() path.
virtual bool isAutomatic() const { return false; }
virtual bool bootstrap(const char* wifi_ssid, const char* wifi_password,
uint32_t wait_ms) {
(void)wait_ms;
return begin(wifi_ssid, wifi_password);
}
// WebConfig and OTA pin the current route for the lifetime of their session.
virtual void lockSwitching() {}
virtual void unlockSwitching() {}
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 void formatDiagnostics(char* reply, size_t reply_size) 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;
// Fault reporting normally follows the selected interface. Automatic
// Ethernet-preferred builds instead keep reporting the primary Ethernet
// outage while healthy Wi-Fi carries traffic, so prolonged degradation is
// not hidden by a successful fallback.
virtual NetworkMedium alertMedium() const { return medium(); }
virtual AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const {
return outageSnapshot();
}
};
/** Build-selected singleton. Wi-Fi is the compatibility default. */
NetworkLink& activeNetworkLink();
#endif
+225
View File
@@ -0,0 +1,225 @@
#pragma once
#include <stdint.h>
enum class NetworkTransition : uint8_t {
None,
Up,
Down,
Switched,
};
enum class NetworkMedium : uint8_t {
None,
Ethernet,
WiFi,
};
enum class NetworkDiagnosticReason : uint8_t {
EthernetActive,
EthernetInitFailed,
EthernetLinkUnknown,
EthernetLinkDown,
EthernetAwaitingIp,
EthernetStabilizing,
SwitchingLocked,
EthernetReady,
};
namespace NetworkPolicy {
struct MQTTTransitionActions {
bool disconnect_started_slots;
bool retry_disconnected_slots_now;
bool reset_reconnect_backoff;
};
static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) {
return {
transition == NetworkTransition::Down || transition == NetworkTransition::Switched,
transition == NetworkTransition::Up || transition == NetworkTransition::Switched,
transition == NetworkTransition::Switched,
};
}
static constexpr const char* mediumName(NetworkMedium medium) {
return medium == NetworkMedium::Ethernet ? "ethernet"
: medium == NetworkMedium::WiFi ? "wifi"
: "none";
}
struct AutomaticSelectionInput {
NetworkMedium selected;
bool ethernet_connected;
bool wifi_connected;
bool wifi_configured;
bool switching_locked;
uint32_t ethernet_stable_ms;
uint32_t selected_down_ms;
};
// Keep short link flaps from bouncing the default route. Ethernet is allowed
// to recover before Wi-Fi is started, and must then remain usable before it can
// preempt a working Wi-Fi fallback.
static constexpr uint32_t kEthernetDownGraceMs = 3000;
static constexpr uint32_t kEthernetFailbackStableMs = 10000;
static constexpr uint32_t kEthernetNoLinkBootGraceMs = 750;
static constexpr uint32_t kEthernetNoIpRecoveryMs = 120000;
static constexpr uint32_t kEthernetInitRetryMinMs = 5000;
static constexpr uint32_t kEthernetInitRetryMaxMs = 300000;
static constexpr uint32_t kNtpRetryMs = 30000;
static constexpr uint32_t kManualOtaSessionTimeoutMs =
15UL * 60UL * 1000UL;
static inline uint32_t ethernetInitRetryDelayMs(uint8_t attempt) {
if (attempt == 0) return 0;
uint32_t delay_ms = kEthernetInitRetryMinMs;
for (uint8_t i = 1; i < attempt && delay_ms < kEthernetInitRetryMaxMs; ++i) {
delay_ms = delay_ms > kEthernetInitRetryMaxMs / 2
? kEthernetInitRetryMaxMs : delay_ms * 2;
}
return delay_ms;
}
static inline bool ethernetInitRetryDue(uint8_t attempt,
uint32_t now_ms,
uint32_t last_attempt_ms) {
return attempt == 0 ||
(uint32_t)(now_ms - last_attempt_ms) >=
ethernetInitRetryDelayMs(attempt);
}
static constexpr bool ethernetNoIpRecoveryDue(bool initialized,
bool link_up,
bool connected,
uint32_t now_ms,
uint32_t no_ip_since_ms) {
return initialized && link_up && !connected && no_ip_since_ms != 0 &&
(uint32_t)(now_ms - no_ip_since_ms) >= kEthernetNoIpRecoveryMs;
}
static inline NetworkDiagnosticReason automaticDiagnosticReason(
bool ethernet_initialized, bool ethernet_link_known,
bool ethernet_link_up, bool ethernet_connected, NetworkMedium selected,
bool switching_locked, uint32_t ethernet_stable_ms) {
if (!ethernet_initialized) {
return NetworkDiagnosticReason::EthernetInitFailed;
}
if (!ethernet_link_known) {
return NetworkDiagnosticReason::EthernetLinkUnknown;
}
if (!ethernet_link_up) {
return NetworkDiagnosticReason::EthernetLinkDown;
}
if (!ethernet_connected) {
return NetworkDiagnosticReason::EthernetAwaitingIp;
}
if (selected == NetworkMedium::Ethernet) {
return NetworkDiagnosticReason::EthernetActive;
}
if (switching_locked) {
return NetworkDiagnosticReason::SwitchingLocked;
}
if (selected == NetworkMedium::WiFi &&
ethernet_stable_ms < kEthernetFailbackStableMs) {
return NetworkDiagnosticReason::EthernetStabilizing;
}
return NetworkDiagnosticReason::EthernetReady;
}
static inline const char* diagnosticReasonName(
NetworkDiagnosticReason reason) {
switch (reason) {
case NetworkDiagnosticReason::EthernetActive:
return "ethernet-active";
case NetworkDiagnosticReason::EthernetInitFailed:
return "ethernet-init-failed";
case NetworkDiagnosticReason::EthernetLinkUnknown:
return "ethernet-link-unknown";
case NetworkDiagnosticReason::EthernetLinkDown:
return "ethernet-link-down";
case NetworkDiagnosticReason::EthernetAwaitingIp:
return "ethernet-awaiting-ip";
case NetworkDiagnosticReason::EthernetStabilizing:
return "ethernet-stabilizing";
case NetworkDiagnosticReason::SwitchingLocked:
return "switching-locked";
case NetworkDiagnosticReason::EthernetReady:
return "ethernet-ready";
}
return "unknown";
}
static constexpr bool ntpPendingAfterConnectivitySample(
bool synced, bool pending, bool was_connected, bool connected) {
return pending || (!synced && connected && !was_connected);
}
static constexpr bool ntpRetryDue(bool synced, bool connected,
uint32_t now_ms, uint32_t last_attempt_ms) {
return !synced && connected &&
(uint32_t)(now_ms - last_attempt_ms) >= kNtpRetryMs;
}
static constexpr NetworkMedium bootSelection(bool ethernet_connected,
bool wifi_configured) {
return ethernet_connected ? NetworkMedium::Ethernet
: wifi_configured ? NetworkMedium::WiFi
: NetworkMedium::None;
}
// Give the PHY a short window to report carrier. Once a definitive link-down
// sample arrives, do not stall mesh startup for the full DHCP deadline. A
// present link (or an unknown PHY state) still receives the entire probe.
static constexpr bool ethernetBootProbePending(bool ethernet_initialized,
bool ethernet_connected,
bool link_known,
bool link_up,
uint32_t elapsed_ms,
uint32_t wait_ms) {
return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms &&
(!link_known || link_up || elapsed_ms < kEthernetNoLinkBootGraceMs);
}
static inline NetworkMedium automaticSelection(
const AutomaticSelectionInput& input) {
if (input.switching_locked) return input.selected;
if (input.selected == NetworkMedium::Ethernet) {
if (input.ethernet_connected) return NetworkMedium::Ethernet;
if (input.wifi_configured && input.wifi_connected &&
input.selected_down_ms >= kEthernetDownGraceMs) {
return NetworkMedium::WiFi;
}
return NetworkMedium::Ethernet;
}
if (input.selected == NetworkMedium::WiFi) {
if (input.ethernet_connected &&
(!input.wifi_connected ||
input.ethernet_stable_ms >= kEthernetFailbackStableMs)) {
return NetworkMedium::Ethernet;
}
return NetworkMedium::WiFi;
}
if (input.ethernet_connected) return NetworkMedium::Ethernet;
if (input.wifi_configured && input.wifi_connected) return NetworkMedium::WiFi;
return NetworkMedium::None;
}
// `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;
}
static constexpr bool manualOtaTimeoutDue(uint32_t now_ms,
uint32_t started_ms,
bool upload_in_progress) {
return !upload_in_progress &&
(uint32_t)(now_ms - started_ms) >= kManualOtaSessionTimeoutMs;
}
} // namespace NetworkPolicy
+6 -5
View File
@@ -1,7 +1,9 @@
#ifdef WITH_SNMP
#include "SNMPAgent.h"
#include "NetworkLink.h"
#include <esp_heap_caps.h>
#include <climits>
#define SNMP_PORT 161
@@ -15,7 +17,7 @@ MeshSNMPAgent::MeshSNMPAgent()
_total_air_time_secs(0),
_mqtt_connected_slots(0), _mqtt_queue_depth(0), _mqtt_skipped_publishes(0),
_free_heap(0), _max_alloc(0), _internal_free(0), _psram_free(0),
_wifi_rssi(0)
_wifi_rssi(-127)
{
_firmware_version[0] = '\0';
_node_name[0] = '\0';
@@ -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 = activeNetworkLink().rssi();
_wifi_rssi = signal == INT_MIN ? -127 : 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 -127 when disconnected or the medium has no RSSI)
class MeshSNMPAgent {
public:
+157 -217
View File
@@ -107,16 +107,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 && activeNetworkLink().configValid(obs->wifi_ssid);
}
#ifdef WITH_MQTT_BRIDGE
@@ -129,7 +121,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;
@@ -217,17 +209,11 @@ 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;
// Latched by end() when a stop passes its deadline unacknowledged. end() clears
// the diagnostic singleton, so without this `get mqtt.status` could only say
// "not running" and an operator would have no way to learn that the bridge is
// down for the rest of the boot and why.
static bool s_stop_unproven = false;
static unsigned long s_wifi_disconnect_time = 0;
#ifdef MQTT_MEMORY_DEBUG
// #region agent log
@@ -248,7 +234,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 activeNetworkLink().connectedAtMillis();
}
#if defined(WITH_MQTT_NEIGHBORS)
@@ -441,27 +427,6 @@ int MQTTBridge::getMaxActiveSlots() {
#endif
}
// One mapping for startup, reconnect and CLI (see WifiPowerSavePolicy). The
// stored default is `none`; `min` means MIN_MODEM here exactly as the CLI says
// it does.
void MQTTBridge::applyWifiPowerSave() {
#ifdef ESP_PLATFORM
static_assert((int)WifiPowerSavePolicy::kModeNone == (int)WIFI_PS_NONE, "wifi_ps_type_t drift");
static_assert((int)WifiPowerSavePolicy::kModeMinModem == (int)WIFI_PS_MIN_MODEM, "wifi_ps_type_t drift");
static_assert((int)WifiPowerSavePolicy::kModeMaxModem == (int)WIFI_PS_MAX_MODEM, "wifi_ps_type_t drift");
if (!_obs) return;
const uint8_t stored = _obs->wifi_power_save;
esp_wifi_set_ps((wifi_ps_type_t)WifiPowerSavePolicy::modeFor(stored));
// Read back rather than logging what we asked for: this is the only place the
// mode is observable on a running node, and the setting used to change
// meaning between the CLI and this path.
wifi_ps_type_t applied = WIFI_PS_NONE;
esp_wifi_get_ps(&applied);
MQTT_DEBUG_PRINTLN("WiFi power save: %s (mode=%d)",
WifiPowerSavePolicy::nameFor(stored), (int)applied);
#endif
}
bool MQTTBridge::stopUnprovenLatched() { return s_stop_unproven; }
bool MQTTBridge::stopAcknowledgedLate() const {
@@ -472,8 +437,12 @@ bool MQTTBridge::stopAcknowledgedLate() const {
#endif
}
uint8_t MQTTBridge::getLastWifiDisconnectReason() { return s_wifi_disconnect_reason; }
unsigned long MQTTBridge::getLastWifiDisconnectTime() { return s_wifi_disconnect_time; }
uint8_t MQTTBridge::getLastWifiDisconnectReason() {
return activeNetworkLink().lastDisconnectReason();
}
unsigned long MQTTBridge::getLastWifiDisconnectTime() {
return activeNetworkLink().lastDisconnectTime();
}
unsigned long MQTTBridge::getSlotCurrentOutageStartMs(int slot_index) const {
if (slot_index < 0 || slot_index >= RUNTIME_MQTT_SLOTS) return 0;
@@ -706,6 +675,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(&activeNetworkLink()),
_queue_count(0),
_last_status_publish(0), _last_status_retry(0), _status_interval(300000),
_last_ntp_sync(0), _ntp_synced(false), _ntp_sync_pending(false), _slots_setup_done(false), _max_active_slots(RUNTIME_MQTT_SLOTS),
@@ -732,8 +702,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),
@@ -944,9 +912,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;
}
@@ -1131,11 +1100,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
@@ -1348,79 +1314,41 @@ 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));
// Treat the first post-settle sample as an edge. A fast Wi-Fi association
// may complete during the delay above, before the task has sampled state;
// initializing from the live value would then defer NTP until its retry.
bool network_was_connected = false;
unsigned long last_ntp_attempt = 0;
// Main task loop
#ifdef MQTT_MEMORY_DEBUG
static unsigned long last_agent_log = 0;
@@ -1500,30 +1428,28 @@ 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
// 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) {
_slots[i].last_reconnect_attempt = 0;
}
}
}
handleNetworkConnection(now);
// Check for pending NTP sync (triggered from WiFi event handler)
if (_ntp_sync_pending && WiFi.status() == WL_CONNECTED) {
// Schedule once per link-up edge. Failed syncs are owned by the 30-second
// retry below; re-arming here on every loop would make a blocked NTP path
// run the multi-second sync sequence back-to-back forever.
const bool network_connected = _network->isConnected();
_ntp_sync_pending = NetworkPolicy::ntpPendingAfterConnectivitySample(
_ntp_synced, _ntp_sync_pending, network_was_connected,
network_connected);
network_was_connected = network_connected;
if (_ntp_sync_pending && network_connected) {
_ntp_sync_pending = false;
last_ntp_attempt = now;
syncTimeWithNTP();
}
// Retry NTP every 30s if initial sync failed (slots can't start without valid time)
if (!_ntp_synced && WiFi.status() == WL_CONNECTED) {
static unsigned long last_ntp_retry = 0;
if (now - last_ntp_retry >= 30000) {
last_ntp_retry = now;
syncTimeWithNTP();
}
if (NetworkPolicy::ntpRetryDue(
_ntp_synced, network_connected, (uint32_t)now,
(uint32_t)last_ntp_attempt)) {
last_ntp_attempt = now;
syncTimeWithNTP();
}
// Process a CLI-requested forced NTP sync (queued from Core 1). Running it here
@@ -1633,7 +1559,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);
}
@@ -1655,7 +1581,7 @@ void MQTTBridge::mqttTaskLoop() {
// Periodic NTP refresh (every hour): a validated probe, one attempt per
// server. Blocks this task for about one probe timeout per server that does
// not answer (see refreshNTP()).
if (WiFi.status() == WL_CONNECTED && now - _last_ntp_sync > 3600000) {
if (_network->isConnected() && now - _last_ntp_sync > 3600000) {
refreshNTP();
}
@@ -2403,6 +2329,56 @@ void MQTTBridge::closeLiveClientForReconfigure(int index) {
slot.client_state = ClientState::Disconnected;
}
// The old route is already gone, so a link transition waits far less for the
// DISCONNECTED event than an ordinary teardown does.
static constexpr unsigned long kNetworkTransitionDisconnectMs = 2000;
// Close a slot's transport because the route under it changed.
//
// Deliberately NOT the reconfigure path, even though both close a live client.
// A reconfigure gives the slot a new endpoint and new credentials, so an
// in-flight attempt that completes afterwards reports a connection to the OLD
// broker under the new configuration, and cancelling it is worth a real stop
// (F04). A link transition changes neither: the attempt is aimed at the same
// broker with the same credentials, so if it completes it is credited to the
// endpoint it actually reached. It is stale only in that its socket sits on a
// route that is going away, and the ordinary DISCONNECTED path handles that.
//
// So an attempt in flight is left alone here. Stopping it would mean calling
// esp_mqtt_client_stop(), which waits on the SDK's API mutex and its task's
// stopped event with no bound (see stopSlotClient()) — the client task notices
// only when it returns from whatever transport call it is in, up to
// network_timeout_ms for a TLS connect. During teardown that is contained by
// the bridge's StopUnproven timeout; during a transition nothing contains it,
// so paying it serially for up to five slots would freeze the sole MQTT worker
// far past the bound this path advertises, and would block the bridge's own
// stop handshake behind it. A routine Wi-Fi flap must not cost that.
//
// A connected client takes the cheap bounded path, and the esp-mqtt task stays,
// which is what keeps a flap from recreating every slot's task and
// refragmenting internal heap. Nothing here touches a client that is not live,
// so a Quarantined one (its SDK task was never joined) is left alone, as it
// must be for the rest of the boot.
void MQTTBridge::closeLiveClientForLinkTransition(int index) {
if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return;
MQTTSlot& slot = _slots[index];
if (slot.client == nullptr || !clientStateIsLive(slot.client_state)) return;
if (mqttClientStateHasAttemptInFlight(slot.client_state)) {
MQTT_DEBUG_PRINTLN("MQTT%d link transition during connect - leaving the attempt to resolve",
index + 1);
return;
}
MQTT_DEBUG_PRINTLN("MQTT%d disconnecting for link transition", index + 1);
const esp_err_t r = slot.client->softDisconnect(kNetworkTransitionDisconnectMs);
if (r != ESP_OK) {
MQTT_DEBUG_PRINTLN("MQTT%d link transition: transport did not close cleanly (%s)",
index + 1, esp_err_to_name(r));
}
slot.client_state = ClientState::Disconnected;
}
// Stop a live client and record whether the stop was proven. ESP_ERR_TIMEOUT
// means no DISCONNECTED event arrived but esp_mqtt_client_stop() itself
// returned OK, so the SDK task is joined and the object is safe to reuse; any
@@ -2490,10 +2466,8 @@ void MQTTBridge::teardownSlot(int index, TeardownReason reason) {
// A stopped client needs connect(): reconnect() is a documented no-op on one, so reaching
// it here would strand the slot. The producer is a failed esp_mqtt_client_start(), which
// leaves _started false while initial_connect_done stays set. Not the WiFi-drop teardown,
// which only stops slots still marked connected: a publishing slot's socket fails first, so
// the guard skips it — measured across a 62 s deauth, five slots, zero stops. An idle slot
// with no traffic to fail on is the one case that could still reach here that way.
// leaves _started false while initial_connect_done stays set. Not a link transition,
// which only disconnects the session and keeps the client task.
esp_err_t MQTTBridge::reconnectSlotClient(int index) {
if (index < 0 || index >= RUNTIME_MQTT_SLOTS) return ESP_ERR_INVALID_ARG;
MQTTSlot& slot = _slots[index];
@@ -2527,7 +2501,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);
@@ -3322,95 +3296,60 @@ void MQTTBridge::checkConfigurationMismatch() {
}
}
bool MQTTBridge::handleWiFiConnection(unsigned long now) {
wl_status_t current_wifi_status = WiFi.status();
bool transitioned_to_connected = false;
bool MQTTBridge::handleNetworkConnection(unsigned long now) {
const NetworkMedium previous_medium = _network->medium();
_network->updateWifiCredentials(_obs->wifi_ssid, _obs->wifi_password);
const NetworkTransition transition =
_network->maintain((uint32_t)now, _obs->wifi_power_save);
const NetworkMedium selected_medium = _network->medium();
if (current_wifi_status == WL_CONNECTED && s_wifi_connected_at == 0) {
s_wifi_connected_at = now;
if (transition == NetworkTransition::Down) {
MQTT_DEBUG_PRINTLN("Network: %s link down", NetworkPolicy::mediumName(previous_medium));
} else if (transition == NetworkTransition::Up) {
MQTT_DEBUG_PRINTLN("Network: %s link up (%s, IP %s)",
NetworkPolicy::mediumName(selected_medium),
_network->statusName(), _network->localIP().toString().c_str());
} else if (transition == NetworkTransition::Switched) {
MQTT_DEBUG_PRINTLN("Network: switched %s -> %s (%s, IP %s)",
NetworkPolicy::mediumName(previous_medium),
NetworkPolicy::mediumName(selected_medium),
_network->statusName(), _network->localIP().toString().c_str());
}
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));
#ifdef ESP_PLATFORM
// Already associated at bridge start (end()/begin() leaves STA up): there is
// no connect transition below to carry the setting, so apply it here or the
// node runs on whatever the previous mode was.
if (current_wifi_status == WL_CONNECTED) applyWifiPowerSave();
#endif
}
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));
}
}
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
applyWifiPowerSave();
#ifdef MQTT_WIFI_TX_POWER
WiFi.setTxPower(MQTT_WIFI_TX_POWER);
#else
WiFi.setTxPower(WIFI_POWER_11dBm);
#endif
#endif
const NetworkPolicy::MQTTTransitionActions actions =
NetworkPolicy::mqttActions(transition);
if (actions.disconnect_started_slots) {
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
closeLiveClientForLinkTransition(i);
_slots[i].connected = false;
_slots[i].connected_at_ms = 0;
}
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();
updateCachedConnectionStatus();
}
if (actions.retry_disconnected_slots_now) {
// Link recovery gets one immediate attempt without forgiving broker
// failures earned on the same route. A medium switch is a new connection
// epoch, so only that transition clears the old route's ladder/breaker.
for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) {
if (_slots[i].enabled && _slots[i].initial_connect_done && !_slots[i].connected) {
if (actions.reset_reconnect_backoff) {
_slots[i].reconnect_backoff = 0;
_slots[i].max_backoff_failures = 0;
_slots[i].circuit_breaker_tripped = false;
}
}
} 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);
_slots[i].last_reconnect_attempt = MQTTConnectionPolicy::immediateRetryLastAttempt(
static_cast<uint32_t>(now), _slots[i].circuit_breaker_tripped,
_slots[i].reconnect_backoff, static_cast<uint8_t>(i));
}
}
_last_wifi_status = current_wifi_status;
_last_slot_reconnect_ms = now - MQTTConnectionPolicy::kReconnectGuardMs;
}
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 {
@@ -3470,10 +3409,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();
}
@@ -3513,7 +3452,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();
}
@@ -4531,8 +4470,9 @@ bool MQTTBridge::probeNtpServer(const char* server, uint32_t min_epoch,
// previous destination when a name fails to resolve, so sending anyway asks
// whichever server resolved last and credits its genuine reply to this name.
// Observed on d4: `set mqtt.ntp bogus.invalid` reported success with a correct
// epoch, answered by the pool address left over from boot.
if (!WiFi.hostByName(server, expected_ip)) {
// epoch, answered by the pool address left over from boot. Resolution goes
// through the link so it follows whichever esp_netif is selected.
if (!_network->resolveHost(server, expected_ip)) {
if (why) *why = kNtpDnsFailedReason;
return false;
}
@@ -4606,8 +4546,8 @@ bool MQTTBridge::probeNtpServer(const char* server, uint32_t min_epoch,
}
bool MQTTBridge::syncTimeWithNTP(bool force, bool primary_only, int attempts_per_server) {
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;
}
+19 -29
View File
@@ -2,6 +2,7 @@
#include "MeshCore.h"
#include "helpers/bridges/BridgeBase.h"
#include "helpers/NetworkLink.h"
#include <PsychicMqttClient.h>
#include <WiFi.h>
#include <WiFiUdp.h>
@@ -262,11 +263,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)
@@ -460,25 +456,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
@@ -541,6 +520,9 @@ private:
// Close a live client for a reconfigure: softDisconnect where that is enough,
// a real stop where an in-flight connection attempt has to be cancelled.
void closeLiveClientForReconfigure(int index);
// Close a live client because the route changed under it: bounded where that
// is enough, a real stop where an in-flight attempt has to be cancelled.
void closeLiveClientForLinkTransition(int index);
// Stop a live client, recording Stopped (proven) or Quarantined (not joined).
void stopSlotClient(int index);
// Reconnect a slot, starting it instead when the client is stopped (reconnect() is a
@@ -563,13 +545,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,
@@ -601,7 +583,6 @@ private:
void getClientVersion(char* buffer, size_t buffer_size) const;
void logMemoryStatus();
void refreshOriginFromPrefs();
void applyWifiPowerSave(); // one mapping, applied on every association
// begin()/end()-scoped PSRAM buffers. Each allocation is independent so a
// transient heap shortage degrades to the existing stack fallback instead
// of making the bridge unusable.
@@ -632,6 +613,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;
NetworkLink* _network = nullptr;
public:
MQTTBridge(NodePrefs *prefs, MQTTPrefs *obs, mesh::PacketManager *mgr, mesh::RTCClock *rtc, mesh::LocalIdentity *identity);
@@ -753,13 +735,21 @@ public:
static unsigned long getWifiConnectedAtMillis();
/**
* Current WiFi outage snapshot for AlertReporter: down, started_ms, and the
* initiating disconnect reason. Distinct from getLastWifiDisconnectTime() /
* Current alert-network outage snapshot for AlertReporter: down, started_ms,
* and the initiating disconnect reason. Ethernet-preferred builds continue
* tracking Ethernet after a Wi-Fi fallback. 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 getNetworkOutageSnapshot() const {
return _network ? _network->alertOutageSnapshot()
: AlertFaultPolicy::OutageSnapshot{false, 0, 0};
}
NetworkMedium getNetworkAlertMedium() const {
return _network ? _network->alertMedium() : NetworkMedium::None;
}
/**
* Per-slot outage accessors used by AlertReporter to detect prolonged
@@ -821,7 +811,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();
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <atomic>
#include <stdint.h>
// Ownership guard for the two AsyncWebServer users. It prevents `start ota`
// from claiming port 80 while WebConfig is listening (and vice versa), even
// when the requests arrive from different tasks.
namespace HttpPort80Lease {
enum class Owner : uint8_t { None = 0, WebConfig, Ota };
inline std::atomic<Owner>& current() {
static std::atomic<Owner> owner{Owner::None};
return owner;
}
inline bool acquire(Owner requested) {
if (requested == Owner::None) return false;
Owner expected = Owner::None;
return current().compare_exchange_strong(
expected, requested, std::memory_order_acq_rel,
std::memory_order_relaxed);
}
inline void release(Owner expected) {
current().compare_exchange_strong(
expected, Owner::None, std::memory_order_acq_rel,
std::memory_order_relaxed);
}
inline const char* ownerName() {
switch (current().load(std::memory_order_acquire)) {
case Owner::WebConfig: return "webconfig";
case Owner::Ota: return "ota";
default: return "none";
}
}
} // namespace HttpPort80Lease
+179 -20
View File
@@ -7,22 +7,71 @@
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <esp_random.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <SHA256.h>
#include <helpers/CommonCLI.h>
#include <helpers/NetworkLink.h>
#include <helpers/MQTTPacketFilter.h>
#include <helpers/MQTTPresets.h>
#include <helpers/WebConfigKeys.h>
#include <helpers/WifiPowerSavePolicy.h>
#include <helpers/bridges/MQTTBridge.h>
#include "HttpPort80Lease.h"
#include "WebConfigHtml.h"
// Placeholder sent instead of stored secrets; POSTs carrying it are dropped
// so an untouched password field never overwrites the stored value.
static const char SECRET_SENTINEL[] = "********";
// The ESP hardware RNG has a continuous entropy source only while an RF block
// is running. Ethernet LAN mode deliberately keeps Wi-Fi and Bluetooth down,
// so briefly start an unassociated station while creating login secrets. The
// bootloader RNG helper is intentionally not used here: ESP-IDF limits it to
// early boot, before normal RF/ADC/I2S operation begins.
class WebConfigEntropyGuard {
wifi_mode_t _previous_mode;
bool _started_wifi;
bool _ready;
public:
WebConfigEntropyGuard()
: _previous_mode(WiFi.getMode()), _started_wifi(false), _ready(true) {
if (_previous_mode == WIFI_MODE_NULL) {
_ready = WiFi.mode(WIFI_MODE_STA);
_started_wifi = _ready;
}
}
~WebConfigEntropyGuard() {
if (_started_wifi) WiFi.mode(_previous_mode);
}
bool ready() const { return _ready; }
};
static bool fillRandomBytes(uint8_t* output, size_t byte_count) {
if (!output || byte_count == 0) return false;
WebConfigEntropyGuard entropy;
if (!entropy.ready()) return false;
esp_fill_random(output, byte_count);
return true;
}
static void bytesToHex(char* output, const uint8_t* bytes, size_t byte_count) {
static const char HEX_DIGITS[] = "0123456789ABCDEF";
for (size_t i = 0; i < byte_count; ++i) {
output[i * 2] = HEX_DIGITS[bytes[i] >> 4];
output[i * 2 + 1] = HEX_DIGITS[bytes[i] & 0x0f];
}
output[byte_count * 2] = '\0';
}
// Key classification (allowlist, secret detection, slot-prefix parsing) lives in
// helpers/WebConfigKeys.h so it can be unit-tested on the host. Thin aliases keep
// the call sites below readable.
@@ -173,17 +222,28 @@ bool WebConfigServer::isRebootPending() {
bool WebConfigServer::getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len) {
WebConfigServer* w = _active;
if (w == NULL || w->_mode != MODE_SETUP || w->_stopping) return false;
if (w == NULL || w->_stopping ||
(w->_mode != MODE_SETUP && !(w->_mode == MODE_LAN && w->_initial_setup))) {
return false;
}
if (ssid && ssid_len > 0) {
strncpy(ssid, w->_ap_ssid, ssid_len - 1);
const char* label = w->_mode == MODE_LAN ? w->_setup_code : w->_ap_ssid;
strncpy(ssid, label, ssid_len - 1);
ssid[ssid_len - 1] = 0;
}
if (ip && ip_len > 0) {
snprintf(ip, ip_len, "%s", WiFi.softAPIP().toString().c_str());
const IPAddress address = w->_mode == MODE_LAN
? activeNetworkLink().localIP() : WiFi.softAPIP();
snprintf(ip, ip_len, "%s", address.toString().c_str());
}
return true;
}
bool WebConfigServer::isLanSetup() {
WebConfigServer* w = _active;
return w != NULL && !w->_stopping && w->_initial_setup && w->_mode == MODE_LAN;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
@@ -193,6 +253,11 @@ bool WebConfigServer::startSetupMode(char reply[]) {
strcpy(reply, "Err: webconfig busy");
return false;
}
if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::WebConfig)) {
snprintf(reply, 160, "Err: port 80 is in use by %s",
HttpPort80Lease::ownerName());
return false;
}
// AP_STA (not pure AP) so the WiFi scan for the SSID picker works while
// the AP is up. STA stays unconnected - the bridge won't touch WiFi
// while wifi_ssid is empty, and `start webconfig ap` requires it stopped.
@@ -214,6 +279,7 @@ bool WebConfigServer::startSetupMode(char reply[]) {
#endif
if (!ap_ok) {
WiFi.mode(WIFI_OFF);
HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig);
strcpy(reply, "Err: failed to start AP");
return false;
}
@@ -224,8 +290,10 @@ bool WebConfigServer::startSetupMode(char reply[]) {
_dns->start(53, "*", ip); // captive portal: every name resolves to us
_mode = MODE_SETUP;
_initial_setup = (_obs->wifi_ssid[0] == 0);
_initial_setup = !mqttNetworkSetupComplete(_obs);
createServer();
activeNetworkLink().lockSwitching();
_network_locked = true;
_was_setup_ap = true;
_last_activity = millis();
WiFi.scanNetworks(true); // pre-populate the SSID picker
@@ -234,21 +302,59 @@ bool WebConfigServer::startSetupMode(char reply[]) {
return true;
}
bool WebConfigServer::startLanMode(char reply[]) {
bool WebConfigServer::startLanMode(bool initial_setup, char reply[]) {
if (_mode != MODE_OFF || _stopping) {
strcpy(reply, "Err: webconfig busy");
return false;
}
if (WiFi.status() != WL_CONNECTED) {
strcpy(reply, "Err: WiFi not connected");
if (!HttpPort80Lease::acquire(HttpPort80Lease::Owner::WebConfig)) {
snprintf(reply, 160, "Err: port 80 is in use by %s",
HttpPort80Lease::ownerName());
return false;
}
activeNetworkLink().lockSwitching();
_network_locked = true;
// Read the address only once the route is pinned, so it names the locked link.
const IPAddress ip = activeNetworkLink().localIP();
if (!activeNetworkLink().isConnected() || ip == IPAddress()) {
activeNetworkLink().unlockSwitching();
_network_locked = false;
HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig);
strcpy(reply, "Err: selected network not connected");
return false;
}
_initial_setup = initial_setup;
uint8_t session_entropy[sizeof(_session_secret) + 6];
const size_t entropy_size = sizeof(_session_secret) + (_initial_setup ? 6 : 0);
if (!fillRandomBytes(session_entropy, entropy_size)) {
activeNetworkLink().unlockSwitching();
_network_locked = false;
HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig);
strcpy(reply, "Err: secure random source unavailable");
return false;
}
memcpy(_session_secret, session_entropy, sizeof(_session_secret));
_session_generation = 0;
if (_initial_setup) {
bytesToHex(_setup_code, session_entropy + sizeof(_session_secret), 6);
} else {
_setup_code[0] = 0;
}
memset(session_entropy, 0, sizeof(session_entropy));
_mode = MODE_LAN;
createServer();
_last_activity = millis();
int pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)",
WiFi.localIP().toString().c_str());
int pos;
if (_initial_setup) {
pos = sprintf(reply, "WebConfig Ethernet setup: http://%s/ code %s",
ip.toString().c_str(), _setup_code);
_setup_reminder_at = millis() + 60000;
if (_setup_reminder_at == 0) _setup_reminder_at = 1;
} else {
pos = sprintf(reply, "WebConfig started: http://%s/ (admin password login)",
ip.toString().c_str());
}
if (heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL) < 60 * 1024) {
sprintf(reply + pos, " WARN: low heap");
}
@@ -318,8 +424,8 @@ void WebConfigServer::finalizeTeardown() {
_dns = NULL;
if (_was_setup_ap) {
WiFi.softAPdisconnect(true);
// Nothing else owns WiFi when we raised the AP: either the node is
// unconfigured, or `start webconfig ap` required the bridge stopped.
// A Wi-Fi-selected bridge must be stopped before a forced AP. An
// Ethernet-selected bridge can remain up because it does not own the STA.
if (_obs->wifi_ssid[0] == 0) {
WiFi.mode(WIFI_OFF);
} else {
@@ -335,8 +441,17 @@ void WebConfigServer::finalizeTeardown() {
_batch_state = BATCH_IDLE;
_batch_next = 0;
_batch_reboot_armed = false;
memset(_session_secret, 0, sizeof(_session_secret));
_session_generation = 0;
_session_token[0] = 0;
_stats_json[0] = 0;
_setup_code[0] = 0;
_setup_reminder_at = 0;
if (_network_locked) {
activeNetworkLink().unlockSwitching();
_network_locked = false;
}
HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig);
if (_cb) _cb->onWebConfigStopped();
}
@@ -359,6 +474,15 @@ void WebConfigServer::tick(uint32_t now) {
}
if (_mode == MODE_OFF) return;
if (_mode == MODE_LAN && _initial_setup && _setup_reminder_at != 0 &&
(int32_t)(now - _setup_reminder_at) >= 0) {
Serial.printf("WC: Ethernet setup http://%s/ code %s\n",
activeNetworkLink().localIP().toString().c_str(),
_setup_code);
_setup_reminder_at = now + 60000;
if (_setup_reminder_at == 0) _setup_reminder_at = 1;
}
if (_dns) _dns->processNextRequest();
if (_batch_state == BATCH_PENDING) drainBatch(now);
@@ -460,6 +584,15 @@ void WebConfigServer::drainBatch(uint32_t now) {
return; // more commands next tick
}
}
// Wi-Fi onboarding is recorded by the stored SSID; only Ethernet LAN setup needs the marker.
if (_initial_setup && _admin_pwd_set && _batch_all_ok && _mode == MODE_LAN) {
if (_cb->onInitialSetupComplete()) {
_initial_setup = false;
_setup_code[0] = 0;
} else {
_batch_all_ok = false;
}
}
_cb->onConfigBatchEnd();
WCLock lock(_mux);
_batch_state = BATCH_DONE;
@@ -573,13 +706,14 @@ bool WebConfigServer::checkAuth(AsyncWebServerRequest* req) {
_last_activity = millis();
if (_mode == MODE_SETUP) return true; // physical proximity implied, nothing configured
if (_mode != MODE_LAN) return false;
if (_session_token[0] == 0) return false;
if (!req->hasHeader("Cookie")) return false;
const String& cookies = req->getHeader("Cookie")->value();
int idx = cookies.indexOf("wcs=");
if (idx < 0 || (int)cookies.length() < idx + 4 + 32) return false;
String token = cookies.substring(idx + 4, idx + 4 + 32);
uint32_t now = millis();
WCLock lock(_mux);
if (_session_token[0] == 0) return false;
if ((uint32_t)(now - _session_last_seen) > WEBCONFIG_SESSION_TTL_MS) return false;
if (!fixedTimeEquals(token.c_str(), _session_token, 32)) return false;
_session_last_seen = now; // sliding expiry
@@ -612,7 +746,7 @@ void WebConfigServer::handleStatus(AsyncWebServerRequest* req) {
DynamicJsonDocument doc(512);
doc["mode"] = (_mode == MODE_SETUP) ? "setup" : "lan";
doc["auth"] = authed;
doc["needs_setup"] = (_obs->wifi_ssid[0] == 0);
doc["needs_setup"] = !mqttNetworkSetupComplete(_obs);
doc["name"] = (const char*)_prefs->node_name;
char node_id[17];
for (int i = 0; i < 8; i++) sprintf(&node_id[i * 2], "%02x", _pub_key[i]);
@@ -658,7 +792,10 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
return;
}
const char* pwd = doc["password"] | "";
if (!fixedTimeEquals(pwd, _prefs->password, sizeof(_prefs->password))) {
const char* expected = _initial_setup ? _setup_code : _prefs->password;
const size_t expected_size = _initial_setup ? sizeof(_setup_code)
: sizeof(_prefs->password);
if (!fixedTimeEquals(pwd, expected, expected_size)) {
if (++_login_fails >= 5) {
_login_lock_until = now + 30000;
if (_login_lock_until == 0) _login_lock_until = 1;
@@ -669,19 +806,41 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
}
_login_fails = 0;
_login_lock_until = 0;
for (int i = 0; i < 4; i++) sprintf(&_session_token[i * 8], "%08lx", (unsigned long)esp_random());
_session_last_seen = now;
uint32_t generation;
{
WCLock lock(_mux);
generation = ++_session_generation;
if (generation == 0) generation = ++_session_generation;
}
uint8_t token_bytes[16];
SHA256 token_hmac;
token_hmac.resetHMAC(_session_secret, sizeof(_session_secret));
token_hmac.update(reinterpret_cast<const uint8_t*>(&generation),
sizeof(generation));
token_hmac.finalizeHMAC(_session_secret, sizeof(_session_secret),
token_bytes, sizeof(token_bytes));
char session_token[33];
bytesToHex(session_token, token_bytes, sizeof(token_bytes));
memset(token_bytes, 0, sizeof(token_bytes));
{
WCLock lock(_mux);
memcpy(_session_token, session_token, sizeof(_session_token));
_session_last_seen = now;
}
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
char cookie[80];
sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", _session_token);
sprintf(cookie, "wcs=%s; HttpOnly; SameSite=Lax; Path=/", session_token);
res->addHeader("Set-Cookie", cookie);
req->send(res);
}
void WebConfigServer::handleLogout(AsyncWebServerRequest* req) {
if (_mode == MODE_OFF) { req->send(503); return; }
_session_token[0] = 0;
{
WCLock lock(_mux);
_session_token[0] = 0;
}
AsyncWebServerResponse* res = req->beginResponse(200, "application/json", "{\"ok\":true}");
res->addHeader("Set-Cookie", "wcs=; Max-Age=0; Path=/");
req->send(res);
@@ -836,7 +995,7 @@ void WebConfigServer::handleConfigPost(AsyncWebServerRequest* req) {
// First onboarding is not complete until the known factory password has
// been replaced. Enforce this server-side so the Advanced editor or a crafted
// request cannot save WiFi and strand the node with the default password.
if (_mode == MODE_SETUP && _initial_setup && !set.containsKey("password") &&
if (_initial_setup && !set.containsKey("password") &&
(reboot_after || set.containsKey("wifi.ssid"))) {
req->send(400, "application/json", "{\"error\":\"admin password required for initial setup\"}");
return;
@@ -1148,7 +1307,7 @@ void WebConfigServer::handleCliPost(AsyncWebServerRequest* req) {
// LAN still holding the factory password is a known credential on someone
// else's network. The terminal warned about this client-side, which is a
// reminder, not a rule — a pasted script or a direct POST ignored it.
if (_mode == MODE_SETUP && _initial_setup && !seq_sets_pwd && !_admin_pwd_set &&
if (_initial_setup && !seq_sets_pwd && !_admin_pwd_set &&
(defer_reboot || seq_sets_ssid)) {
req->send(400, "application/json",
"{\"error\":\"admin password required for initial setup — "
+13 -3
View File
@@ -6,8 +6,9 @@
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
// when no WiFi is configured (wifi_ssid empty), or manually via
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
// - LAN: bound to the existing STA connection (owned by the MQTT bridge
// task), started via `start webconfig`, admin-password login required.
// - LAN: bound to the selected network, started via `start webconfig`,
// admin-password login required. First-run Ethernet uses a random one-time
// login code and requires the operator to replace the admin password.
//
// Concurrency model: AsyncWebServer handlers run on the async_tcp task and
// must never touch the CLI, prefs persistence, or the radio. Config writes
@@ -57,6 +58,9 @@ public:
// `set` handlers can be coalesced into one.
virtual void onConfigBatchStart() {}
virtual void onConfigBatchEnd() {}
// Persist completion of Ethernet onboarding after its password-setting
// batch succeeds. Called from the loop task.
virtual bool onInitialSetupComplete() { return false; }
// Fill buf with the stats JSON snapshot. Called from tick() (loop task).
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
// Teardown finished (session + DNS freed, WiFi mode restored).
@@ -72,6 +76,7 @@ public:
// Fills the AP SSID and portal IP; either buffer may be NULL to just poll.
// Call from the loop task only (same task that changes the mode).
static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len);
static bool isLanSetup();
// For the device display: true once a config save completed and the node is
// about to reboot — ground truth for the user even if the browser lost its
@@ -79,7 +84,7 @@ public:
static bool isRebootPending();
bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal
bool startLanMode(char reply[]); // bind to existing STA connection
bool startLanMode(bool initial_setup, char reply[]); // bind to the selected network
void requestStop(); // stop listening and detach this session
void tick(uint32_t now); // call every loop iteration
@@ -136,11 +141,13 @@ private:
bool _stopping = false;
bool _was_setup_ap = false;
bool _initial_setup = false;
bool _network_locked = false;
// A `password` command has succeeded this session. Lets the CLI satisfy the
// initial-setup invariant across separate submissions; the form batch always
// sends the password with the rest, so it never needed the memory.
bool _admin_pwd_set = false;
char _ap_ssid[33] = {0};
char _setup_code[13] = {0};
// Currently attached session, also used by the display's setup-info poll.
static WebConfigServer* _active;
@@ -164,8 +171,11 @@ private:
BatchEntry _batch[MAX_BATCH];
// LAN-mode session (single slot; new login evicts the old session)
uint8_t _session_secret[32] = {0};
uint32_t _session_generation = 0;
char _session_token[33] = {0};
uint32_t _session_last_seen = 0;
uint32_t _setup_reminder_at = 0;
uint8_t _login_fails = 0;
uint32_t _login_lock_until = 0;
+36
View File
@@ -0,0 +1,36 @@
#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(const char* hostname = nullptr) {
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 (hostname && hostname[0] != '\0') {
if (!CH390.setHostname(hostname)) {
Serial.printf("Network: invalid Ethernet hostname %s\n", hostname);
}
}
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);
+2
View File
@@ -29,6 +29,8 @@ 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_network_hostname` | `src/helpers/NetworkHostname.h` | DHCP-safe `meshcore-` hostname generation; invalid-character collapse; empty fallback; exact length boundary; stable identity suffix on truncation |
| `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);
@@ -75,6 +75,35 @@ TEST(MqttClientState, StopIsAcknowledgedOnlyWhenEveryClientIsProvenStopped) {
EXPECT_FALSE(mqttStopMayBeAcknowledged(nullptr, 3));
}
// A link transition closes each slot's transport because the route under it
// changed. These two predicates are what decide which clients it may touch.
//
// The distinction from a reconfigure matters: a reconfigure must cancel an
// in-flight attempt, because that attempt would report the OLD endpoint under
// the new configuration. A transition changes no configuration, so the attempt
// is left to resolve — cancelling it would mean an unbounded
// esp_mqtt_client_stop() on the sole MQTT worker during a routine link flap.
TEST(MqttClientState, LinkTransitionOnlyDisconnectsClientsWithNoAttemptInFlight) {
for (MqttClientState s : kAllStates) {
const bool live = mqttClientStateIsLive(s);
const bool disconnected_here = live && !mqttClientStateHasAttemptInFlight(s);
// Quarantined is never touched: its SDK task was not joined, so it stays
// out of service for the rest of the boot.
if (s == MqttClientState::Quarantined) EXPECT_FALSE(live) << mqttClientStateName(s);
// Nothing that is already proven stopped is worth a disconnect.
if (mqttClientStateIsProvenStopped(s)) EXPECT_FALSE(live) << mqttClientStateName(s);
EXPECT_EQ(s == MqttClientState::Connected || s == MqttClientState::Disconnected,
disconnected_here) << mqttClientStateName(s);
// Starting is live, and still must not be disconnected here.
if (s == MqttClientState::Starting) {
EXPECT_TRUE(live);
EXPECT_FALSE(disconnected_here);
}
}
}
TEST(MqttClientState, EveryStateHasAName) {
for (MqttClientState s : kAllStates) {
ASSERT_NE(nullptr, mqttClientStateName(s));
@@ -96,6 +96,19 @@ TEST(MQTTConnectionPolicy, CircuitBreakerProbeHasExactThirtyMinuteBoundary) {
EXPECT_TRUE(Policy::circuitBreakerProbeDue(900000U, last));
}
TEST(MQTTConnectionPolicy, LinkRecoveryMakesCurrentBackoffDueImmediately) {
const uint32_t now = 1234U;
const uint32_t last = Policy::immediateRetryLastAttempt(now, false, 4, 2);
EXPECT_TRUE(Policy::reconnectDue(now, last, 4, 2));
EXPECT_FALSE(Policy::circuitBreakerProbeDue(now, last));
}
TEST(MQTTConnectionPolicy, LinkRecoveryMakesBreakerProbeDueWithoutClearingIt) {
const uint32_t now = 1234U;
const uint32_t last = Policy::immediateRetryLastAttempt(now, true, 5, 2);
EXPECT_TRUE(Policy::circuitBreakerProbeDue(now, last));
}
TEST(MQTTConnectionPolicy, JwtLifetimeUsesCappedPerSlotStagger) {
EXPECT_EQ(86400U, Policy::jwtLifetimeSecs(86400U, 0));
EXPECT_EQ(86100U, Policy::jwtLifetimeSecs(86400U, 1));
@@ -113,6 +113,7 @@ TEST(MQTTPrefsSerializer, RoundTripsEveryGroupAndNumericSlotKeys) {
source.snmp_enabled = 1;
source.radio_watchdog_minutes = 120;
source.alert_enabled = 1;
source.network_setup_complete = 1;
strcpy(source.alert_psk_hex, "0123456789abcdef0123456789abcdef");
strcpy(source.alert_hashtag, "#ops");
strcpy(source.alert_region, "PNW");
@@ -137,6 +138,19 @@ TEST(MQTTPrefsSerializer, RoundTripsEveryGroupAndNumericSlotKeys) {
EXPECT_EQ(0x8001, loaded.mqtt_slot_packet_filter[5]);
EXPECT_EQ(MQTT_NEIGHBORS_MAX_INTERVAL_MS, loaded.mqtt_neighbors_interval);
EXPECT_STREQ("PNW", loaded.alert_region);
EXPECT_EQ(1, loaded.network_setup_complete);
}
TEST(MQTTPrefsSerializer, NetworkOnboardingSupportsWifiAndEthernetOnlyInstalls) {
MQTTPrefs prefs = defaults();
EXPECT_FALSE(mqttNetworkSetupComplete(&prefs));
strcpy(prefs.wifi_ssid, "existing-install");
EXPECT_TRUE(mqttNetworkSetupComplete(&prefs));
prefs.wifi_ssid[0] = 0;
prefs.network_setup_complete = 1;
EXPECT_TRUE(mqttNetworkSetupComplete(&prefs));
}
TEST(MQTTPrefsSerializer, MissingOptionalKeysKeepDefaults) {
@@ -471,6 +485,20 @@ TEST(MQTTPrefsSerializer, UnknownGroupsAreIgnoredSoAppendedKeysAreDowngradeSafe)
EXPECT_EQ(45, prefs.display_timeout_secs);
}
TEST(MQTTPrefsSerializer, UnknownKeysInsideAKnownGroupAreDowngradeSafe) {
// Firmware that predates wifi.setup_complete sees it as an unknown key in a
// known group; the rest of the group must still load.
MQTTPrefs prefs = defaults();
InputStream input(
"{version:1,wifi:{ssid:\"home\",future_key:1,power_save:2}}");
MQTTPrefsSerializer serializer(&prefs);
ASSERT_TRUE(serializer.loadSerial(input));
bool repaired = false;
ASSERT_TRUE(serializer.apply(&repaired));
EXPECT_STREQ("home", prefs.wifi_ssid);
EXPECT_EQ(2, prefs.wifi_power_save);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -0,0 +1,139 @@
#include <gtest/gtest.h>
#include <cstring>
#include "helpers/NetworkHostname.h"
namespace {
const uint8_t kStableId[] = {0xab, 0xcd, 0x01};
void expectDhcpSafe(const char* hostname) {
ASSERT_NE(nullptr, hostname);
const size_t length = strlen(hostname);
EXPECT_GT(length, 0u);
EXPECT_LE(length, NetworkHostname::kMaxLength);
EXPECT_NE('-', hostname[0]);
EXPECT_NE('-', hostname[length - 1]);
for (size_t i = 0; i < length; ++i) {
const char ch = hostname[i];
EXPECT_TRUE((ch >= 'a' && ch <= 'z') ||
(ch >= '0' && ch <= '9') || ch == '-')
<< "invalid byte at " << i;
}
}
} // namespace
TEST(NetworkHostname, PrefixesAndLowercasesReadableNodeName) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"Hill Repeater 2", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-hill-repeater-2", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, CollapsesInvalidRunsAndTrimsEdges) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
" Adam's___Hill / Repeater! ",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-adam-s-hill-repeater", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-node-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, RemovesUtf8AndRetainsStableIdentity) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"M\xC3\xBCnchen", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-m-nchen-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, MarksCombiningUnicodeAsLossy) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"Cafe\xCC\x81", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-cafe-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, UnicodeOnlyNamesUseDistinctIdentityFallbacks) {
const uint8_t other_id[] = {0x12, 0x34, 0x56};
const char unicode_only[] = "\xE7\xBD\x91\xE6\xA0\xBC\xF0\x9F\x8C\x90";
char first[NetworkHostname::kBufferSize];
char second[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(first, sizeof(first), unicode_only,
kStableId, sizeof(kStableId)));
ASSERT_TRUE(NetworkHostname::build(second, sizeof(second), unicode_only,
other_id, sizeof(other_id)));
EXPECT_STREQ("meshcore-node-abcd01", first);
EXPECT_STREQ("meshcore-node-123456", second);
EXPECT_STRNE(first, second);
expectDhcpSafe(first);
expectDhcpSafe(second);
}
TEST(NetworkHostname, MalformedUtf8CannotReachDhcpHostname) {
char hostname[NetworkHostname::kBufferSize];
const char malformed[] = {'b', 'a', 'd', static_cast<char>(0xff),
'n', 'a', 'm', 'e', '\0'};
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), malformed,
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-bad-name-abcd01", hostname);
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, KeepsExactBoundaryWithoutIdentitySuffix) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"abcdefghijklmnopqrstuv", kStableId,
sizeof(kStableId)));
EXPECT_STREQ("meshcore-abcdefghijklmnopqrstuv", hostname);
EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname));
}
TEST(NetworkHostname, TruncatesReadablePartAndAddsStableIdentitySuffix) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-abcdefghijklmno-abcd01", hostname);
EXPECT_EQ(NetworkHostname::kMaxLength, strlen(hostname));
expectDhcpSafe(hostname);
}
TEST(NetworkHostname, IdentitySuffixSeparatesOtherwiseCollidingNames) {
const uint8_t other_id[] = {0x12, 0x34, 0x56};
char first[NetworkHostname::kBufferSize];
char second[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(first, sizeof(first),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
kStableId, sizeof(kStableId)));
ASSERT_TRUE(NetworkHostname::build(second, sizeof(second),
"ABCDEFGHIJKLMNOPQRSTUVWXYZ012345",
other_id, sizeof(other_id)));
EXPECT_STRNE(first, second);
EXPECT_STREQ("meshcore-abcdefghijklmno-123456", second);
}
TEST(NetworkHostname, RejectsMissingOutputBuffer) {
EXPECT_FALSE(NetworkHostname::build(nullptr, 0, "node", kStableId,
sizeof(kStableId)));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
@@ -0,0 +1,202 @@
#include <gtest/gtest.h>
#include "helpers/NetworkPolicy.h"
TEST(NetworkPolicy, MqttDownDisconnectsSlotsWithoutRequestingImmediateRetry) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Down);
EXPECT_TRUE(actions.disconnect_started_slots);
EXPECT_FALSE(actions.retry_disconnected_slots_now);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, MqttUpRetriesWithoutClearingBrokerCircuitBreaker) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Up);
EXPECT_FALSE(actions.disconnect_started_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::None);
EXPECT_FALSE(actions.disconnect_started_slots);
EXPECT_FALSE(actions.retry_disconnected_slots_now);
EXPECT_FALSE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched);
EXPECT_TRUE(actions.disconnect_started_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
EXPECT_TRUE(actions.reset_reconnect_backoff);
}
TEST(NetworkPolicy, MediumNamesAreStableForTransitionLogs) {
EXPECT_STREQ("none", NetworkPolicy::mediumName(NetworkMedium::None));
EXPECT_STREQ("ethernet", NetworkPolicy::mediumName(NetworkMedium::Ethernet));
EXPECT_STREQ("wifi", NetworkPolicy::mediumName(NetworkMedium::WiFi));
}
TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) {
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::bootSelection(true, true));
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::bootSelection(false, true));
EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false));
}
TEST(NetworkPolicy, EthernetBootProbeUsesFullDeadlineForLinkOrUnknownState) {
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(
true, false, false, false, 1500, 8000));
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(
true, false, true, true, 7999, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
true, false, true, true, 8000, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
true, true, true, true, 100, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
false, false, false, false, 100, 8000));
}
TEST(NetworkPolicy, EthernetBootProbeStopsEarlyOnKnownCableDown) {
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(
true, false, true, false,
NetworkPolicy::kEthernetNoLinkBootGraceMs - 1, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(
true, false, true, false,
NetworkPolicy::kEthernetNoLinkBootGraceMs, 8000));
}
TEST(NetworkPolicy, EthernetInitRetryUsesBoundedExponentialBackoff) {
EXPECT_EQ(0u, NetworkPolicy::ethernetInitRetryDelayMs(0));
EXPECT_EQ(5000u, NetworkPolicy::ethernetInitRetryDelayMs(1));
EXPECT_EQ(10000u, NetworkPolicy::ethernetInitRetryDelayMs(2));
EXPECT_EQ(300000u, NetworkPolicy::ethernetInitRetryDelayMs(8));
EXPECT_EQ(300000u, NetworkPolicy::ethernetInitRetryDelayMs(255));
EXPECT_FALSE(NetworkPolicy::ethernetInitRetryDue(1, 4999, 0));
EXPECT_TRUE(NetworkPolicy::ethernetInitRetryDue(1, 5000, 0));
EXPECT_TRUE(NetworkPolicy::ethernetInitRetryDue(1, 10, 10u - 5000u));
}
TEST(NetworkPolicy, EthernetNoIpRecoveryRequiresSustainedCarrier) {
const uint32_t timeout = NetworkPolicy::kEthernetNoIpRecoveryMs;
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, timeout, 0));
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, timeout, 1));
EXPECT_TRUE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, timeout + 1, 1));
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, false, false, timeout + 1, 1));
EXPECT_FALSE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, true, timeout + 1, 1));
EXPECT_TRUE(NetworkPolicy::ethernetNoIpRecoveryDue(
true, true, false, 100, 100u - timeout));
}
TEST(NetworkPolicy, EthernetFailureWaitsForGraceAndConnectedWifi) {
NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::Ethernet, false, true, true, false, 0,
NetworkPolicy::kEthernetDownGraceMs - 1};
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
input.selected_down_ms = NetworkPolicy::kEthernetDownGraceMs;
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, FailbackRequiresStableEthernet) {
NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, true, true, false,
NetworkPolicy::kEthernetFailbackStableMs - 1, 0};
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
input.ethernet_stable_ms = NetworkPolicy::kEthernetFailbackStableMs;
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, DeadWifiFailsBackToReadyEthernetImmediately) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, false, true, false, 0, 0};
EXPECT_EQ(NetworkMedium::Ethernet, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, SwitchingLockPinsTheCurrentMedium) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, true, true, true,
NetworkPolicy::kEthernetFailbackStableMs, 0};
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
}
TEST(NetworkPolicy, DiagnosticsIdentifyEthernetFallbackCause) {
using Reason = NetworkDiagnosticReason;
EXPECT_EQ(Reason::EthernetInitFailed,
NetworkPolicy::automaticDiagnosticReason(
false, false, false, false, NetworkMedium::WiFi, false, 0));
EXPECT_EQ(Reason::EthernetLinkUnknown,
NetworkPolicy::automaticDiagnosticReason(
true, false, false, false, NetworkMedium::WiFi, false, 0));
EXPECT_EQ(Reason::EthernetLinkDown,
NetworkPolicy::automaticDiagnosticReason(
true, true, false, false, NetworkMedium::WiFi, false, 0));
EXPECT_EQ(Reason::EthernetAwaitingIp,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, false, NetworkMedium::WiFi, false, 0));
}
TEST(NetworkPolicy, DiagnosticsExplainWhyReadyEthernetHasNotBeenSelected) {
using Reason = NetworkDiagnosticReason;
EXPECT_EQ(Reason::SwitchingLocked,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::WiFi, true,
NetworkPolicy::kEthernetFailbackStableMs));
EXPECT_EQ(Reason::EthernetStabilizing,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::WiFi, false,
NetworkPolicy::kEthernetFailbackStableMs - 1));
EXPECT_EQ(Reason::EthernetReady,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::WiFi, false,
NetworkPolicy::kEthernetFailbackStableMs));
EXPECT_EQ(Reason::EthernetActive,
NetworkPolicy::automaticDiagnosticReason(
true, true, true, true, NetworkMedium::Ethernet, false, 0));
}
TEST(NetworkPolicy, NtpIsScheduledOncePerConnectivityEdge) {
EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample(
false, false, false, true));
EXPECT_FALSE(NetworkPolicy::ntpPendingAfterConnectivitySample(
false, false, true, true));
EXPECT_TRUE(NetworkPolicy::ntpPendingAfterConnectivitySample(
false, true, true, true));
EXPECT_FALSE(NetworkPolicy::ntpPendingAfterConnectivitySample(
true, false, false, true));
}
TEST(NetworkPolicy, FailedNtpSyncWaitsForThirtySecondRetryBoundary) {
EXPECT_FALSE(NetworkPolicy::ntpRetryDue(false, true, 29999, 0));
EXPECT_TRUE(NetworkPolicy::ntpRetryDue(false, true, 30000, 0));
EXPECT_FALSE(NetworkPolicy::ntpRetryDue(true, true, 60000, 0));
EXPECT_FALSE(NetworkPolicy::ntpRetryDue(false, false, 60000, 0));
EXPECT_TRUE(NetworkPolicy::ntpRetryDue(false, true, 10, 0xffff8000u));
}
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));
}
TEST(NetworkPolicy, ManualOtaTimeoutIsUploadSafeAndWrapSafe) {
const uint32_t timeout = NetworkPolicy::kManualOtaSessionTimeoutMs;
EXPECT_FALSE(NetworkPolicy::manualOtaTimeoutDue(timeout - 1, 0, false));
EXPECT_TRUE(NetworkPolicy::manualOtaTimeoutDue(timeout, 0, false));
EXPECT_FALSE(NetworkPolicy::manualOtaTimeoutDue(timeout, 0, true));
EXPECT_TRUE(NetworkPolicy::manualOtaTimeoutDue(
100, 100 - timeout, false));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+21 -6
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/agessaman/ESP32-CH390.git#a9367ff8ba400880270615b54da9c239cfd68eca
; 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
@@ -182,8 +191,8 @@ 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
; MQTT observers prefer the onboard CH390 Ethernet and fall back to Wi-Fi, without
; enabling the legacy Ethernet CLI transport (no ETHERNET_ENABLED). The board has
; PSRAM, so MAX_NEIGHBOURS enables WITH_MQTT_NEIGHBORS (see MQTTBridge.h).
[env:ThinkNode_M7_repeater_observer_mqtt]
extends = ThinkNode_M7
@@ -194,6 +203,8 @@ board_ssl_cert_source = adafruit-full
board_build.embed_files = src/certs/x509_crt_bundle.bin
build_flags =
${ThinkNode_M7.build_flags}
${ThinkNode_M7_ch390.build_flags}
-D NETWORK_PREFER_ETHERNET=1
-D ADVERT_NAME='"MQTT Observer"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
@@ -221,6 +232,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter}
+<../examples/simple_repeater>
lib_deps =
${ThinkNode_M7.lib_deps}
${ThinkNode_M7_ch390.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 7.4.3
@@ -238,6 +250,8 @@ board_ssl_cert_source = adafruit-full
board_build.embed_files = src/certs/x509_crt_bundle.bin
build_flags =
${ThinkNode_M7.build_flags}
${ThinkNode_M7_ch390.build_flags}
-D NETWORK_PREFER_ETHERNET=1
-D ADVERT_NAME='"ThinkNode M7 Room Observer"'
-D ADVERT_LAT=0.0
-D ADVERT_LON=0.0
@@ -266,6 +280,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter}
+<../examples/simple_room_server>
lib_deps =
${ThinkNode_M7.lib_deps}
${ThinkNode_M7_ch390.lib_deps}
${esp32_ota.lib_deps}
elims/PsychicMqttClient@^0.2.4
bblanchon/ArduinoJson @ 7.4.3
+6 -2
View File
@@ -476,7 +476,7 @@ body.tab-cli{padding-bottom:0}
</div></div>
<div class="f"><label>Password</label><input type="password" data-k="wifi.pwd" maxlength="63" autocomplete="off"></div>
<div class="f"><label>Power save</label>
<select data-k="wifi.powersave"><option value="min">Minimum (default)</option><option value="none">None (lowest latency)</option><option value="max">Maximum (battery)</option></select></div>
<select data-k="wifi.powersave"><option value="none">None (default, lowest latency)</option><option value="min">Minimum</option><option value="max">Maximum (battery)</option></select></div>
</div>
<div class="card">
<h2>Maintenance</h2>
@@ -1156,7 +1156,9 @@ function pollStats(){
tile("Direct RX/TX",s.recv_direct+" / "+s.sent_direct)+
tile("TX queue",s.tx_queue)+
tile("MQTT queue",s.mqtt_queue)+
tile("WiFi RSSI",s.wifi_rssi,"dBm")+
(s.wifi_rssi==null
? tile("Network",s.network_medium||"—","RSSI unavailable")
: tile("WiFi RSSI",s.wifi_rssi,"dBm"))+
tile("IP",s.ip||"—");
push(st.hist.heap,kb);push(st.hist.noise,s.noise);
spark($("#spark-heap"),st.hist.heap);spark($("#spark-noise"),st.hist.noise);
@@ -1546,6 +1548,8 @@ var CLI_KEYS=[
["wifi.ssid","WiFi network name",0],
["wifi.pwd","WiFi password",0],
["wifi.powersave","WiFi power-save mode",0,"none|min|max"],
["link.status","Selected network, IP, signal and uptime",1],
["link.diag","Ethernet selection and fallback diagnostics",1],
["wifi.status","WiFi connection, IP, RSSI and uptime",1],
["mqtt.origin","Observer name in published messages",0],
["mqtt.iata","IATA region code used in topic paths",0],