From 9ff4be41f754352ffcd14f0d8b267b488058b904 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 08:43:28 +0200 Subject: [PATCH 1/8] getWifiReconnects -> getNetworkReconnects --- src/ESP32React/NetworkStatus.cpp | 2 +- src/core/network.h | 6 +----- src/core/system.cpp | 4 ++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/ESP32React/NetworkStatus.cpp b/src/ESP32React/NetworkStatus.cpp index cbfbe5152..e01ee2fb5 100644 --- a/src/ESP32React/NetworkStatus.cpp +++ b/src/ESP32React/NetworkStatus.cpp @@ -51,7 +51,7 @@ void NetworkStatus::networkStatus(AsyncWebServerRequest * request) { root["ssid"] = WiFi.SSID(); root["bssid"] = WiFi.BSSIDstr(); root["channel"] = WiFi.channel(); - root["reconnect_count"] = emsesp::EMSESP::network_.getWifiReconnects(); + root["reconnect_count"] = emsesp::EMSESP::network_.getNetworkReconnects(); root["subnet_mask"] = WiFi.subnetMask().toString(); if (WiFi.gatewayIP() != INADDR_NONE) { diff --git a/src/core/network.h b/src/core/network.h index 577f3f5a4..69c061768 100644 --- a/src/core/network.h +++ b/src/core/network.h @@ -89,10 +89,6 @@ class Network { void begin(); void loop(); - uint16_t getWifiReconnects() const { - return connectcount_; - } - uint32_t network_ip() const { return network_ip_; } @@ -121,7 +117,7 @@ class Network { return has_ipv6_ && (network_iface_ == NetIface::WIFI || network_iface_ == NetIface::ETHERNET); } - uint16_t getWifiReconnects() { + uint16_t getNetworkReconnects() { return connectcount_; } diff --git a/src/core/system.cpp b/src/core/system.cpp index b420e13b1..57357ca08 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -998,7 +998,7 @@ void System::heartbeat_json(JsonObject output) { int8_t rssi = WiFi.RSSI(); output["rssi"] = rssi; output["wifistrength"] = wifi_quality(rssi); - output["wifireconnects"] = EMSESP::network_.getWifiReconnects(); + output["wifireconnects"] = EMSESP::network_.getNetworkReconnects(); } #endif @@ -2451,7 +2451,7 @@ bool System::command_info(const char * value, const int8_t id, JsonObject output node["network"] = "WiFi"; node["hostname"] = WiFi.getHostname(); node["RSSI"] = WiFi.RSSI(); - node["WIFIReconnects"] = EMSESP::network_.getWifiReconnects(); + node["WIFIReconnects"] = EMSESP::network_.getNetworkReconnects(); // node["MAC"] = WiFi.macAddress(); // node["IPv4 address"] = uuid::printable_to_string(WiFi.localIP()) + "/" + uuid::printable_to_string(WiFi.subnetMask()); // node["IPv4 gateway"] = uuid::printable_to_string(WiFi.gatewayIP()); From 666ba41f67d830013ff279bc7d7a67115b749740 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 14:24:36 +0200 Subject: [PATCH 2/8] package update --- interface/pnpm-lock.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index b19239666..44941caf4 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -2993,9 +2993,9 @@ packages: resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' - stack-trace@1.0.0-pre2: - resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} - engines: {node: '>=16'} + stack-trace@1.0.0: + resolution: {integrity: sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==} + engines: {node: '>=20.0.0'} strict-uri-encode@1.1.0: resolution: {integrity: sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==} @@ -6178,7 +6178,7 @@ snapshots: stable@0.1.8: {} - stack-trace@1.0.0-pre2: {} + stack-trace@1.0.0: {} strict-uri-encode@1.1.0: {} @@ -6420,7 +6420,7 @@ snapshots: node-html-parser: 6.1.13 simple-code-frame: 1.3.0 source-map: 0.7.6 - stack-trace: 1.0.0-pre2 + stack-trace: 1.0.0 vite: 8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2) vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.4)(terser@5.46.2): From eab7cdd7b541eb4a7ab5424b6ae511d2530f5be7 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 15:19:49 +0200 Subject: [PATCH 3/8] updates --- src/ESP32React/NetworkSettingsService.cpp | 10 +- src/core/network.cpp | 259 +++++++++++++--------- src/core/network.h | 41 +++- src/core/system.cpp | 17 +- src/web/WebSettingsService.cpp | 1 - src/web/WebStatusService.cpp | 18 +- src/web/WebStatusService.h | 14 +- 7 files changed, 219 insertions(+), 141 deletions(-) diff --git a/src/ESP32React/NetworkSettingsService.cpp b/src/ESP32React/NetworkSettingsService.cpp index af80fe049..9b67c8dff 100644 --- a/src/ESP32React/NetworkSettingsService.cpp +++ b/src/ESP32React/NetworkSettingsService.cpp @@ -72,12 +72,6 @@ StateUpdateResult NetworkSettings::update(JsonObject root, NetworkSettings & set settings.staticIPConfig = false; } - // see if we need to inform the user of a restart - // if tx power, enableCORS, CORSOrigin, ssid changes, we need to restart - if (tx_power != settings.tx_power || enableCORS != settings.enableCORS || CORSOrigin != settings.CORSOrigin - || (ssid != settings.ssid && settings.ssid.isEmpty())) { - return StateUpdateResult::CHANGED_RESTART; // tell WebUI that a restart is needed - } - - return StateUpdateResult::CHANGED; + // always best to do a restart after changing network settings + return StateUpdateResult::CHANGED_RESTART; } \ No newline at end of file diff --git a/src/core/network.cpp b/src/core/network.cpp index 0619017e4..e303de841 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -26,8 +26,8 @@ uuid::log::Logger Network::logger_{F_(network), uuid::log::Facility::KERN}; void Network::begin() { #ifndef EMSESP_STANDALONE - // pull Network settings and store locally on stack - EMSESP::esp32React.getNetworkSettingsService()->read([&](auto & settings) { + // pull all settings and store locally + EMSESP::esp32React.getNetworkSettingsService()->read([&](NetworkSettings & settings) { enableMDNS_ = settings.enableMDNS; staticIPConfig_ = settings.staticIPConfig; localIP_ = settings.localIP; @@ -66,36 +66,96 @@ void Network::begin() { }); // 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. // Persistence is true by default, so this WiFi.mode() call writes opmode=0 to NVS for future boots. - WiFi.mode(WIFI_OFF); + // WiFi.mode(WIFI_OFF); + + // if Wifi is disabled, with no SSID, stop here + if (ssid_.isEmpty()) { + WiFi.mode(WIFI_OFF); + return; + } - // From here on, mode changes stay in RAM only and don't touch NVS WiFi.persistent(false); WiFi.setAutoReconnect(false); WiFi.mode(WIFI_STA); + WiFi.disconnect(true); // wipe old settings in NVS + WiFi.setHostname(hostname_.c_str()); // updates shared default_hostname buffer + WiFi.enableSTA(true); // creates the STA netif + WiFi.STA.setHostname(hostname_.c_str()); // pushes to esp_netif_set_hostname + WiFi.enableIPv6(true); + if (staticIPConfig_) { + WiFi.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); // configure for static IP + } + + // www.esp32.com/viewtopic.php?t=12055 + if (bandwidth20_) { + esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT20); + } else { + esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT40); + } + if (nosleep_) { + 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 // 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.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set - // 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. - // Also release the connect-pending guard so the next loop tick can issue a fresh WiFi.begin() - WiFi.onEvent( - [this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) { - last_disconnect_reason_ = info.wifi_sta_disconnected.reason; - wifi_connect_pending_ = false; - }, - ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + // arduino-esp32's WiFi.onEvent() simply appends to an internal callback list with no + // de-duplication, so register the lambdas only once across the lifetime of this Network instance + if (!wifi_events_registered_) { + wifi_events_registered_ = true; + + // Defer Tx power setting until STA is actually started. Calling WiFi.setTxPower() before + // 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( + [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { +#ifdef BOARD_C3_MINI_V1 + // always hardcode Tx power for Wemos C3 Mini v1 + // v1 needs this value, see https://github.com/emsesp/EMS-ESP32/pull/620#discussion_r993173979 + // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi + WiFi.setTxPower(WIFI_POWER_8_5dBm); +#else + setWiFiPower(tx_power_); +#endif + }, + ARDUINO_EVENT_WIFI_STA_START); + + // 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. + // 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( + [this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) { + last_disconnect_reason_ = info.wifi_sta_disconnected.reason; + wifi_connect_pending_ = false; + if (wifi_ever_connected_) { + LOG_WARNING("WiFi disconnected (reason: %s)", disconnectReason(last_disconnect_reason_)); + } else { + LOG_WARNING("WiFi initial connect attempt failed (reason: %s)", disconnectReason(last_disconnect_reason_)); + } + }, + ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + + // clear the saved reason and the connect-pending guard on a fresh STA association, + // and latch wifi_ever_connected_ so future disconnects log as warnings + WiFi.onEvent( + [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { + last_disconnect_reason_ = 0; + wifi_connect_pending_ = false; + wifi_ever_connected_ = true; + }, + ARDUINO_EVENT_WIFI_STA_GOT_IP); + } - // clear the saved reason and the connect-pending guard on a fresh STA association - WiFi.onEvent( - [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { - last_disconnect_reason_ = 0; - wifi_connect_pending_ = false; - }, - ARDUINO_EVENT_WIFI_STA_GOT_IP); #endif } @@ -159,12 +219,13 @@ uint8_t Network::getStationNum() const { // disconnect all WiFi, Eth and AP // so we can starts searching again to reconnect void Network::reconnect() { - LOG_DEBUG("Disconnecting all networks"); + LOG_DEBUG("Reconnecting all networks"); #ifndef EMSESP_STANDALONE // disconnect WiFi if (wifi_connected()) { - WiFi.disconnect(true); + WiFi.disconnect(true, true); + WiFi.mode(WIFI_STA); // reset mode } // disconnect AP @@ -177,11 +238,13 @@ void Network::reconnect() { network_ip_ = 0; network_iface_ = NetIface::NONE; has_ipv6_ = false; - juststopped_ = true; + juststopped_ = false; wifi_connect_pending_ = false; last_disconnect_reason_ = 0; + connect_retry_ = 0; + reconnect_count_ = 0; - // reload the network settings, as this could be called from the console + // reload the network settings and apply them begin(); } @@ -196,16 +259,17 @@ void Network::loop() { lastConnectionAttempt_ = currentMillis; // manage network interfaces - startAP(); // Captive Portal (AP) - startWIFI(); // WiFi startEthernet(); // Ethernet + startWIFI(); // WiFi + startAP(); // Captive Portal (AP) // 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) { checkConnection(); } - findNetworks(); // detect new connections + + findNetworks(); // detect any new network connections } // process DNS requests for the captive portal while the soft-AP is up @@ -219,6 +283,10 @@ void Network::loop() { // if a netif is no longer up or has lost its IP (cable unplugged, AP gone, DHCP lease lost, ...) we drop our state so // find_networks() can pick up a new one void Network::checkConnection() { + if (network_iface_ == NetIface::NONE) { + return; + } + #ifndef EMSESP_STANDALONE bool still_up = false; for (esp_netif_t * netif = esp_netif_next_unsafe(NULL); netif != NULL; netif = esp_netif_next_unsafe(netif)) { @@ -238,11 +306,15 @@ void Network::checkConnection() { if (reason == 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)); - } else { + } else if (network_iface_ == NetIface::ETHERNET) { LOG_INFO("Ethernet connection lost"); } - reconnect(); + juststopped_ = true; + network_iface_ = NetIface::NONE; + network_ip_ = 0; + has_ipv6_ = false; } #endif } @@ -309,7 +381,7 @@ void Network::setWiFiPower(uint8_t tx_power) { #endif if (!WiFi.setTxPower(p)) { - emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power"); + emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power!!"); } #endif } @@ -419,53 +491,23 @@ const char * Network::disconnectReason(uint8_t code) { // WiFi management void Network::startWIFI() { #ifndef EMSESP_STANDALONE - // Abort if already connected, or if we have no SSID or another Wifi.begin() is already in progress - if (WiFi.isConnected() || ssid_.length() == 0 || wifi_connect_pending_) { + // exit if WiFi is already connected, or if we have no SSID or another Wifi.begin() is already in progress + if (WiFi.isConnected() || ssid_.isEmpty() || wifi_connect_pending_) { return; } - WiFi.setHostname(hostname_.c_str()); // updates shared default_hostname buffer - WiFi.enableSTA(true); // creates the STA netif - WiFi.STA.setHostname(hostname_.c_str()); // pushes to esp_netif_set_hostname - WiFi.enableIPv6(true); - if (staticIPConfig_) { - WiFi.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); // configure for static IP - } + wifi_connect_pending_ = true; - // www.esp32.com/viewtopic.php?t=12055 - if (bandwidth20_) { - esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT20); - } else { - esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_STA), WIFI_BW_HT40); - } - if (nosleep_) { - WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE - } - - // attempt to connect to the network - uint8_t bssid[6]; - wl_status_t status; - wifi_connect_pending_ = true; // set before begin() so the event handlers can race-clear it safely + // LOG_DEBUG("WiFi connection with %s and %s", ssid_.c_str(), password_.c_str()); + // attempt to connect to the wifi network + // the event handlers handle error handling and retries + uint8_t bssid[6]; if (formatBSSID(bssid_, bssid)) { - status = WiFi.begin(ssid_.c_str(), password_.c_str(), 0, bssid); + WiFi.begin(ssid_.c_str(), password_.c_str(), 0, bssid); } else { - status = WiFi.begin(ssid_.c_str(), password_.c_str()); + WiFi.begin(ssid_.c_str(), password_.c_str()); } - if (status == WL_CONNECT_FAILED) { - wifi_connect_pending_ = false; // begin() didn't actually start anything, allow next tick to retry - LOG_ERROR("WiFi connection failed (code %d)", status); - } - -#ifdef BOARD_C3_MINI_V1 - // always hardcode Tx power for Wemos CS Mini v1 - // v1 needs this value, see https://github.com/emsesp/EMS-ESP32/pull/620#discussion_r993173979 - // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi - WiFi.setTxPower(WIFI_POWER_8_5dBm); -#else - setWiFiPower(tx_power_); -#endif - #endif } @@ -482,26 +524,11 @@ void Network::startEthernet() { #ifndef EMSESP_STANDALONE - // no ethernet present or wifi takes precedence - if (phy_type_ == PHY_type::PHY_TYPE_NONE || (ssid_.length() > 0)) { + // no ethernet present + if (phy_type_ == PHY_type::PHY_TYPE_NONE) { return; } - // configure Ethernet - int mdc = 23; // Pin# of the I²C clock signal for the Ethernet PHY - hardcoded - int mdio = 18; // Pin# of the I²C IO signal for the Ethernet PHY - hardcoded - uint8_t phy_addr = eth_phy_addr_; // I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) - int8_t power = eth_power_; // Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) - eth_phy_type_t type = (phy_type_ == PHY_type::PHY_TYPE_LAN8720) ? ETH_PHY_LAN8720 - : (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_TLK110 - : ETH_PHY_RTL8201; // Type of the Ethernet PHY (LAN8720 or TLK110) - // clock mode: - // ETH_CLOCK_GPIO0_IN = 0 RMII clock input to GPIO0 - // ETH_CLOCK_GPIO0_OUT = 1 RMII clock output from GPIO0 - // ETH_CLOCK_GPIO16_OUT = 2 RMII clock output from GPIO16 - // ETH_CLOCK_GPIO17_OUT = 3 RMII clock output from GPIO17, for 50hz inverted clock - auto clock_mode = (eth_clock_mode_t)eth_clock_mode_; - // reset power and add a delay as ETH doesn't not always start up correctly after a warm boot if (eth_power_ != -1) { pinMode(eth_power_, OUTPUT); @@ -510,7 +537,22 @@ void Network::startEthernet() { digitalWrite(eth_power_, HIGH); } - if (ETH.begin(type, phy_addr, mdc, mdio, power, clock_mode)) { + // 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 - hardcoded + // mdio = 18 = Pin# of the I²C IO signal for the Ethernet PHY - hardcoded + // phy_addr = eth_phy_addr_ = I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) + // 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) + // clock_mode = + // ETH_CLOCK_GPIO0_IN = 0 RMII clock input to GPIO0 + // ETH_CLOCK_GPIO0_OUT = 1 RMII clock output from GPIO0 + // ETH_CLOCK_GPIO16_OUT = 2 RMII clock output from GPIO16 + // ETH_CLOCK_GPIO17_OUT = 3 RMII clock output from GPIO17, for 50hz inverted clock + eth_phy_type_t type = (phy_type_ == PHY_type::PHY_TYPE_LAN8720) ? ETH_PHY_LAN8720 + : (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_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_)) { + LOG_DEBUG("Ethernet started"); eth_started_ = true; // mark up; do not re-enter this block until reboot / explicit teardown ETH.setHostname(hostname_.c_str()); // Push hostname to the ETH netif immediately after it's created ETH.enableIPv6(true); @@ -526,16 +568,18 @@ void Network::startEthernet() { // check if the network is connected and set network_ip_ / network_iface_ / has_ipv6_ // Iterates over every esp-netif that exists, prioritizing Ethernet > WiFi > AP -bool Network::findNetworks() { +void Network::findNetworks() { #ifndef EMSESP_STANDALONE // exit if already have a connection, unless in AP mode // when in AP mode, it will always try and connect to the WiFi - if (network_ip_ != 0 && !(WiFi.getMode() & WIFI_AP)) { - // const esp_ip4_addr_t ip4 = {.addr = network_ip_}; - // LOG_DEBUG("Network already connected via IPv4: " IPSTR, IP2STR(&ip4)); - return true; - } + // TODO what about if Eth drops and then comes back - we want to auto-switch? + // if (network_ip_ != 0 && !(WiFi.getMode() & WIFI_AP)) { + // // for debugging only + // // const esp_ip4_addr_t ip4 = {.addr = network_ip_}; + // // LOG_DEBUG("Network already connected via IPv4: " IPSTR, IP2STR(&ip4)); + // return; + // } struct NetInfo { esp_ip4_addr_t ip; @@ -580,18 +624,17 @@ bool Network::findNetworks() { strlcpy(info.desc, desc, sizeof(info.desc)); } info.has_ipv6 = (esp_netif_get_ip6_linklocal(netif, &info.ip6) == ESP_OK); - best_iface = candidate; + best_iface = candidate; if (best_iface == NetIface::ETHERNET) { break; // top priority, can't be beaten by anything later in the list } } - auto previous_iface = NetIface::NONE; + // 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 (best_iface != NetIface::NONE && best_iface != previous_iface) { - previous_iface = network_iface_; // save the previous interface for comparison next time + if (best_iface != NetIface::NONE && best_iface != network_iface_) { network_ip_ = info.ip.addr; network_iface_ = iface_from_desc(info.desc); // "sta"/"ap"/"eth*" has_ipv6_ = info.has_ipv6; @@ -612,8 +655,7 @@ bool Network::findNetworks() { // count the number of restarts (for Wifi and Eth) if (juststopped_) { juststopped_ = false; - connectcount_++; - LOG_DEBUG("Network re-connection count %d", connectcount_); + reconnect_count_++; } // start mDNS for any real network interface (skip the SoftAP since the captive portal handles its own DNS) @@ -624,17 +666,20 @@ bool Network::findNetworks() { // fetch the versions.json file from emsesp.org EMSESP::webStatusService.schedule_versions_refresh(); - return true; // we have a network connection + return; + } + + // fallback, reset network state if nothing found + if (best_iface == NetIface::NONE) { + network_ip_ = 0; + network_iface_ = NetIface::NONE; + has_ipv6_ = false; + connect_retry_++; + LOG_DEBUG("No active network interfaces found yet (retry #%d)", connect_retry_); } - // fallback - network_ip_ = 0; - network_iface_ = NetIface::NONE; - has_ipv6_ = false; - connect_retry_++; - LOG_DEBUG("No active network interfaces found yet, re-connection count %d", connect_retry_); #endif - return false; // no connection found yet + return; } // access point (soft-AP) and the captive portal diff --git a/src/core/network.h b/src/core/network.h index 69c061768..f61c8b866 100644 --- a/src/core/network.h +++ b/src/core/network.h @@ -35,12 +35,12 @@ namespace emsesp { -#define NETWORK_RECONNECTION_DELAY_SHORT 3000 // 3 seconds -#define NETWORK_RECONNECTION_DELAY_LONG 60000 // 60 seconds +#define NETWORK_RECONNECTION_DELAY_SHORT 5000 // 5 seconds +#define NETWORK_RECONNECTION_DELAY_LONG 60000 // 1 minute -#define MAX_NETWORK_RECONNECTION_ATTEMPTS 3 // maximum number of network reconnection attempts +#define MAX_NETWORK_RECONNECTION_ATTEMPTS 4 // maximum number of network reconnection attempts before going to AP fallback -#define DNS_PORT 53 +#define DNS_PORT 53 // dns server port for captive portal // copied from Tasmota #if CONFIG_IDF_TARGET_ESP32S2 @@ -79,9 +79,9 @@ namespace emsesp { // multiple ETH instances) -> ETHERNET. Anything else stays as NONE. enum class NetIface : uint8_t { NONE = 0, - WIFI, - ETHERNET, - AP, + WIFI, // 1 + ETHERNET, // 2 + AP, // 3 }; class Network { @@ -118,7 +118,7 @@ class Network { } uint16_t getNetworkReconnects() { - return connectcount_; + return reconnect_count_; } std::string getLocalIP() const; @@ -145,10 +145,25 @@ class Network { return NetIface::NONE; } + static const char * network_iface_to_string(NetIface iface) { + switch (iface) { + case NetIface::WIFI: + return "WiFi"; + case NetIface::ETHERNET: + return "Ethernet"; + case NetIface::AP: + return "AP"; + case NetIface::NONE: + default: + return "None"; + } + return "unknown"; + } + private: static uuid::log::Logger logger_; - bool findNetworks(); + void findNetworks(); void checkConnection(); void startmDNS() const; bool formatBSSID(const String & bssid, uint8_t (&mac)[6]); @@ -165,7 +180,7 @@ class Network { #endif unsigned long lastConnectionAttempt_ = 0; - uint16_t connectcount_ = 0; // number of network reconnects + uint16_t reconnect_count_ = 0; // number of network reconnects uint32_t network_ip_ = 0; NetIface network_iface_ = NetIface::NONE; bool has_ipv6_ = false; @@ -173,7 +188,11 @@ class Network { 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; uint16_t connect_retry_ = 0; // number of network re-connection attempts - volatile bool wifi_connect_pending_ = false; + + volatile bool wifi_connect_pending_ = false; + + 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 // Network and AP settings bool enableMDNS_; diff --git a/src/core/system.cpp b/src/core/system.cpp index 57357ca08..350a304bf 100644 --- a/src/core/system.cpp +++ b/src/core/system.cpp @@ -742,6 +742,8 @@ void System::button_OnClick(PButton & b) { } // button double click +// reconnect to AP by removing the SSID from the network settings +// note: in v3.9 this is normal behaviour to fallback to AP if the Wifi or Ethernet connection fails void System::button_OnDblClick(PButton & b) { LOG_NOTICE("Button pressed - double click - wifi reconnect to AP"); #ifndef EMSESP_STANDALONE @@ -1307,19 +1309,19 @@ void System::show_system(uuid::console::Shell & shell) { shell.println("Network:"); switch (WiFi.status()) { case WL_IDLE_STATUS: - shell.printfln(" Status: Idle"); + shell.printfln(" WiFi Status: Idle"); break; case WL_NO_SSID_AVAIL: - shell.printfln(" Status: Network not found"); + shell.printfln(" WiFi Status: Network not found"); break; case WL_SCAN_COMPLETED: - shell.printfln(" Status: Network scan complete"); + shell.printfln(" WiFi Status: Network scan complete"); break; case WL_CONNECTED: - shell.printfln(" Status: WiFi connected"); + shell.printfln(" WiFi Status: Connected"); shell.printfln(" SSID: %s", WiFi.SSID().c_str()); shell.printfln(" BSSID: %s", WiFi.BSSIDstr().c_str()); shell.printfln(" RSSI: %d dBm (%d %%)", WiFi.RSSI(), wifi_quality(WiFi.RSSI())); @@ -1367,8 +1369,13 @@ void System::show_system(uuid::console::Shell & shell) { shell.printfln(" IPv6 address: %s", uuid::printable_to_string(ETH.linkLocalIPv6()).c_str()); } } - shell.println(); + // show AP is connected + if (EMSESP::network_.ap_connected()) { + shell.printfln(" AP Status: connected"); + } + + shell.println(); shell.println("Syslog:"); if (!syslog_enabled_) { shell.printfln(" Syslog: disabled"); diff --git a/src/web/WebSettingsService.cpp b/src/web/WebSettingsService.cpp index 1fee94c64..2cd8dfa0c 100644 --- a/src/web/WebSettingsService.cpp +++ b/src/web/WebSettingsService.cpp @@ -359,7 +359,6 @@ StateUpdateResult WebSettings::update(JsonObject root, WebSettings & settings) { // either via the Web UI or via the Console void WebSettingsService::onUpdate() { // skip if we're restarting anyway - if (WebSettings::has_flags(WebSettings::ChangeFlags::RESTART)) { return; } diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index d12ddc226..48d2bd27a 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -369,6 +369,20 @@ void WebStatusService::getVersions(JsonObject root) { #endif } +// schedule the next versions.json fetch a few seconds out so the network stack has time to settle +// (DHCP completion, default-netif assignment and DNS server propagation through lwip) before +// HTTPClient::begin() does the hostByName() lookup. Without this delay the very first fetch races +// with the link-up event and arduino-esp32 logs a noisy "DNS Failed ... error '-54'". +void WebStatusService::schedule_versions_refresh() { +#ifndef EMSESP_STANDALONE + uint32_t next = uuid::get_uptime() + VERSIONS_INITIAL_FETCH_DELAY_MS; + if (next == 0) { + next = 1; // 0 is the "idle" sentinel — never let the wrap land there + } + versions_next_fetch_ms_ = next; +#endif +} + // periodic refresh (1 hour) of the cached versions.json // runs on the main loop task, which has a much bigger stack than AsyncTCP needed for https void WebStatusService::loop() { @@ -378,8 +392,6 @@ void WebStatusService::loop() { return; } - // TODO handle a network re-connect to fetch the values again (set versions_next_fetch_ms_ to 1) - // 0 = idle, nothing scheduled if (versions_next_fetch_ms_ == 0) { return; @@ -419,7 +431,7 @@ bool WebStatusService::refresh_versions_cache() { int httpCode = http.GET(); if (httpCode != HTTP_CODE_OK) { #if defined(EMSESP_DEBUG) - EMSESP::logger().debug("versions.json: HTTP %d", httpCode); + EMSESP::logger().debug("versions.json: HTTP error code %d", httpCode); #endif http.end(); return false; diff --git a/src/web/WebStatusService.h b/src/web/WebStatusService.h index 90414b6b8..e4164f8a3 100644 --- a/src/web/WebStatusService.h +++ b/src/web/WebStatusService.h @@ -30,10 +30,11 @@ class WebStatusService { return versions_cache_valid_; } - // refresh the versions.json cache - void schedule_versions_refresh() { - versions_next_fetch_ms_ = 1; - } + // schedule a refresh of the versions.json cache. Defers the fetch by + // VERSIONS_INITIAL_FETCH_DELAY_MS so the network stack (DHCP, default netif, DNS server) + // has time to settle after the link first comes up — otherwise hostByName() can fail + // immediately on boot with a noisy "DNS Failed ... error '-54'". + void schedule_versions_refresh(); bool current_upgradeable() const; // true if a newer version is available @@ -71,8 +72,9 @@ class WebStatusService { bool refresh_versions_cache(); // does the actual HTTPS fetch + parse, returns true on success - static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success - static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after failure + static constexpr uint32_t VERSIONS_REFRESH_INTERVAL_MS = 60UL * 60UL * 1000UL; // 1 hour on success + static constexpr uint32_t VERSIONS_RETRY_INTERVAL_MS = 5UL * 60UL * 1000UL; // 5 min after failure + static constexpr uint32_t VERSIONS_INITIAL_FETCH_DELAY_MS = 5UL * 1000UL; // 5 s after a link comes up }; } // namespace emsesp From 033ce24fb7f55f1e20ce65af05534ef7392cc061 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 15:21:26 +0200 Subject: [PATCH 4/8] udpate --- src/web/WebStatusService.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 48d2bd27a..3a4c508dc 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -370,9 +370,7 @@ void WebStatusService::getVersions(JsonObject root) { } // schedule the next versions.json fetch a few seconds out so the network stack has time to settle -// (DHCP completion, default-netif assignment and DNS server propagation through lwip) before -// HTTPClient::begin() does the hostByName() lookup. Without this delay the very first fetch races -// with the link-up event and arduino-esp32 logs a noisy "DNS Failed ... error '-54'". +// (DHCP completion, default-netif assignment and DNS server propagation through lwip) void WebStatusService::schedule_versions_refresh() { #ifndef EMSESP_STANDALONE uint32_t next = uuid::get_uptime() + VERSIONS_INITIAL_FETCH_DELAY_MS; From e2bd721c3eb8440481e444fa8c34a662f1552c13 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 17:16:38 +0200 Subject: [PATCH 5/8] remove empty SSID check --- interface/pnpm-lock.yaml | 8 ++++---- interface/src/app/settings/network/NetworkSettings.tsx | 2 +- interface/src/i18n/cz/index.ts | 1 - interface/src/i18n/de/index.ts | 1 - interface/src/i18n/en/index.ts | 1 - interface/src/i18n/fr/index.ts | 1 - interface/src/i18n/it/index.ts | 1 - interface/src/i18n/nl/index.ts | 1 - interface/src/i18n/no/index.ts | 1 - interface/src/i18n/pl/index.ts | 1 - interface/src/i18n/sk/index.ts | 1 - interface/src/i18n/sv/index.ts | 1 - interface/src/i18n/tr/index.ts | 1 - 13 files changed, 5 insertions(+), 16 deletions(-) diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index 44941caf4..9e3d7743b 100644 --- a/interface/pnpm-lock.yaml +++ b/interface/pnpm-lock.yaml @@ -1151,8 +1151,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.25: - resolution: {integrity: sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==} + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} engines: {node: '>=6.0.0'} hasBin: true @@ -4279,7 +4279,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.25: {} + baseline-browser-mapping@2.10.27: {} bin-build@3.0.0: dependencies: @@ -4340,7 +4340,7 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.25 + baseline-browser-mapping: 2.10.27 caniuse-lite: 1.0.30001791 electron-to-chromium: 1.5.349 node-releases: 2.0.38 diff --git a/interface/src/app/settings/network/NetworkSettings.tsx b/interface/src/app/settings/network/NetworkSettings.tsx index 89789ccc2..25c2ae2b6 100644 --- a/interface/src/app/settings/network/NetworkSettings.tsx +++ b/interface/src/app/settings/network/NetworkSettings.tsx @@ -173,7 +173,7 @@ const NetworkSettings = () => { Date: Sun, 3 May 2026 21:59:28 +0200 Subject: [PATCH 6/8] use a state machine for cycling between Eth-Wifi-AP --- src/core/network.cpp | 170 +++++++++++++++++++++++++++++-------------- src/core/network.h | 28 +++++-- 2 files changed, 139 insertions(+), 59 deletions(-) diff --git a/src/core/network.cpp b/src/core/network.cpp index e303de841..a57bf2a42 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -67,11 +67,15 @@ void Network::begin() { // 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. - // Persistence is true by default, so this WiFi.mode() call writes opmode=0 to NVS for future boots. - // WiFi.mode(WIFI_OFF); + // 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 - // 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()) { WiFi.mode(WIFI_OFF); return; @@ -99,20 +103,19 @@ void Network::begin() { 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 // WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN // 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 - // de-duplication, so register the lambdas only once across the lifetime of this Network instance + // avoid duplicate registration, so register the lambdas only once across the lifetime of this Network instance if (!wifi_events_registered_) { wifi_events_registered_ = true; - // Defer Tx power setting until STA is actually started. Calling WiFi.setTxPower() before - // 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. + // Defer Tx power setting until STA is actually started WiFi.onEvent( [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { #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 // 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( [this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) { last_disconnect_reason_ = info.wifi_sta_disconnected.reason; @@ -235,19 +233,35 @@ void Network::reconnect() { #endif // reset network state - network_ip_ = 0; - network_iface_ = NetIface::NONE; - has_ipv6_ = false; - juststopped_ = false; - wifi_connect_pending_ = false; - last_disconnect_reason_ = 0; - connect_retry_ = 0; - reconnect_count_ = 0; + network_ip_ = 0; + network_iface_ = NetIface::NONE; + has_ipv6_ = false; + juststopped_ = false; + wifi_connect_pending_ = false; + ethernet_connect_pending_ = false; + last_disconnect_reason_ = 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 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 void Network::loop() { #ifndef EMSESP_STANDALONE @@ -258,13 +272,12 @@ void Network::loop() { if (!lastConnectionAttempt_ || static_cast(currentMillis - lastConnectionAttempt_) >= reconnectDelay) { lastConnectionAttempt_ = currentMillis; - // manage network interfaces + // manage the network interfaces: Ethernet, WiFi and AP in that order startEthernet(); // Ethernet startWIFI(); // WiFi startAP(); // Captive Portal (AP) // 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) { checkConnection(); } @@ -306,15 +319,18 @@ void Network::checkConnection() { if (reason == 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)); + wifi_connect_pending_ = false; } else if (network_iface_ == NetIface::ETHERNET) { LOG_INFO("Ethernet connection lost"); + ethernet_connect_pending_ = false; } juststopped_ = true; network_iface_ = NetIface::NONE; network_ip_ = 0; has_ipv6_ = false; + connect_retry_ = 0; + phase_ = initialPhase(); } #endif } @@ -322,12 +338,12 @@ void Network::checkConnection() { // 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 // Range ESP32 : 2dBm - 20dBm -// 802.11b - wifi1 -// 802.11a - wifi2 -// 802.11g - wifi3 -// 802.11n - wifi4 -// 802.11ac - wifi5 -// 802.11ax - wifi6 +// 802.11b - wifi1 +// 802.11a - wifi2 +// 802.11g - wifi3 +// 802.11n - wifi4 +// 802.11ac - wifi5 +// 802.11ax - wifi6 // tx_power is the Tx power to set, 0 for auto void Network::setWiFiPower(uint8_t tx_power) { #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 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) // US and some other countries allow 1000 mW (30 dBm) int rssi = WiFi.RSSI() * 10; @@ -381,7 +397,7 @@ void Network::setWiFiPower(uint8_t tx_power) { #endif if (!WiFi.setTxPower(p)) { - emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power!!"); + LOG_DEBUG("Failed to set WiFi Tx Power"); } #endif } @@ -491,14 +507,19 @@ const char * Network::disconnectReason(uint8_t code) { // WiFi management void Network::startWIFI() { #ifndef EMSESP_STANDALONE - // exit if WiFi is already connected, or if we have no SSID or another Wifi.begin() is already in progress - if (WiFi.isConnected() || ssid_.isEmpty() || wifi_connect_pending_) { + // only run during the WIFI phase; ETHERNET phase keeps WiFi quiet, AP phase has its own bring-up + 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; } 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 // the event handlers handle error handling and retries @@ -513,12 +534,17 @@ void Network::startWIFI() { // Ethernet management // 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() -// again on every iteration because that thrashes the netif and can prevent DHCP from completing +// continues to run autonomously (link negotiation, DHCP, etc) void Network::startEthernet() { #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 - if (eth_started_) { + if (ethernet_connect_pending_ || ethernet_connected()) { return; } @@ -529,6 +555,12 @@ void Network::startEthernet() { 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 if (eth_power_ != -1) { pinMode(eth_power_, OUTPUT); @@ -537,11 +569,10 @@ void Network::startEthernet() { 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 - hardcoded - // mdio = 18 = Pin# of the I²C IO signal for the Ethernet PHY - hardcoded - // phy_addr = eth_phy_addr_ = I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110) - // power = eth_power_ = Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source) + // mdc = 23 = Pin# of the I²C clock signal for the Ethernet PHY + // mdio = 18 = Pin# of the I²C IO signal for the Ethernet PHY + // 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) // type = Type of the Ethernet PHY (LAN8720 or TLK110) // clock_mode = // 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 : 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_)) { - LOG_DEBUG("Ethernet started"); - eth_started_ = true; // mark up; do not re-enter this block until reboot / explicit teardown + LOG_DEBUG("Ethernet module found - starting"); + ethernet_connect_pending_ = true; ETH.setHostname(hostname_.c_str()); // Push hostname to the ETH netif immediately after it's created ETH.enableIPv6(true); if (staticIPConfig_) { ETH.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); } } else { - LOG_ERROR("Failed to start Ethernet"); + LOG_ERROR("Failed to start Ethernet module"); } #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 (best_iface != NetIface::NONE && best_iface != network_iface_) { network_ip_ = info.ip.addr; @@ -640,6 +669,17 @@ void Network::findNetworks() { has_ipv6_ = info.has_ipv6; 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 ")", network_iface_ == NetIface::ETHERNET ? "Ethernet" : network_iface_ == NetIface::WIFI ? "WiFi" @@ -675,7 +715,31 @@ void Network::findNetworks() { network_iface_ = NetIface::NONE; has_ipv6_ = false; 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 @@ -685,8 +749,8 @@ void Network::findNetworks() { // access point (soft-AP) and the captive portal void Network::startAP() { #ifndef EMSESP_STANDALONE - // Only start AP as a fallback if the Network has failed - if (connect_retry_ < MAX_NETWORK_RECONNECTION_ATTEMPTS) { + // only start AP as a fallback once both the Ethernet and WiFi phases have given up + if (phase_ != NetPhase::AP) { return; } diff --git a/src/core/network.h b/src/core/network.h index f61c8b866..3bea92298 100644 --- a/src/core/network.h +++ b/src/core/network.h @@ -36,7 +36,12 @@ namespace emsesp { #define NETWORK_RECONNECTION_DELAY_SHORT 5000 // 5 seconds + +#ifndef EMSESP_DEBUG #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 @@ -75,15 +80,23 @@ namespace emsesp { // 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, -// "ap" -> AP, "eth"/"eth1"/"eth2"/... (arduino-esp32 v3.x suffixes ETH netifs because it supports -// multiple ETH instances) -> ETHERNET. Anything else stays as NONE. +// "ap" -> AP, "eth"/"eth1"/"eth2"/... enum class NetIface : uint8_t { - NONE = 0, + NONE = 0, // 0 WIFI, // 1 ETHERNET, // 2 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 { public: void begin(); @@ -173,6 +186,7 @@ class Network { void setWiFiPower(uint8_t tx_power); const char * disconnectReason(uint8_t code); void stopAP(); + NetPhase initialPhase() const; #if defined(__clang__) #pragma clang diagnostic push @@ -185,14 +199,16 @@ class Network { NetIface network_iface_ = NetIface::NONE; bool has_ipv6_ = 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; 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_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 bool enableMDNS_; From cc118adec648ac6026dd6091a5bdc5771b871581 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 22:55:25 +0200 Subject: [PATCH 7/8] text changes --- src/web/WebStatusService.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/WebStatusService.cpp b/src/web/WebStatusService.cpp index 3a4c508dc..ae1d21eb8 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -463,7 +463,7 @@ bool WebStatusService::refresh_versions_cache() { versions_cache_valid_ = true; #if defined(EMSESP_DEBUG) - EMSESP::logger().debug("versions.json: refreshed (stable=%s dev=%s), current=%s", + EMSESP::logger().debug("versions.json: fetched stable=%s, dev=%s, current=%s", versions_stable_.version.c_str(), versions_dev_.version.c_str(), current_version_s.c_str()); From 99ef4c0c18a305b8794ccba7a9b7f6d3a7f7c665 Mon Sep 17 00:00:00 2001 From: proddy Date: Sun, 3 May 2026 23:03:22 +0200 Subject: [PATCH 8/8] updates --- src/core/network.cpp | 83 +++++++++++++++++++++++--------------------- src/core/network.h | 7 ++-- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/core/network.cpp b/src/core/network.cpp index a57bf2a42..ff9420ae9 100644 --- a/src/core/network.cpp +++ b/src/core/network.cpp @@ -20,6 +20,13 @@ #include "emsesp.h" +#ifndef NETWORK_FALLBACK_AP_SSID +#define NETWORK_FALLBACK_AP_SSID "ems-esp" +#endif +#ifndef NETWORK_FALLBACK_AP_PASSWORD +#define NETWORK_FALLBACK_AP_PASSWORD "ems-esp-neo" +#endif + namespace emsesp { uuid::log::Logger Network::logger_{F_(network), uuid::log::Facility::KERN}; @@ -65,22 +72,13 @@ void Network::begin() { ap_subnetMask_ = settings.subnetMask; }); - // Initialise WiFi - we only do this once, when the network service is started. + // set before begin() so the event handlers can race-clear it safely + wifi_connect_pending_ = false; + ethernet_connect_pending_ = false; - // 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 - - // 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()) { - WiFi.mode(WIFI_OFF); - return; - } - + // Initialise WiFi once when the Network service starts WiFi.persistent(false); WiFi.setAutoReconnect(false); WiFi.mode(WIFI_STA); @@ -103,10 +101,6 @@ void Network::begin() { WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE } - // 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 // WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN // WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set @@ -144,7 +138,6 @@ void Network::begin() { ARDUINO_EVENT_WIFI_STA_DISCONNECTED); // clear the saved reason and the connect-pending guard on a fresh STA association, - // and latch wifi_ever_connected_ so future disconnects log as warnings WiFi.onEvent( [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) { last_disconnect_reason_ = 0; @@ -205,7 +198,7 @@ std::string Network::getMacAddress() const { } } -// get the number of stations connected to the AP +// get the number of sessions connected to the AP uint8_t Network::getStationNum() const { #ifndef EMSESP_STANDALONE return network_iface_ == NetIface::AP ? WiFi.softAPgetStationNum() : 0; @@ -249,16 +242,14 @@ void Network::reconnect() { } // 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. +// 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; } @@ -293,8 +284,7 @@ void Network::loop() { } // Re-validate the currently active connection -// if a netif is no longer up or has lost its IP (cable unplugged, AP gone, DHCP lease lost, ...) we drop our state so -// find_networks() can pick up a new one +// if a netif is no longer up or has lost its IP (cable unplugged, AP gone, DHCP lease lost, ...) we drop our state so find_networks() can pick up a new one void Network::checkConnection() { if (network_iface_ == NetIface::NONE) { return; @@ -602,16 +592,6 @@ void Network::startEthernet() { void Network::findNetworks() { #ifndef EMSESP_STANDALONE - // exit if already have a connection, unless in AP mode - // when in AP mode, it will always try and connect to the WiFi - // TODO what about if Eth drops and then comes back - we want to auto-switch? - // if (network_ip_ != 0 && !(WiFi.getMode() & WIFI_AP)) { - // // for debugging only - // // const esp_ip4_addr_t ip4 = {.addr = network_ip_}; - // // LOG_DEBUG("Network already connected via IPv4: " IPSTR, IP2STR(&ip4)); - // return; - // } - struct NetInfo { esp_ip4_addr_t ip; esp_ip6_addr_t ip6; @@ -754,23 +734,46 @@ void Network::startAP() { return; } - // don't start the soft-AP if it is disabled, or Ethernet has taken over or we have a real WiFi connection or it's already running - if (ap_provisionMode_ == AP_MODE_NEVER || network_connected() || WiFi.getMode() & WIFI_AP) { + // Don't start AP if wired/Wi-Fi STA is serving the network + if (ap_provisionMode_ == AP_MODE_NEVER || network_connected()) { return; } - WiFi.softAPenableIPv6(); // force IPv6, same as for STA - fixes https://github.com/emsesp/EMS-ESP32/issues/1922 - WiFi.softAPConfig(ap_localIP_, ap_gatewayIP_, ap_subnetMask_); + // Captive-portal DNS is already bound to the softAP interface + if (ap_dnsServer_) { + return; + } + + if (!WiFi.softAPConfig(ap_localIP_, ap_gatewayIP_, ap_subnetMask_)) { + LOG_DEBUG("softAPConfig failed"); + return; + } esp_wifi_set_bandwidth(static_cast(ESP_IF_WIFI_AP), WIFI_BW_HT20); - WiFi.softAP(ap_ssid_.c_str(), ap_password_.c_str(), ap_channel_, ap_ssid_hidden_, ap_max_clients_); + + // WiFi.softAPenableIPv6(); + + if (!WiFi.softAP(ap_ssid_.c_str(), ap_password_.c_str(), ap_channel_, ap_ssid_hidden_, ap_max_clients_)) { + LOG_ERROR("softAP failed; check SSID/password in AP settings"); + WiFi.softAPdisconnect(true); + return; + } #if CONFIG_IDF_TARGET_ESP32C3 WiFi.setTxPower(WIFI_POWER_8_5dBm); // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi #endif const IPAddress apIp = WiFi.softAPIP(); + if (static_cast(apIp) == 0) { + LOG_DEBUG("SoftAP has no IPv4 yet; skipping captive-portal DNS for now."); + WiFi.softAPdisconnect(true); + return; + } LOG_INFO("Starting Access Point with captive portal on %u.%u.%u.%u", apIp[0], apIp[1], apIp[2], apIp[3]); - // start DNS server for Captive Portal ap_dnsServer_ = new DNSServer; + if (!ap_dnsServer_) { + LOG_DEBUG("Out of memory starting captive-portal DNSServer"); + WiFi.softAPdisconnect(true); + return; + } ap_dnsServer_->start(DNS_PORT, "*", apIp); #endif } diff --git a/src/core/network.h b/src/core/network.h index 3bea92298..fcbcbef36 100644 --- a/src/core/network.h +++ b/src/core/network.h @@ -43,7 +43,7 @@ namespace emsesp { #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 3 // maximum number of network reconnection attempts before going to AP fallback #define DNS_PORT 53 // dns server port for captive portal @@ -88,9 +88,6 @@ enum class NetIface : uint8_t { 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, @@ -247,7 +244,7 @@ class Network { // for the captive portal in AP mode #ifndef EMSESP_STANDALONE - DNSServer * ap_dnsServer_; + DNSServer * ap_dnsServer_ = nullptr; #endif };