fix(network): harden Ethernet-preferred lifecycle

This commit is contained in:
agessaman committed 2026-09-04 08:42:46 -07:00
1 parent d9db3e8e6a
commit a0188bd174
26 files changed
+803 -193

No files matched your search

+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.
---
+7 -4
View File
@@ -1550,15 +1550,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;
char wifi_rssi[12] = "null";
NetworkInterface& network = activeNetworkInterface();
const char* network_medium = network.mediumName();
if (network.isConnected()) {
strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1);
const int signal = network.rssi();
wifi_rssi = signal == INT_MIN ? 0 : signal;
if (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,"
@@ -1567,7 +1569,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(),
@@ -1578,7 +1581,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;
+2
View File
@@ -138,6 +138,8 @@ void setup() {
}
void loop() {
board.maintainOTAUpdate(millis());
// Handle Serial CLI
int len = strlen(command);
while (Serial.available() && len < sizeof(command)-1) {
+7 -4
View File
@@ -1352,15 +1352,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;
char wifi_rssi[12] = "null";
NetworkInterface& network = activeNetworkInterface();
const char* network_medium = network.mediumName();
if (network.isConnected()) {
strncpy(ip, network.localIP().toString().c_str(), sizeof(ip) - 1);
const int signal = network.rssi();
wifi_rssi = signal == INT_MIN ? 0 : signal;
if (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,"
@@ -1369,7 +1371,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(),
@@ -1380,7 +1383,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;
+2
View File
@@ -114,6 +114,8 @@ void setup() {
}
void loop() {
board.maintainOTAUpdate(millis());
int len = strlen(command);
while (Serial.available() && len < sizeof(command)-1) {
char c = Serial.read();
+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.
+8 -10
View File
@@ -208,10 +208,14 @@ 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),
@@ -219,20 +223,14 @@ void AlertReporter::onLoop(unsigned long now_ms) {
if (r.action == AlertFaultPolicy::Action::FireDown) {
char text[80];
AlertFaultPolicy::formatNetworkAlert(
text, sizeof(text),
activeNetworkInterface().medium() == NetworkMedium::Ethernet
? "Ethernet" : "WiFi",
r, snap);
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::formatNetworkAlert(
text, sizeof(text),
activeNetworkInterface().medium() == NetworkMedium::Ethernet
? "Ethernet" : "WiFi",
r, snap);
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();
+125 -22
View File
@@ -9,14 +9,66 @@
#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) {
activeNetworkInterface().unlockSwitching();
ota_network_locked = false;
}
HttpPort80Lease::release(HttpPort80Lease::Owner::Ota);
}
} // namespace
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
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;
}
inhibit_sleep = true; // prevent sleep during OTA
// Manual ElegantOTA owns port 80 and remains active until a successful upload
// reboots the device, so its route lock is intentionally reboot-scoped.
activeNetworkInterface().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
@@ -29,38 +81,89 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
force_ap, activeNetworkInterface().isConnected())) {
ip = activeNetworkInterface().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
// ---------------------------------------------------------------------------
+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
+11 -5
View File
@@ -25,8 +25,10 @@ static inline char asciiLower(uint8_t ch) {
*
* The result is lowercase, starts with "meshcore-", contains only letters,
* digits and hyphens, and never exceeds ESP-IDF's practical 31-character
* payload limit. If the readable name must be shortened, six hex digits from
* the stable node identity are retained to reduce truncation collisions.
* 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) {
@@ -44,6 +46,7 @@ static inline bool build(char* dest, size_t dest_size, const char* node_name,
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) {
@@ -57,12 +60,14 @@ static inline bool build(char* dest, size_t dest_size, const char* node_name,
slug[slug_length++] = asciiLower(ch);
}
separator_pending = false;
} else if (slug_length > 0) {
separator_pending = true;
} 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];
@@ -75,7 +80,8 @@ static inline bool build(char* dest, size_t dest_size, const char* node_name,
if (max_length == 0) return false;
const bool needs_truncation = kPrefixLength + slug_length > max_length;
const bool can_add_identity = needs_truncation && stable_id &&
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
+270 -85
View File
@@ -236,7 +236,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
};
private:
bool _started = false;
std::atomic<bool> _started{false};
bool _event_registered = false;
char _hostname[32] = {};
std::atomic<uint8_t> _event_state{static_cast<uint8_t>(EventState::None)};
@@ -256,7 +256,7 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
}
bool begin(const char*, const char*) override {
if (_started) return true;
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.
@@ -294,9 +294,21 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
});
_event_registered = true;
}
_started = beginConfiguredCH390(_hostname);
if (_started && isConnected()) noteConnected(millis());
return _started;
_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 {
@@ -334,7 +346,9 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
return NetworkTransition::Down;
}
bool isConnected() const override { return _started && CH390.isConnected(); }
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 {
@@ -351,9 +365,10 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
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) return 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.
@@ -390,22 +405,26 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
class AutomaticNetworkInterface final : public NetworkInterface {
EthernetNetworkInterface _ethernet;
WiFiNetworkInterface _wifi;
NetworkMedium _selected = NetworkMedium::None;
bool _ethernet_started = false;
bool _wifi_started = false;
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] = {};
uint32_t _ethernet_stable_since = 0;
uint32_t _selected_down_since = 0;
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};
NetworkInterface& selectedInterface() {
return _selected == NetworkMedium::Ethernet
NetworkInterface& selectedInterface(NetworkMedium selected) {
return selected == NetworkMedium::Ethernet
? static_cast<NetworkInterface&>(_ethernet)
: static_cast<NetworkInterface&>(_wifi);
}
const NetworkInterface& selectedInterface() const {
return _selected == NetworkMedium::Ethernet
const NetworkInterface& selectedInterface(NetworkMedium selected) const {
return selected == NetworkMedium::Ethernet
? static_cast<const NetworkInterface&>(_ethernet)
: static_cast<const NetworkInterface&>(_wifi);
}
@@ -420,8 +439,40 @@ class AutomaticNetworkInterface final : public NetworkInterface {
}
void startWifiFallback() {
if (_wifi_started || !wifiConfigured()) return;
_wifi_started = _wifi.begin(_wifi_ssid, _wifi_password);
if (_wifi_started.load(std::memory_order_acquire) || !wifiConfigured()) return;
_wifi_started.store(_wifi.begin(_wifi_ssid, _wifi_password),
std::memory_order_release);
}
bool beginUnlockedMutation() {
bool expected = false;
if (!_switch_in_progress.compare_exchange_strong(
expected, true, std::memory_order_acq_rel,
std::memory_order_relaxed)) {
return false;
}
if (_switch_locks.load(std::memory_order_acquire) != 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) {
@@ -431,25 +482,46 @@ class AutomaticNetworkInterface final : public NetworkInterface {
// on Wi-Fi after the manager has declared Ethernet active.
WiFi.setAutoReconnect(false);
WiFi.disconnect(false, false);
_wifi_started = false;
_wifi_started.store(false, std::memory_order_release);
}
_selected = medium;
_selected_down_since = 0;
_selected_down_since.store(0, std::memory_order_relaxed);
_selected.store(medium, std::memory_order_release);
}
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 {
if (_selected == NetworkMedium::Ethernet) return "ethernet";
if (_selected == NetworkMedium::WiFi) return "wifi";
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; }
NetworkMedium medium() const override {
return _selected.load(std::memory_order_acquire);
}
const char* statusName() const override {
return _selected == NetworkMedium::None ? "not_selected"
: selectedInterface().statusName();
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? "not_selected"
: selectedInterface(selected).statusName();
}
int statusCode() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().statusCode();
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
@@ -469,36 +541,51 @@ class AutomaticNetworkInterface final : public NetworkInterface {
bool begin(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started) {
_ethernet_started = _ethernet.begin(nullptr, nullptr);
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))) {
startOrRetryEthernet(now_ms, attempt != 0);
}
}
// 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.
if (_selected == NetworkMedium::None) {
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::None) {
if (_ethernet.isConnected()) {
select(NetworkMedium::Ethernet);
} else if (wifiConfigured()) {
startWifiFallback();
_selected = NetworkMedium::WiFi;
_selected.store(NetworkMedium::WiFi, std::memory_order_release);
}
} else if (_selected == NetworkMedium::WiFi) {
} else if (selected == NetworkMedium::WiFi) {
startWifiFallback();
}
return _ethernet_started || _wifi_started;
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) {
_ethernet_started = _ethernet.begin(nullptr, nullptr);
if (!_ethernet_started.load(std::memory_order_acquire)) {
startOrRetryEthernet(millis(), false);
}
const uint32_t started_at = millis();
while (NetworkPolicy::ethernetBootProbePending(
_ethernet_started, _ethernet.isConnected(),
(uint32_t)(millis() - started_at), wait_ms)) {
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);
}
@@ -508,15 +595,60 @@ class AutomaticNetworkInterface final : public NetworkInterface {
select(initial);
} else {
startWifiFallback();
_selected = initial;
_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() == EthernetNetworkInterface::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 EthernetNetworkInterface::EventState ethernet_event =
_ethernet.eventState();
const bool ethernet_link_up =
ethernet_event == EthernetNetworkInterface::EventState::LinkUp ||
ethernet_event == EthernetNetworkInterface::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);
}
if (!switching_locked && NetworkPolicy::ethernetNoIpRecoveryDue(
ethernet_started, ethernet_link_up, _ethernet.isConnected(),
now_ms, _ethernet_no_ip_since.load(std::memory_order_relaxed))) {
ethernet_started = startOrRetryEthernet(now_ms, true);
_ethernet_no_ip_since.store(0, std::memory_order_relaxed);
}
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))) {
ethernet_started = startOrRetryEthernet(now_ms, true);
}
}
const NetworkTransition ethernet_transition =
_ethernet.maintain(now_ms, wifi_power_save);
const NetworkTransition wifi_transition = _wifi_started
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;
@@ -525,56 +657,73 @@ class AutomaticNetworkInterface final : public NetworkInterface {
// 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.
if (_selected == NetworkMedium::Ethernet && _ethernet.isConnected() &&
_wifi_started) {
NetworkMedium selected = _selected.load(std::memory_order_acquire);
if (selected == NetworkMedium::Ethernet && _ethernet.isConnected() &&
wifi_started) {
const bool wifi_had_route = _wifi.isConnected();
select(NetworkMedium::Ethernet);
if (wifi_had_route) return NetworkTransition::Switched;
if (selectIfUnlocked(NetworkMedium::Ethernet) && wifi_had_route) {
return NetworkTransition::Switched;
}
}
if (_ethernet.isConnected()) {
if (_ethernet_stable_since == 0) _ethernet_stable_since = now_ms;
if (_ethernet_stable_since.load(std::memory_order_relaxed) == 0) {
_ethernet_stable_since.store(now_ms, std::memory_order_relaxed);
}
} else {
_ethernet_stable_since = 0;
_ethernet_stable_since.store(0, std::memory_order_relaxed);
}
const bool selected_connected = isConnected();
if (!selected_connected) {
if (_selected_down_since == 0) _selected_down_since = now_ms;
if (_selected_down_since.load(std::memory_order_relaxed) == 0) {
_selected_down_since.store(now_ms, std::memory_order_relaxed);
}
} else {
_selected_down_since = 0;
_selected_down_since.store(0, std::memory_order_relaxed);
}
const uint32_t selected_down_ms = _selected_down_since == 0
? 0 : (uint32_t)(now_ms - _selected_down_since);
if (_selected == NetworkMedium::Ethernet && !_ethernet.isConnected() &&
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) {
startWifiFallback();
startWifiFallbackIfUnlocked();
}
const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - _ethernet_stable_since);
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 && _wifi.isConnected(), wifiConfigured(),
_switch_locks.load(std::memory_order_relaxed) != 0,
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) {
const NetworkMedium previous = _selected;
select(next);
return previous == NetworkMedium::None ? NetworkTransition::Up
: NetworkTransition::Switched;
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;
if (selected == NetworkMedium::Ethernet) return ethernet_transition;
if (selected == NetworkMedium::WiFi) return wifi_transition;
return NetworkTransition::None;
}
void lockSwitching() override {
_switch_locks.fetch_add(1, std::memory_order_relaxed);
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),
std::memory_order_acq_rel, std::memory_order_relaxed)) {}
while (_switch_in_progress.load(std::memory_order_acquire)) delay(1);
}
void unlockSwitching() override {
uint8_t value = _switch_locks.load(std::memory_order_relaxed);
@@ -584,60 +733,96 @@ class AutomaticNetworkInterface final : public NetworkInterface {
}
bool isConnected() const override {
return _selected != NetworkMedium::None && selectedInterface().isConnected();
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected != NetworkMedium::None &&
selectedInterface(selected).isConnected();
}
IPAddress localIP() const override {
return _selected == NetworkMedium::None ? IPAddress() : selectedInterface().localIP();
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None ? IPAddress()
: selectedInterface(selected).localIP();
}
int rssi() const override {
return _selected == NetworkMedium::None ? INT_MIN : selectedInterface().rssi();
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 {
return _selected != NetworkMedium::None &&
selectedInterface().resolveHost(hostname, address);
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_relaxed) != 0;
_switch_locks.load(std::memory_order_acquire) != 0;
const uint32_t now_ms = millis();
const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - _ethernet_stable_since);
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_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 evt:%s link:%s ip:%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), mediumName(),
NetworkPolicy::diagnosticReasonName(reason),
NetworkPolicy::mediumName(selected),
switching_locked ? "yes" : "no",
_ethernet_started ? "ok" : "failed", _ethernet.eventName(),
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");
wifiConfigured() ? "yes" : "no", wifi_started ? "yes" : "no",
(wifi_started && _wifi.isConnected()) ? "up" : "down");
}
unsigned long connectedAtMillis() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().connectedAtMillis();
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).connectedAtMillis();
}
uint8_t lastDisconnectReason() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectReason();
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).lastDisconnectReason();
}
unsigned long lastDisconnectTime() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectTime();
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? 0 : selectedInterface(selected).lastDisconnectTime();
}
AlertFaultPolicy::OutageSnapshot outageSnapshot() const override {
return _selected == NetworkMedium::None
const NetworkMedium selected = _selected.load(std::memory_order_acquire);
return selected == NetworkMedium::None
? AlertFaultPolicy::OutageSnapshot{false, 0, 0}
: selectedInterface().outageSnapshot();
: selectedInterface(selected).outageSnapshot();
}
NetworkMedium alertMedium() const override {
return NetworkMedium::Ethernet;
}
AlertFaultPolicy::OutageSnapshot alertOutageSnapshot() const override {
return _ethernet.outageSnapshot();
}
};
#endif
+9
View File
@@ -59,6 +59,15 @@ class NetworkInterface {
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. */
+47 -4
View File
@@ -63,7 +63,40 @@ struct AutomaticSelectionInput {
// 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,
@@ -135,14 +168,17 @@ static constexpr NetworkMedium bootSelection(bool ethernet_connected,
: NetworkMedium::None;
}
// Once the Ethernet controller has initialized, allow the entire boot probe
// window for link negotiation and DHCP. PHY carrier is useful diagnostic data,
// but it must not shorten the advertised deadline when carrier reporting lags.
// 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;
return ethernet_initialized && !ethernet_connected && elapsed_ms < wait_ms &&
(!link_known || link_up || elapsed_ms < kEthernetNoLinkBootGraceMs);
}
static inline NetworkMedium automaticSelection(
@@ -179,4 +215,11 @@ static constexpr bool startOtaUsesSelectedNetwork(bool force_ap,
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
+2 -2
View File
@@ -17,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';
@@ -80,7 +80,7 @@ void MeshSNMPAgent::loop() {
#endif
const int signal = activeNetworkInterface().rssi();
_wifi_rssi = signal == INT_MIN ? 0 : signal;
_wifi_rssi = signal == INT_MIN ? -127 : signal;
_snmp.loop();
}
+12 -10
View File
@@ -2776,18 +2776,20 @@ bool MQTTBridge::handleNetworkConnection(unsigned long now) {
updateCachedConnectionStatus();
}
if (actions.reset_reconnect_backoff) {
// A usable route is a new connection epoch. Failures earned on the old
// route must not strand the replacement route on the 5-minute rung or at
// the circuit breaker. Preserve JWTs and slot configuration, but give each
// disconnected active slot one immediate, freshly guarded attempt.
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) {
_slots[i].reconnect_backoff = 0;
_slots[i].max_backoff_failures = 0;
_slots[i].circuit_breaker_tripped = false;
_slots[i].last_reconnect_attempt =
now - MQTTConnectionPolicy::reconnectDelayMs(0, static_cast<uint8_t>(i));
if (actions.reset_reconnect_backoff) {
_slots[i].reconnect_backoff = 0;
_slots[i].max_backoff_failures = 0;
_slots[i].circuit_breaker_tripped = false;
}
_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_slot_reconnect_ms = now - MQTTConnectionPolicy::kReconnectGuardMs;
+9 -5
View File
@@ -634,17 +634,21 @@ public:
static unsigned long getWifiConnectedAtMillis();
/**
* Current selected-network 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 _network ? _network->outageSnapshot()
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
+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
+92 -25
View File
@@ -7,9 +7,10 @@
#include <ESPAsyncWebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include <bootloader_random.h>
#include <esp_random.h>
#include <esp_system.h>
#include <esp_heap_caps.h>
#include <SHA256.h>
#include <helpers/CommonCLI.h>
#include <helpers/NetworkInterface.h>
@@ -18,30 +19,58 @@
#include <helpers/WebConfigKeys.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[] = "********";
// esp_random() has a true entropy source only while RF is active. Ethernet
// LAN mode deliberately keeps Wi-Fi and Bluetooth down, so temporarily enable
// the bootloader RNG source while generating authentication secrets there.
// 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 {
bool _enabled;
wifi_mode_t _previous_mode;
bool _started_wifi;
bool _ready;
public:
WebConfigEntropyGuard()
: _enabled(activeNetworkInterface().medium() ==
NetworkMedium::Ethernet) {
if (_enabled) bootloader_random_enable();
: _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 (_enabled) bootloader_random_disable();
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.
@@ -223,6 +252,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.
@@ -244,6 +278,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;
}
@@ -271,26 +306,38 @@ bool WebConfigServer::startLanMode(IPAddress ip, bool initial_setup, 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;
}
activeNetworkInterface().lockSwitching();
_network_locked = true;
if (!activeNetworkInterface().isConnected() || ip == IPAddress()) {
activeNetworkInterface().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)) {
activeNetworkInterface().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) {
{
WebConfigEntropyGuard entropy;
for (int i = 0; i < 3; ++i) {
sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff));
}
}
_setup_code[12] = 0;
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();
@@ -391,6 +438,8 @@ 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;
@@ -399,6 +448,7 @@ void WebConfigServer::finalizeTeardown() {
activeNetworkInterface().unlockSwitching();
_network_locked = false;
}
HttpPort80Lease::release(HttpPort80Lease::Owner::WebConfig);
if (_cb) _cb->onWebConfigStopped();
}
@@ -653,13 +703,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
@@ -752,25 +803,41 @@ void WebConfigServer::handleLogin(AsyncWebServerRequest* req) {
}
_login_fails = 0;
_login_lock_until = 0;
uint32_t generation;
{
WebConfigEntropyGuard entropy;
for (int i = 0; i < 4; i++) {
sprintf(&_session_token[i * 8], "%08lx",
(unsigned long)esp_random());
}
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;
}
_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);
+2
View File
@@ -171,6 +171,8 @@ 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;
@@ -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));
@@ -47,7 +47,51 @@ TEST(NetworkHostname, UsesDocumentedFallbackForEmptySanitizedName) {
char hostname[NetworkHostname::kBufferSize];
ASSERT_TRUE(NetworkHostname::build(hostname, sizeof(hostname), "___ !!!",
kStableId, sizeof(kStableId)));
EXPECT_STREQ("meshcore-node", hostname);
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);
}
@@ -42,13 +42,54 @@ TEST(NetworkPolicy, BootPrefersEthernetAndOtherwiseUsesConfiguredWifi) {
EXPECT_EQ(NetworkMedium::None, NetworkPolicy::bootSelection(false, false));
}
TEST(NetworkPolicy, EthernetBootProbeHonorsFullDeadlineUntilConnected) {
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 0, 8000));
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 1500, 8000));
EXPECT_TRUE(NetworkPolicy::ethernetBootProbePending(true, false, 7999, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, false, 8000, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(true, true, 100, 8000));
EXPECT_FALSE(NetworkPolicy::ethernetBootProbePending(false, false, 100, 8000));
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) {
@@ -146,6 +187,15 @@ TEST(NetworkPolicy, StartOtaForceApOverridesAReachableSelectedNetwork) {
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();
+1 -1
View File
@@ -47,7 +47,7 @@ build_flags =
-D ETH_INT_PIN=45
-D ETHERNET_DEBUG_LOGGING=1
lib_deps =
https://github.com/agessaman/ESP32-CH390.git#55c425705d9b21df11f95782ae6408707b2a91a9
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
+3 -1
View File
@@ -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);