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