use a state machine for cycling between Eth-Wifi-AP

This commit is contained in:
proddy
2026-05-03 21:59:28 +02:00
parent e2bd721c3e
commit ca94e37495
2 changed files with 139 additions and 59 deletions

View File

@@ -67,11 +67,15 @@ void Network::begin() {
// Initialise WiFi - we only do this once, when the network service is started. // Initialise WiFi - we only do this once, when the network service is started.
// We want the device to come up in opmode=0 (WIFI_OFF), which is not the default after a flash erase. // WiFi.mode(WIFI_OFF); // we want the device to come up in opmode=0 (WIFI_OFF), which is not the default after a flash erase
// Persistence is true by default, so this WiFi.mode() call writes opmode=0 to NVS for future boots.
// WiFi.mode(WIFI_OFF);
// if Wifi is disabled, with no SSID, stop here // pick the first usable phase based on what's actually configured on this device.
// done up-front so the early-return paths below still leave us in a sane phase
// (e.g. on a board with no SSID and no Ethernet PHY we want to land in AP without
// burning a 4-tick Ethernet timeout first)
phase_ = initialPhase();
// if Wifi is disabled, or with no SSID, don't initialise WiFi
if (ssid_.isEmpty()) { if (ssid_.isEmpty()) {
WiFi.mode(WIFI_OFF); WiFi.mode(WIFI_OFF);
return; return;
@@ -99,20 +103,19 @@ void Network::begin() {
WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE
} }
wifi_connect_pending_ = false; // set before begin() so the event handlers can race-clear it safely // set before begin() so the event handlers can race-clear it safely
wifi_connect_pending_ = false;
ethernet_connect_pending_ = false;
// scan settings give connect issues since arduino 2.0.14 and arduino 3.x.x with some wifi systems // scan settings give connect issues since arduino 2.0.14 and arduino 3.x.x with some wifi systems
// WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN // WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN
// WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set // WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set
// arduino-esp32's WiFi.onEvent() simply appends to an internal callback list with no // avoid duplicate registration, so register the lambdas only once across the lifetime of this Network instance
// de-duplication, so register the lambdas only once across the lifetime of this Network instance
if (!wifi_events_registered_) { if (!wifi_events_registered_) {
wifi_events_registered_ = true; wifi_events_registered_ = true;
// Defer Tx power setting until STA is actually started. Calling WiFi.setTxPower() before // Defer Tx power setting until STA is actually started
// WIFI_EVENT_STA_START fires would fail with "Neither AP or STA has been started" because
// WiFi.STA.started() only flips after esp_wifi_start() raises the event asynchronously.
WiFi.onEvent( WiFi.onEvent(
[this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) {
#ifdef BOARD_C3_MINI_V1 #ifdef BOARD_C3_MINI_V1
@@ -128,11 +131,6 @@ void Network::begin() {
// capture the WIFI_REASON_* code on every STA disconnect event so check_connection() can // capture the WIFI_REASON_* code on every STA disconnect event so check_connection() can
// log a meaningful reason when its periodic poll notices we're no longer associated. // log a meaningful reason when its periodic poll notices we're no longer associated.
// Also release the connect-pending guard so the next loop tick can issue a fresh WiFi.begin().
// The first STA_DISCONNECTED after boot is suppressed because arduino-esp32 hard-codes a
// "retry once on first_connect" inside its own STA event handler (see STA.cpp), so a
// transient initial AUTH_FAIL / NO_AP_FOUND / etc. is automatically retried and almost
// always succeeds; logging it as a WARNING is misleading noise.
WiFi.onEvent( WiFi.onEvent(
[this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) { [this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) {
last_disconnect_reason_ = info.wifi_sta_disconnected.reason; last_disconnect_reason_ = info.wifi_sta_disconnected.reason;
@@ -235,19 +233,35 @@ void Network::reconnect() {
#endif #endif
// reset network state // reset network state
network_ip_ = 0; network_ip_ = 0;
network_iface_ = NetIface::NONE; network_iface_ = NetIface::NONE;
has_ipv6_ = false; has_ipv6_ = false;
juststopped_ = false; juststopped_ = false;
wifi_connect_pending_ = false; wifi_connect_pending_ = false;
last_disconnect_reason_ = 0; ethernet_connect_pending_ = false;
connect_retry_ = 0; last_disconnect_reason_ = 0;
reconnect_count_ = 0; connect_retry_ = 0;
reconnect_count_ = 0;
phase_ = NetPhase::ETHERNET; // begin() will refine this once settings are reloaded
// reload the network settings and apply them // reload the network settings and apply them
begin(); begin();
} }
// pick the first phase that has the hardware/config to even be attempted on this device.
// boards without an Ethernet PHY skip straight to WIFI; without a configured SSID, straight to AP.
NetPhase Network::initialPhase() const {
#ifndef EMSESP_STANDALONE
if (phy_type_ != PHY_type::PHY_TYPE_NONE) {
return NetPhase::ETHERNET;
}
if (!ssid_.isEmpty()) {
return NetPhase::WIFI;
}
#endif
return NetPhase::AP;
}
// network loop, looking for new and disconnecting networks // network loop, looking for new and disconnecting networks
void Network::loop() { void Network::loop() {
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
@@ -258,13 +272,12 @@ void Network::loop() {
if (!lastConnectionAttempt_ || static_cast<uint32_t>(currentMillis - lastConnectionAttempt_) >= reconnectDelay) { if (!lastConnectionAttempt_ || static_cast<uint32_t>(currentMillis - lastConnectionAttempt_) >= reconnectDelay) {
lastConnectionAttempt_ = currentMillis; lastConnectionAttempt_ = currentMillis;
// manage network interfaces // manage the network interfaces: Ethernet, WiFi and AP in that order
startEthernet(); // Ethernet startEthernet(); // Ethernet
startWIFI(); // WiFi startWIFI(); // WiFi
startAP(); // Captive Portal (AP) startAP(); // Captive Portal (AP)
// already have a connection: verify it's still alive // already have a connection: verify it's still alive
// or trigger if the WiFi handshaked failed on the WiFi.begin() call
if (network_ip_ != 0 || last_disconnect_reason_ != 0) { if (network_ip_ != 0 || last_disconnect_reason_ != 0) {
checkConnection(); checkConnection();
} }
@@ -306,15 +319,18 @@ void Network::checkConnection() {
if (reason == 0) { if (reason == 0) {
reason = WIFI_REASON_UNSPECIFIED; // event hasn't fired yet (or was cleared); avoid logging "0" reason = WIFI_REASON_UNSPECIFIED; // event hasn't fired yet (or was cleared); avoid logging "0"
} }
wifi_connect_pending_ = false;
LOG_INFO("WiFi connection lost (reason %u: %s)", reason, disconnectReason(reason)); LOG_INFO("WiFi connection lost (reason %u: %s)", reason, disconnectReason(reason));
wifi_connect_pending_ = false;
} else if (network_iface_ == NetIface::ETHERNET) { } else if (network_iface_ == NetIface::ETHERNET) {
LOG_INFO("Ethernet connection lost"); LOG_INFO("Ethernet connection lost");
ethernet_connect_pending_ = false;
} }
juststopped_ = true; juststopped_ = true;
network_iface_ = NetIface::NONE; network_iface_ = NetIface::NONE;
network_ip_ = 0; network_ip_ = 0;
has_ipv6_ = false; has_ipv6_ = false;
connect_retry_ = 0;
phase_ = initialPhase();
} }
#endif #endif
} }
@@ -322,12 +338,12 @@ void Network::checkConnection() {
// set the WiFi TxPower based on the RSSI (signal strength), picking the lowest value // set the WiFi TxPower based on the RSSI (signal strength), picking the lowest value
// code is based of RSSI (signal strength) and copied from Tasmota's WiFiSetTXpowerBasedOnRssi() which is copied ESPEasy's ESPEasyWifi.SetWiFiTXpower() function // code is based of RSSI (signal strength) and copied from Tasmota's WiFiSetTXpowerBasedOnRssi() which is copied ESPEasy's ESPEasyWifi.SetWiFiTXpower() function
// Range ESP32 : 2dBm - 20dBm // Range ESP32 : 2dBm - 20dBm
// 802.11b - wifi1 // 802.11b - wifi1
// 802.11a - wifi2 // 802.11a - wifi2
// 802.11g - wifi3 // 802.11g - wifi3
// 802.11n - wifi4 // 802.11n - wifi4
// 802.11ac - wifi5 // 802.11ac - wifi5
// 802.11ax - wifi6 // 802.11ax - wifi6
// tx_power is the Tx power to set, 0 for auto // tx_power is the Tx power to set, 0 for auto
void Network::setWiFiPower(uint8_t tx_power) { void Network::setWiFiPower(uint8_t tx_power) {
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
@@ -341,7 +357,7 @@ void Network::setWiFiPower(uint8_t tx_power) {
int max_tx_pwr = MAX_TX_PWR_DBM_n; // assume wifi4 int max_tx_pwr = MAX_TX_PWR_DBM_n; // assume wifi4
int threshold = WIFI_SENSITIVITY_n + 120; // Margin in dBm * 10 on top of threshold int threshold = WIFI_SENSITIVITY_n + 120; // Margin in dBm * 10 on top of threshold
// Assume AP sends with max set by ETSI standard. // Assume AP sends with max set by ETSI standard
// 2.4 GHz: 100 mWatt (20 dBm) // 2.4 GHz: 100 mWatt (20 dBm)
// US and some other countries allow 1000 mW (30 dBm) // US and some other countries allow 1000 mW (30 dBm)
int rssi = WiFi.RSSI() * 10; int rssi = WiFi.RSSI() * 10;
@@ -381,7 +397,7 @@ void Network::setWiFiPower(uint8_t tx_power) {
#endif #endif
if (!WiFi.setTxPower(p)) { if (!WiFi.setTxPower(p)) {
emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power!!"); LOG_DEBUG("Failed to set WiFi Tx Power");
} }
#endif #endif
} }
@@ -491,14 +507,19 @@ const char * Network::disconnectReason(uint8_t code) {
// WiFi management // WiFi management
void Network::startWIFI() { void Network::startWIFI() {
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
// exit if WiFi is already connected, or if we have no SSID or another Wifi.begin() is already in progress // only run during the WIFI phase; ETHERNET phase keeps WiFi quiet, AP phase has its own bring-up
if (WiFi.isConnected() || ssid_.isEmpty() || wifi_connect_pending_) { if (phase_ != NetPhase::WIFI) {
return;
}
// exit if WiFi or Ethernet is already connected, or if we have no SSID or another Wifi.begin() is already in progress
if (WiFi.isConnected() || ssid_.isEmpty() || ethernet_connected() || wifi_connect_pending_) {
return; return;
} }
wifi_connect_pending_ = true; wifi_connect_pending_ = true;
// LOG_DEBUG("WiFi connection with %s and %s", ssid_.c_str(), password_.c_str()); LOG_DEBUG("WiFi connection with %s and %s", ssid_.c_str(), password_.c_str());
// attempt to connect to the wifi network // attempt to connect to the wifi network
// the event handlers handle error handling and retries // the event handlers handle error handling and retries
@@ -513,12 +534,17 @@ void Network::startWIFI() {
// Ethernet management // Ethernet management
// Brings up the ETH driver / netif exactly once. After ETH.begin() returns true the driver // Brings up the ETH driver / netif exactly once. After ETH.begin() returns true the driver
// continues to run autonomously (link negotiation, DHCP, etc); the loop must NOT call ETH.begin() // continues to run autonomously (link negotiation, DHCP, etc)
// again on every iteration because that thrashes the netif and can prevent DHCP from completing
void Network::startEthernet() { void Network::startEthernet() {
#if CONFIG_IDF_TARGET_ESP32 #if CONFIG_IDF_TARGET_ESP32
// only run during the ETHERNET phase; once we've given up on Ethernet, the driver is left
// running in case the link comes up later, but we no longer try to (re)start it here
if (phase_ != NetPhase::ETHERNET) {
return;
}
// already up and running, nothing to do // already up and running, nothing to do
if (eth_started_) { if (ethernet_connect_pending_ || ethernet_connected()) {
return; return;
} }
@@ -529,6 +555,12 @@ void Network::startEthernet() {
return; return;
} }
// disabled Ethernet for boards with only 4MB flash and no PSRAM
if (ESP.getFlashChipSize() < 4194304 && !ESP.getPsramSize()) { // 4MB
LOG_NOTICE("Ethernet disabled for boards with only 4MB flash");
return;
}
// reset power and add a delay as ETH doesn't not always start up correctly after a warm boot // reset power and add a delay as ETH doesn't not always start up correctly after a warm boot
if (eth_power_ != -1) { if (eth_power_ != -1) {
pinMode(eth_power_, OUTPUT); pinMode(eth_power_, OUTPUT);
@@ -537,11 +569,10 @@ void Network::startEthernet() {
digitalWrite(eth_power_, HIGH); digitalWrite(eth_power_, HIGH);
} }
// call to ETH.begin(type, phy_addr, mdc, mdio, power, clock_mode) // mdc = 23 = Pin# of the I²C clock signal for the Ethernet PHY
// mdc = 23 = Pin# of the I²C clock signal for the Ethernet PHY - hardcoded // mdio = 18 = Pin# of the I²C IO signal for the Ethernet PHY
// mdio = 18 = Pin# of the I²C IO signal for the Ethernet PHY - hardcoded // phy_addr = I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110)
// phy_addr = eth_phy_addr_ = I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) // power = Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source)
// power = eth_power_ = Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source)
// type = Type of the Ethernet PHY (LAN8720 or TLK110) // type = Type of the Ethernet PHY (LAN8720 or TLK110)
// clock_mode = // clock_mode =
// ETH_CLOCK_GPIO0_IN = 0 RMII clock input to GPIO0 // ETH_CLOCK_GPIO0_IN = 0 RMII clock input to GPIO0
@@ -552,15 +583,15 @@ void Network::startEthernet() {
: (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_TLK110 : (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_TLK110
: ETH_PHY_RTL8201; // Type of the Ethernet PHY (LAN8720 or TLK110) : ETH_PHY_RTL8201; // Type of the Ethernet PHY (LAN8720 or TLK110)
if (ETH.begin(type, eth_phy_addr_, 23, 18, eth_power_, (eth_clock_mode_t)eth_clock_mode_)) { if (ETH.begin(type, eth_phy_addr_, 23, 18, eth_power_, (eth_clock_mode_t)eth_clock_mode_)) {
LOG_DEBUG("Ethernet started"); LOG_DEBUG("Ethernet module found - starting");
eth_started_ = true; // mark up; do not re-enter this block until reboot / explicit teardown ethernet_connect_pending_ = true;
ETH.setHostname(hostname_.c_str()); // Push hostname to the ETH netif immediately after it's created ETH.setHostname(hostname_.c_str()); // Push hostname to the ETH netif immediately after it's created
ETH.enableIPv6(true); ETH.enableIPv6(true);
if (staticIPConfig_) { if (staticIPConfig_) {
ETH.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); ETH.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_);
} }
} else { } else {
LOG_ERROR("Failed to start Ethernet"); LOG_ERROR("Failed to start Ethernet module");
} }
#endif #endif
#endif #endif
@@ -631,8 +662,6 @@ void Network::findNetworks() {
} }
} }
// LOG_DEBUG("best_iface: %d, network_iface_: %d", best_iface, network_iface_);
// if we have a connection and it's a new one, set it up // if we have a connection and it's a new one, set it up
if (best_iface != NetIface::NONE && best_iface != network_iface_) { if (best_iface != NetIface::NONE && best_iface != network_iface_) {
network_ip_ = info.ip.addr; network_ip_ = info.ip.addr;
@@ -640,6 +669,17 @@ void Network::findNetworks() {
has_ipv6_ = info.has_ipv6; has_ipv6_ = info.has_ipv6;
connect_retry_ = 0; connect_retry_ = 0;
// sync the phase to the interface that actually came up. ETH can come up late
// (e.g. cable plugged in after we'd already moved on to WiFi/AP) and we want the
// next disconnect-driven retry cycle to start from the right place.
if (network_iface_ == NetIface::ETHERNET) {
phase_ = NetPhase::ETHERNET;
} else if (network_iface_ == NetIface::WIFI) {
phase_ = NetPhase::WIFI;
} else if (network_iface_ == NetIface::AP) {
phase_ = NetPhase::AP;
}
LOG_INFO("Network connected via %s (IP: " IPSTR ")", LOG_INFO("Network connected via %s (IP: " IPSTR ")",
network_iface_ == NetIface::ETHERNET ? "Ethernet" network_iface_ == NetIface::ETHERNET ? "Ethernet"
: network_iface_ == NetIface::WIFI ? "WiFi" : network_iface_ == NetIface::WIFI ? "WiFi"
@@ -675,7 +715,31 @@ void Network::findNetworks() {
network_iface_ = NetIface::NONE; network_iface_ = NetIface::NONE;
has_ipv6_ = false; has_ipv6_ = false;
connect_retry_++; connect_retry_++;
LOG_DEBUG("No active network interfaces found yet (retry #%d)", connect_retry_); LOG_DEBUG("Looking for network interfaces (retry #%d, phase=%s)",
connect_retry_,
phase_ == NetPhase::ETHERNET ? "Ethernet"
: phase_ == NetPhase::WIFI ? "WiFi"
: "AP");
// give up on this interface and try the next one. ETHERNET -> WIFI (or straight to AP if no SSID configured); WIFI -> AP.
if (connect_retry_ >= MAX_NETWORK_RECONNECTION_ATTEMPTS && phase_ != NetPhase::AP) {
if (phase_ == NetPhase::ETHERNET) {
if (ssid_.isEmpty()) {
LOG_WARNING("Ethernet failed to connect after %u attempts, falling back to AP", connect_retry_);
phase_ = NetPhase::AP;
} else {
LOG_WARNING("Ethernet failed to connect after %u attempts, switching to WiFi", connect_retry_);
phase_ = NetPhase::WIFI;
}
ethernet_connect_pending_ = false;
} else if (phase_ == NetPhase::WIFI) {
LOG_WARNING("WiFi failed to connect after %u attempts, falling back to AP", connect_retry_);
phase_ = NetPhase::AP;
wifi_connect_pending_ = false;
WiFi.disconnect(true);
}
connect_retry_ = 0;
}
} }
#endif #endif
@@ -685,8 +749,8 @@ void Network::findNetworks() {
// access point (soft-AP) and the captive portal // access point (soft-AP) and the captive portal
void Network::startAP() { void Network::startAP() {
#ifndef EMSESP_STANDALONE #ifndef EMSESP_STANDALONE
// Only start AP as a fallback if the Network has failed // only start AP as a fallback once both the Ethernet and WiFi phases have given up
if (connect_retry_ < MAX_NETWORK_RECONNECTION_ATTEMPTS) { if (phase_ != NetPhase::AP) {
return; return;
} }

View File

@@ -36,7 +36,12 @@
namespace emsesp { namespace emsesp {
#define NETWORK_RECONNECTION_DELAY_SHORT 5000 // 5 seconds #define NETWORK_RECONNECTION_DELAY_SHORT 5000 // 5 seconds
#ifndef EMSESP_DEBUG
#define NETWORK_RECONNECTION_DELAY_LONG 60000 // 1 minute #define NETWORK_RECONNECTION_DELAY_LONG 60000 // 1 minute
#else
#define NETWORK_RECONNECTION_DELAY_LONG 10000 // 10 seconds - for debugging
#endif
#define MAX_NETWORK_RECONNECTION_ATTEMPTS 4 // maximum number of network reconnection attempts before going to AP fallback #define MAX_NETWORK_RECONNECTION_ATTEMPTS 4 // maximum number of network reconnection attempts before going to AP fallback
@@ -75,15 +80,23 @@ namespace emsesp {
// which physical interface we are currently using for the active network connection. // which physical interface we are currently using for the active network connection.
// Mapped from the esp-netif description string returned by esp_netif_get_desc(): "sta" -> WIFI, // Mapped from the esp-netif description string returned by esp_netif_get_desc(): "sta" -> WIFI,
// "ap" -> AP, "eth"/"eth1"/"eth2"/... (arduino-esp32 v3.x suffixes ETH netifs because it supports // "ap" -> AP, "eth"/"eth1"/"eth2"/...
// multiple ETH instances) -> ETHERNET. Anything else stays as NONE.
enum class NetIface : uint8_t { enum class NetIface : uint8_t {
NONE = 0, NONE = 0, // 0
WIFI, // 1 WIFI, // 1
ETHERNET, // 2 ETHERNET, // 2
AP, // 3 AP, // 3
}; };
// Connection bring-up state machine. We try the "real" interfaces first (Ethernet, then WiFi)
// each with its own MAX_NETWORK_RECONNECTION_ATTEMPTS budget, before falling back to the soft-AP
// captive portal. Phase is advanced in find_networks() once retries are exhausted.
enum class NetPhase : uint8_t {
ETHERNET = 0,
WIFI = 1,
AP = 2,
};
class Network { class Network {
public: public:
void begin(); void begin();
@@ -173,6 +186,7 @@ class Network {
void setWiFiPower(uint8_t tx_power); void setWiFiPower(uint8_t tx_power);
const char * disconnectReason(uint8_t code); const char * disconnectReason(uint8_t code);
void stopAP(); void stopAP();
NetPhase initialPhase() const;
#if defined(__clang__) #if defined(__clang__)
#pragma clang diagnostic push #pragma clang diagnostic push
@@ -185,14 +199,16 @@ class Network {
NetIface network_iface_ = NetIface::NONE; NetIface network_iface_ = NetIface::NONE;
bool has_ipv6_ = false; bool has_ipv6_ = false;
bool juststopped_ = false; bool juststopped_ = false;
bool eth_started_ = false; // true after ETH.begin() has succeeded once; prevents repeated re-init while DHCP is still running
volatile uint8_t last_disconnect_reason_ = 0; volatile uint8_t last_disconnect_reason_ = 0;
uint16_t connect_retry_ = 0; // number of network re-connection attempts uint16_t connect_retry_ = 0; // number of network re-connection attempts
volatile bool wifi_connect_pending_ = false; volatile bool wifi_connect_pending_ = false;
volatile bool ethernet_connect_pending_ = false;
NetPhase phase_ = NetPhase::ETHERNET;
bool wifi_events_registered_ = false; // ensure WiFi.onEvent() handlers are registered only once across begin()/reconnect() cycles bool wifi_events_registered_ = false; // ensure WiFi.onEvent() handlers are registered only once across begin()/reconnect() cycles
bool wifi_ever_connected_ = false; // set true once we've successfully obtained an IP; used to silence the harmless first-attempt disconnect emitted by arduino-esp32's built-in retry-once behaviour bool wifi_ever_connected_ = false; // set true once we've successfully obtained an IP
// Network and AP settings // Network and AP settings
bool enableMDNS_; bool enableMDNS_;