fix(network): harden Ethernet-preferred lifecycle
This commit is contained in:
1 parent
d9db3e8e6a
commit
a0188bd174
26 files changed
+803
-193
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user