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
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user