feat: add automatic network failover
This commit is contained in:
1 parent
03c0ad81fe
commit
366b086713
23 files changed
+600
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,"
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 — "
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user