feat: add automatic network failover

This commit is contained in:
agessaman committed 2026-09-01 21:18:22 -07:00
1 parent 03c0ad81fe
commit 366b086713
23 files changed
+600 -86

No files matched your search

+4 -3
View File
@@ -80,7 +80,7 @@ reboot
**9. Verify configuration**
```bash
get wifi.ssid
get wifi.status
get link.status
get bridge.enabled
get mqtt.rx
get mqtt.tx
@@ -541,12 +541,13 @@ 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 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
+23 -18
View File
@@ -4,7 +4,7 @@
#include <stdlib.h> // for qsort()
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkInterface.h>
#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET)
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#if defined(WITH_MQTT_NEIGHBORS)
@@ -1147,6 +1147,17 @@ void MyMesh::begin(FILESYSTEM *fs) {
}
#endif
NetworkInterface& boot_network = activeNetworkInterface();
if (boot_network.isAutomatic()) {
MQTTPrefs* obs = _cli.getObserverPrefs();
Serial.printf("Network: probing Ethernet for up to %lums\n",
(unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS);
boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password,
NETWORK_ETHERNET_BOOT_WAIT_MS);
Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(),
boot_network.statusName());
}
acl.load(_fs, self_id);
// TODO: key_store.begin();
region_map.load(_fs);
@@ -1221,15 +1232,13 @@ 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 !defined(NETWORK_USE_ETHERNET)
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);
}
#endif
#endif
radio_driver.setParams(_prefs.freq, _prefs.bw, _prefs.sf, _prefs.cr);
@@ -1474,11 +1483,6 @@ void MyMesh::clearStats() {
#ifdef WITH_WEBCONFIG
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
#if defined(NETWORK_USE_ETHERNET)
(void)force_ap;
strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI");
return true;
#else
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
@@ -1491,18 +1495,21 @@ 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() &&
activeNetworkInterface().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 (activeNetworkInterface().isConnected()) {
_webconfig->startLanMode(activeNetworkInterface().localIP(),
!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;
#endif
}
bool MyMesh::stopWebConfig(char* reply) {
@@ -1542,11 +1549,9 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
const int signal = network.rssi();
wifi_rssi = signal == INT_MIN ? 0 : signal;
}
#if !defined(NETWORK_USE_ETHERNET)
else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
#endif
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
+7
View File
@@ -507,6 +507,13 @@ public:
_wc_slot_restart_mask = 0;
}
void onConfigBatchEnd() override;
bool onInitialSetupComplete() override {
MQTTPrefs* obs = _cli.getObserverPrefs();
obs->network_setup_complete = 1;
if (_cli.saveObserverPrefs(_fs)) return true;
obs->network_setup_complete = 0;
return false;
}
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
+3 -2
View File
@@ -174,15 +174,16 @@ void UITask::renderCurrScreen() {
}
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
const bool lan_setup = WebConfigServer::isLanSetup();
// setup portal active: show join instructions instead of the home screen
_display->setTextSize(1);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
_display->print("Observer WiFi Setup");
_display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup");
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->print(lan_setup ? "Login code:" : "Join WiFi:");
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 24);
_display->print(wc_ssid);
+23 -18
View File
@@ -3,7 +3,7 @@
#include <climits>
#include <helpers/RxReservePacketManager.h>
#include <helpers/NetworkInterface.h>
#if defined(ESP_PLATFORM) && !defined(NETWORK_USE_ETHERNET)
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#if defined(WITH_MQTT_NEIGHBORS)
@@ -948,6 +948,17 @@ void MyMesh::begin(FILESYSTEM *fs) {
// load persisted prefs
_cli.loadPrefs(_fs);
NetworkInterface& boot_network = activeNetworkInterface();
if (boot_network.isAutomatic()) {
MQTTPrefs* obs = _cli.getObserverPrefs();
Serial.printf("Network: probing Ethernet for up to %lums\n",
(unsigned long)NETWORK_ETHERNET_BOOT_WAIT_MS);
boot_network.bootstrap(obs->wifi_ssid, obs->wifi_password,
NETWORK_ETHERNET_BOOT_WAIT_MS);
Serial.printf("Network: selected %s (%s)\n", boot_network.mediumName(),
boot_network.statusName());
}
acl.load(_fs, self_id);
region_map.load(_fs);
@@ -1023,15 +1034,13 @@ 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 !defined(NETWORK_USE_ETHERNET)
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);
}
#endif
#endif
}
@@ -1276,11 +1285,6 @@ void MyMesh::formatPacketStatsReply(char *reply) {
#ifdef WITH_WEBCONFIG
bool MyMesh::startWebConfig(bool force_ap, char* reply) {
#if defined(NETWORK_USE_ETHERNET)
(void)force_ap;
strcpy(reply, "Err: webconfig unavailable on Ethernet observer; use serial CLI");
return true;
#else
if (_webconfig && (_webconfig->isRunning() || _webconfig->isStopping())) {
strcpy(reply, _webconfig->isStopping() ? "Err: webconfig still stopping, retry shortly"
: "Err: webconfig already running");
@@ -1293,18 +1297,21 @@ 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() &&
activeNetworkInterface().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 (activeNetworkInterface().isConnected()) {
_webconfig->startLanMode(activeNetworkInterface().localIP(),
!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;
#endif
}
bool MyMesh::stopWebConfig(char* reply) {
@@ -1344,11 +1351,9 @@ void MyMesh::buildStatsJson(char* buf, size_t buf_size) {
const int signal = network.rssi();
wifi_rssi = signal == INT_MIN ? 0 : signal;
}
#if !defined(NETWORK_USE_ETHERNET)
else if (_webconfig && _webconfig->mode() == WebConfigServer::MODE_SETUP) {
strncpy(ip, WiFi.softAPIP().toString().c_str(), sizeof(ip) - 1);
}
#endif
int pos = snprintf(buf, buf_size,
"{\"uptime_s\":%lu,\"batt_mv\":%u,"
"\"heap_free\":%lu,\"heap_min\":%lu,\"heap_max_alloc\":%lu,"
+7
View File
@@ -497,6 +497,13 @@ public:
_wc_slot_restart_mask = 0;
}
void onConfigBatchEnd() override;
bool onInitialSetupComplete() override {
MQTTPrefs* obs = _cli.getObserverPrefs();
obs->network_setup_complete = 1;
if (_cli.saveObserverPrefs(_fs)) return true;
obs->network_setup_complete = 0;
return false;
}
void buildStatsJson(char* buf, size_t buf_size) override;
#endif
};
+3 -2
View File
@@ -155,15 +155,16 @@ void UITask::renderCurrScreen() {
}
char wc_ssid[33], wc_ip[16];
if (WebConfigServer::getSetupInfo(wc_ssid, sizeof(wc_ssid), wc_ip, sizeof(wc_ip))) {
const bool lan_setup = WebConfigServer::isLanSetup();
// setup portal active: show join instructions instead of the home screen
_display->setTextSize(1);
_display->setColor(UIColor::corp_blue);
_display->setCursor(0, 0);
_display->print("Observer WiFi Setup");
_display->print(lan_setup ? "Observer Ethernet Setup" : "Observer WiFi Setup");
_display->setColor(UIColor::primary_txt);
_display->setCursor(0, 14);
_display->print("Join WiFi:");
_display->print(lan_setup ? "Login code:" : "Join WiFi:");
_display->setColor(UIColor::warning_txt);
_display->setCursor(6, 24);
_display->print(wc_ssid);
+3
View File
@@ -497,6 +497,9 @@ 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)),
"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)
+2 -2
View File
@@ -220,7 +220,7 @@ void AlertReporter::onLoop(unsigned long now_ms) {
char text[80];
AlertFaultPolicy::formatNetworkAlert(
text, sizeof(text),
strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0
activeNetworkInterface().medium() == NetworkMedium::Ethernet
? "Ethernet" : "WiFi",
r, snap);
if (sendChannel(text)) {
@@ -230,7 +230,7 @@ void AlertReporter::onLoop(unsigned long now_ms) {
char text[80];
AlertFaultPolicy::formatNetworkAlert(
text, sizeof(text),
strcmp(activeNetworkInterface().mediumName(), "ethernet") == 0
activeNetworkInterface().medium() == NetworkMedium::Ethernet
? "Ethernet" : "WiFi",
r, snap);
sendChannel(text);
+7
View File
@@ -14,6 +14,9 @@
bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
inhibit_sleep = true; // prevent sleep during OTA
// Manual ElegantOTA owns port 80 and remains active until a successful upload
// reboots the device, so its route lock is intentionally reboot-scoped.
activeNetworkInterface().lockSwitching();
// 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
@@ -184,16 +187,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.
NetworkInterface& network = activeNetworkInterface();
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;
}
+1
View File
@@ -112,6 +112,7 @@ static inline void applyMQTTDefaults(MQTTPrefs* prefs) {
prefs->display_timeout_secs = DISPLAY_TIMEOUT_DEFAULT_SECS;
prefs->display_flip = 0;
prefs->network_setup_complete = 0;
}
#endif // WITH_MQTT_BRIDGE
+11 -2
View File
@@ -39,22 +39,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 > 2) {
_power_save = 1;
*repaired = true;
}
if (_setup_complete < 0 || _setup_complete > 1) {
_setup_complete = 0;
*repaired = true;
}
_prefs->wifi_power_save = static_cast<uint8_t>(_power_save);
_prefs->network_setup_complete = static_cast<uint8_t>(_setup_complete);
return true;
}
};
+10
View File
@@ -126,8 +126,18 @@ struct MQTTPrefs {
// Rotate the panel 180 degrees from its compiled DISPLAY_ROTATION, for boards
// mounted the other way up. Runtime only, like display_timeout_secs.
uint8_t display_flip;
// Explicitly records completion of first-run network onboarding when a node
// is configured over Ethernet and therefore may intentionally have no Wi-Fi
// SSID. Existing Wi-Fi installations remain complete by the helper below.
uint8_t network_setup_complete;
};
static inline bool mqttNetworkSetupComplete(const MQTTPrefs* prefs) {
return prefs != nullptr &&
(prefs->network_setup_complete != 0 || prefs->wifi_ssid[0] != '\0');
}
static const uint16_t DISPLAY_TIMEOUT_DEFAULT_SECS = 60;
static const uint16_t DISPLAY_TIMEOUT_MAX_SECS = 3600;
+227 -4
View File
@@ -11,7 +11,7 @@
#include <WiFi.h>
#include <esp_wifi.h>
#if defined(NETWORK_USE_ETHERNET)
#if defined(NETWORK_PREFER_ETHERNET)
#include "ethernet/ch390/CH390Config.h"
#endif
@@ -88,6 +88,7 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase {
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";
@@ -200,13 +201,14 @@ class WiFiNetworkInterface final : public NetworkInterfaceBase {
}
};
#if defined(NETWORK_USE_ETHERNET)
#if defined(NETWORK_PREFER_ETHERNET)
class EthernetNetworkInterface final : public NetworkInterfaceBase {
bool _started = false;
bool _event_registered = false;
public:
const char* mediumName() const override { return "ethernet"; }
NetworkMedium medium() const override { return NetworkMedium::Ethernet; }
const char* statusName() const override {
return isConnected() ? "connected" : "disconnected";
}
@@ -281,13 +283,234 @@ class EthernetNetworkInterface final : public NetworkInterfaceBase {
return WiFi.hostByName(hostname, address);
}
};
class AutomaticNetworkInterface final : public NetworkInterface {
EthernetNetworkInterface _ethernet;
WiFiNetworkInterface _wifi;
NetworkMedium _selected = NetworkMedium::None;
bool _ethernet_started = false;
bool _wifi_started = false;
char _wifi_ssid[33] = {};
char _wifi_password[65] = {};
uint32_t _ethernet_stable_since = 0;
uint32_t _selected_down_since = 0;
std::atomic<uint8_t> _switch_locks{0};
NetworkInterface& selectedInterface() {
return _selected == NetworkMedium::Ethernet
? static_cast<NetworkInterface&>(_ethernet)
: static_cast<NetworkInterface&>(_wifi);
}
const NetworkInterface& selectedInterface() const {
return _selected == NetworkMedium::Ethernet
? static_cast<const NetworkInterface&>(_ethernet)
: static_cast<const NetworkInterface&>(_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 || !wifiConfigured()) return;
_wifi_started = _wifi.begin(_wifi_ssid, _wifi_password);
}
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 = false;
}
_selected = medium;
_selected_down_since = 0;
}
public:
const char* mediumName() const override {
if (_selected == NetworkMedium::Ethernet) return "ethernet";
if (_selected == NetworkMedium::WiFi) return "wifi";
return "none";
}
NetworkMedium medium() const override { return _selected; }
const char* statusName() const override {
return _selected == NetworkMedium::None ? "not_selected"
: selectedInterface().statusName();
}
int statusCode() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().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.
return _ethernet_started || (wifi_ssid && wifi_ssid[0] != '\0');
}
bool isAutomatic() const override { return true; }
bool begin(const char* wifi_ssid, const char* wifi_password) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started) {
_ethernet_started = _ethernet.begin(nullptr, nullptr);
}
// bootstrap() owns the initial choice. MQTT begin() is intentionally
// idempotent and cannot demote a boot-selected Ethernet link because of a
// momentary status sample between tasks.
if (_selected == NetworkMedium::None) {
if (_ethernet.isConnected()) {
select(NetworkMedium::Ethernet);
} else if (wifiConfigured()) {
startWifiFallback();
_selected = NetworkMedium::WiFi;
}
} else if (_selected == NetworkMedium::WiFi) {
startWifiFallback();
}
return _ethernet_started || _wifi_started;
}
bool bootstrap(const char* wifi_ssid, const char* wifi_password,
uint32_t wait_ms) override {
rememberWifi(wifi_ssid, wifi_password);
if (!_ethernet_started) {
_ethernet_started = _ethernet.begin(nullptr, nullptr);
}
const uint32_t started_at = millis();
const uint32_t link_wait_ms = wait_ms < 1500 ? wait_ms : 1500;
while (_ethernet_started && !CH390.linkUp() &&
(uint32_t)(millis() - started_at) < link_wait_ms) {
delay(25);
}
while (_ethernet_started && CH390.linkUp() && !_ethernet.isConnected() &&
(uint32_t)(millis() - started_at) < wait_ms) {
delay(25);
}
const NetworkMedium initial = NetworkPolicy::bootSelection(
_ethernet.isConnected(), wifiConfigured());
if (initial == NetworkMedium::Ethernet) {
select(initial);
} else {
startWifiFallback();
_selected = initial;
}
return initial != NetworkMedium::None;
}
NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) override {
const NetworkTransition ethernet_transition =
_ethernet.maintain(now_ms, wifi_power_save);
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.
if (_selected == NetworkMedium::Ethernet && _ethernet.isConnected() &&
_wifi_started) {
const bool wifi_had_route = _wifi.isConnected();
select(NetworkMedium::Ethernet);
if (wifi_had_route) return NetworkTransition::Switched;
}
if (_ethernet.isConnected()) {
if (_ethernet_stable_since == 0) _ethernet_stable_since = now_ms;
} else {
_ethernet_stable_since = 0;
}
const bool selected_connected = isConnected();
if (!selected_connected) {
if (_selected_down_since == 0) _selected_down_since = now_ms;
} else {
_selected_down_since = 0;
}
const uint32_t selected_down_ms = _selected_down_since == 0
? 0 : (uint32_t)(now_ms - _selected_down_since);
if (_selected == NetworkMedium::Ethernet && !_ethernet.isConnected() &&
selected_down_ms >= NetworkPolicy::kEthernetDownGraceMs) {
startWifiFallback();
}
const uint32_t ethernet_stable_ms = _ethernet_stable_since == 0
? 0 : (uint32_t)(now_ms - _ethernet_stable_since);
const NetworkPolicy::AutomaticSelectionInput input = {
_selected, _ethernet.isConnected(),
_wifi_started && _wifi.isConnected(), wifiConfigured(),
_switch_locks.load(std::memory_order_relaxed) != 0,
ethernet_stable_ms, selected_down_ms};
const NetworkMedium next = NetworkPolicy::automaticSelection(input);
if (next != _selected) {
const NetworkMedium previous = _selected;
select(next);
return previous == NetworkMedium::None ? NetworkTransition::Up
: NetworkTransition::Switched;
}
if (_selected == NetworkMedium::Ethernet) return ethernet_transition;
if (_selected == NetworkMedium::WiFi) return wifi_transition;
return NetworkTransition::None;
}
void lockSwitching() override {
_switch_locks.fetch_add(1, std::memory_order_relaxed);
}
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 {
return _selected != NetworkMedium::None && selectedInterface().isConnected();
}
IPAddress localIP() const override {
return _selected == NetworkMedium::None ? IPAddress() : selectedInterface().localIP();
}
int rssi() const override {
return _selected == NetworkMedium::None ? INT_MIN : selectedInterface().rssi();
}
bool resolveHost(const char* hostname, IPAddress& address) const override {
return _selected != NetworkMedium::None &&
selectedInterface().resolveHost(hostname, address);
}
unsigned long connectedAtMillis() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().connectedAtMillis();
}
uint8_t lastDisconnectReason() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectReason();
}
unsigned long lastDisconnectTime() const override {
return _selected == NetworkMedium::None ? 0 : selectedInterface().lastDisconnectTime();
}
AlertFaultPolicy::OutageSnapshot outageSnapshot() const override {
return _selected == NetworkMedium::None
? AlertFaultPolicy::OutageSnapshot{false, 0, 0}
: selectedInterface().outageSnapshot();
}
};
#endif
} // namespace
NetworkInterface& activeNetworkInterface() {
#if defined(NETWORK_USE_ETHERNET)
static EthernetNetworkInterface network;
#if defined(NETWORK_PREFER_ETHERNET)
static AutomaticNetworkInterface network;
#else
static WiFiNetworkInterface network;
#endif
+19
View File
@@ -8,6 +8,10 @@
#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.
*
@@ -21,12 +25,27 @@ class NetworkInterface {
virtual ~NetworkInterface() = 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;
virtual bool begin(const char* wifi_ssid, const char* wifi_password) = 0;
virtual NetworkTransition maintain(uint32_t now_ms, uint8_t wifi_power_save) = 0;
// 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.
+70 -2
View File
@@ -6,6 +6,13 @@ enum class NetworkTransition : uint8_t {
None,
Up,
Down,
Switched,
};
enum class NetworkMedium : uint8_t {
None,
Ethernet,
WiFi,
};
namespace NetworkPolicy {
@@ -17,11 +24,72 @@ struct MQTTTransitionActions {
static constexpr MQTTTransitionActions mqttActions(NetworkTransition transition) {
return {
transition == NetworkTransition::Down,
transition == NetworkTransition::Up,
transition == NetworkTransition::Down || transition == NetworkTransition::Switched,
transition == NetworkTransition::Up || transition == NetworkTransition::Switched,
};
}
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 kNtpRetryMs = 30000;
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;
}
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.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,
+18 -12
View File
@@ -1252,6 +1252,9 @@ void MQTTBridge::mqttTaskLoop() {
// Wait a bit for WiFi to start connecting
vTaskDelay(pdMS_TO_TICKS(1000));
bool network_was_connected = _network->isConnected();
unsigned long last_ntp_attempt = 0;
// Main task loop
#ifdef MQTT_MEMORY_DEBUG
static unsigned long last_agent_log = 0;
@@ -1315,23 +1318,26 @@ void MQTTBridge::mqttTaskLoop() {
}
}
// A connected observation is enough to schedule NTP; physical event callbacks
// stay encapsulated in the selected network adapter.
if (!_ntp_synced && _network->isConnected() && !_ntp_sync_pending) {
_ntp_sync_pending = true;
}
if (_ntp_sync_pending && _network->isConnected()) {
// 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 && _network->isConnected()) {
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
+77 -15
View File
@@ -11,6 +11,7 @@
#include <esp_heap_caps.h>
#include <helpers/CommonCLI.h>
#include <helpers/NetworkInterface.h>
#include <helpers/MQTTPacketFilter.h>
#include <helpers/MQTTPresets.h>
#include <helpers/WebConfigKeys.h>
@@ -172,17 +173,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
? activeNetworkInterface().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
// ---------------------------------------------------------------------------
@@ -223,8 +235,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();
activeNetworkInterface().lockSwitching();
_network_locked = true;
_was_setup_ap = true;
_last_activity = millis();
WiFi.scanNetworks(true); // pre-populate the SSID picker
@@ -233,21 +247,42 @@ bool WebConfigServer::startSetupMode(char reply[]) {
return true;
}
bool WebConfigServer::startLanMode(char reply[]) {
bool WebConfigServer::startLanMode(IPAddress ip, 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");
activeNetworkInterface().lockSwitching();
_network_locked = true;
if (!activeNetworkInterface().isConnected() || ip == IPAddress()) {
activeNetworkInterface().unlockSwitching();
_network_locked = false;
strcpy(reply, "Err: selected network not connected");
return false;
}
_initial_setup = initial_setup;
if (_initial_setup) {
for (int i = 0; i < 3; ++i) {
sprintf(&_setup_code[i * 4], "%04X", (unsigned)(esp_random() & 0xffff));
}
_setup_code[12] = 0;
} else {
_setup_code[0] = 0;
}
_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");
}
@@ -317,8 +352,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 {
@@ -336,6 +371,12 @@ void WebConfigServer::finalizeTeardown() {
_batch_reboot_armed = false;
_session_token[0] = 0;
_stats_json[0] = 0;
_setup_code[0] = 0;
_setup_reminder_at = 0;
if (_network_locked) {
activeNetworkInterface().unlockSwitching();
_network_locked = false;
}
if (_cb) _cb->onWebConfigStopped();
}
@@ -358,6 +399,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",
activeNetworkInterface().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);
@@ -459,6 +509,15 @@ void WebConfigServer::drainBatch(uint32_t now) {
return; // more commands next tick
}
}
if (_initial_setup && _admin_pwd_set && _batch_all_ok &&
(_mode == MODE_LAN || _obs->wifi_ssid[0] != '\0')) {
if (_cb->onInitialSetupComplete()) {
_initial_setup = false;
_setup_code[0] = 0;
} else {
_batch_all_ok = false;
}
}
_cb->onConfigBatchEnd();
WCLock lock(_mux);
_batch_state = BATCH_DONE;
@@ -611,7 +670,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]);
@@ -657,7 +716,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;
@@ -836,7 +898,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 +1210,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 — "
+11 -3
View File
@@ -6,8 +6,9 @@
// - SETUP: open SoftAP + captive portal, raised automatically on first boot
// when no WiFi is configured (wifi_ssid empty), or manually via
// `start webconfig ap`. Save -> reboot; auto-stops after an idle timeout.
// - LAN: bound to the existing STA connection (owned by the MQTT bridge
// task), started via `start webconfig`, admin-password login required.
// - LAN: bound to the selected network, started via `start webconfig`,
// admin-password login required. First-run Ethernet uses a random one-time
// login code and requires the operator to replace the admin password.
//
// Concurrency model: AsyncWebServer handlers run on the async_tcp task and
// must never touch the CLI, prefs persistence, or the radio. Config writes
@@ -57,6 +58,9 @@ public:
// `set` handlers can be coalesced into one.
virtual void onConfigBatchStart() {}
virtual void onConfigBatchEnd() {}
// Persist completion of Ethernet onboarding after its password-setting
// batch succeeds. Called from the loop task.
virtual bool onInitialSetupComplete() { return false; }
// Fill buf with the stats JSON snapshot. Called from tick() (loop task).
virtual void buildStatsJson(char* buf, size_t buf_size) = 0;
// Teardown finished (session + DNS freed, WiFi mode restored).
@@ -72,6 +76,7 @@ public:
// Fills the AP SSID and portal IP; either buffer may be NULL to just poll.
// Call from the loop task only (same task that changes the mode).
static bool getSetupInfo(char* ssid, size_t ssid_len, char* ip, size_t ip_len);
static bool isLanSetup();
// For the device display: true once a config save completed and the node is
// about to reboot — ground truth for the user even if the browser lost its
@@ -79,7 +84,7 @@ public:
static bool isRebootPending();
bool startSetupMode(char reply[]); // open SoftAP + DNS captive portal
bool startLanMode(char reply[]); // bind to existing STA connection
bool startLanMode(IPAddress ip, bool initial_setup, char reply[]);
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;
@@ -166,6 +173,7 @@ private:
// LAN-mode session (single slot; new login evicts the old session)
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;
@@ -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) {
@@ -20,6 +20,62 @@ TEST(NetworkPolicy, NoTransitionHasNoMqttSideEffects) {
EXPECT_FALSE(actions.retry_disconnected_slots_now);
}
TEST(NetworkPolicy, LinkSwitchReconnectsMqttSlotsImmediately) {
const auto actions = NetworkPolicy::mqttActions(NetworkTransition::Switched);
EXPECT_TRUE(actions.disconnect_slots);
EXPECT_TRUE(actions.retry_disconnected_slots_now);
}
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, 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, SwitchingLockPinsTheCurrentMedium) {
const NetworkPolicy::AutomaticSelectionInput input = {
NetworkMedium::WiFi, true, true, true, true,
NetworkPolicy::kEthernetFailbackStableMs, 0};
EXPECT_EQ(NetworkMedium::WiFi, NetworkPolicy::automaticSelection(input));
}
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));
+3 -3
View File
@@ -190,7 +190,7 @@ build_src_filter = ${ThinkNode_M7.build_src_filter}
+<../examples/kiss_modem/>
; Wi-Fi remains the default observer transport. Ethernet twins below select the
; onboard CH390 through NETWORK_USE_ETHERNET without enabling the legacy CLI
; onboard CH390 through the Ethernet-preferred network manager without enabling the legacy CLI
; transport. The board has PSRAM, so MAX_NEIGHBOURS enables
; WITH_MQTT_NEIGHBORS (see MQTTBridge.h).
[env:ThinkNode_M7_repeater_observer_mqtt]
@@ -242,7 +242,7 @@ extends = env:ThinkNode_M7_repeater_observer_mqtt
build_flags =
${env:ThinkNode_M7_repeater_observer_mqtt.build_flags}
${ThinkNode_M7_ch390.build_flags}
-D NETWORK_USE_ETHERNET=1
-D NETWORK_PREFER_ETHERNET=1
build_src_filter = ${env:ThinkNode_M7_repeater_observer_mqtt.build_src_filter}
lib_deps =
${env:ThinkNode_M7_repeater_observer_mqtt.lib_deps}
@@ -298,7 +298,7 @@ extends = env:ThinkNode_M7_room_server_observer_mqtt
build_flags =
${env:ThinkNode_M7_room_server_observer_mqtt.build_flags}
${ThinkNode_M7_ch390.build_flags}
-D NETWORK_USE_ETHERNET=1
-D NETWORK_PREFER_ETHERNET=1
build_src_filter = ${env:ThinkNode_M7_room_server_observer_mqtt.build_src_filter}
lib_deps =
${env:ThinkNode_M7_room_server_observer_mqtt.lib_deps}
+1
View File
@@ -1546,6 +1546,7 @@ 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],
["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],