diff --git a/interface/pnpm-lock.yaml b/interface/pnpm-lock.yaml index b19239666..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 @@ -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==} @@ -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 @@ -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): 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 = () => { read([&](auto & settings) { + // pull all settings and store locally + EMSESP::esp32React.getNetworkSettingsService()->read([&](NetworkSettings & settings) { enableMDNS_ = settings.enableMDNS; staticIPConfig_ = settings.staticIPConfig; localIP_ = settings.localIP; @@ -65,37 +72,81 @@ void Network::begin() { ap_subnetMask_ = settings.subnetMask; }); - // 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); + // set before begin() so the event handlers can race-clear it safely + wifi_connect_pending_ = false; + ethernet_connect_pending_ = false; - // From here on, mode changes stay in RAM only and don't touch NVS + phase_ = initialPhase(); + + // Initialise WiFi once when the Network service starts 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 + } // 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); + // 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 + 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. + 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, + 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 } @@ -147,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; @@ -159,12 +210,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 @@ -174,17 +226,33 @@ void Network::reconnect() { #endif // reset network state - network_ip_ = 0; - network_iface_ = NetIface::NONE; - has_ipv6_ = false; - juststopped_ = true; - wifi_connect_pending_ = false; - last_disconnect_reason_ = 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, as this could be called from the console + // 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 { + if (phy_type_ != PHY_type::PHY_TYPE_NONE) { + return NetPhase::ETHERNET; + } + if (!ssid_.isEmpty()) { + return NetPhase::WIFI; + } + return NetPhase::AP; +} + // network loop, looking for new and disconnecting networks void Network::loop() { #ifndef EMSESP_STANDALONE @@ -195,17 +263,17 @@ void Network::loop() { if (!lastConnectionAttempt_ || static_cast(currentMillis - lastConnectionAttempt_) >= reconnectDelay) { lastConnectionAttempt_ = currentMillis; - // manage network interfaces - startAP(); // Captive Portal (AP) - startWIFI(); // WiFi + // 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(); } - findNetworks(); // detect new connections + + findNetworks(); // detect any new network connections } // process DNS requests for the captive portal while the soft-AP is up @@ -216,9 +284,12 @@ 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; + } + #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)) { @@ -239,10 +310,17 @@ void Network::checkConnection() { reason = WIFI_REASON_UNSPECIFIED; // event hasn't fired yet (or was cleared); avoid logging "0" } LOG_INFO("WiFi connection lost (reason %u: %s)", reason, disconnectReason(reason)); - } else { + wifi_connect_pending_ = false; + } else if (network_iface_ == NetIface::ETHERNET) { LOG_INFO("Ethernet connection lost"); + ethernet_connect_pending_ = false; } - reconnect(); + juststopped_ = true; + network_iface_ = NetIface::NONE; + network_ip_ = 0; + has_ipv6_ = false; + connect_retry_ = 0; + phase_ = initialPhase(); } #endif } @@ -250,12 +328,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 @@ -269,7 +347,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; @@ -309,7 +387,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 } @@ -419,88 +497,59 @@ 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_) { + // only run during the WIFI phase; ETHERNET phase keeps WiFi quiet, AP phase has its own bring-up + if (phase_ != NetPhase::WIFI) { 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 + // 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; } - // 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_ = true; - // 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 } // 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; } #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_; + // 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) { @@ -510,15 +559,29 @@ void Network::startEthernet() { digitalWrite(eth_power_, HIGH); } - if (ETH.begin(type, phy_addr, mdc, mdio, power, clock_mode)) { - eth_started_ = true; // mark up; do not re-enter this block until reboot / explicit teardown + // 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 + // 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 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 @@ -526,17 +589,9 @@ 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; - } - struct NetInfo { esp_ip4_addr_t ip; esp_ip6_addr_t ip6; @@ -580,23 +635,31 @@ 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; - // 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; 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" @@ -612,8 +675,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,44 +686,94 @@ 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("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; + } } - // 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 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; } - // 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 577f3f5a4..fcbcbef36 100644 --- a/src/core/network.h +++ b/src/core/network.h @@ -35,12 +35,17 @@ 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 MAX_NETWORK_RECONNECTION_ATTEMPTS 3 // maximum number of network reconnection attempts +#ifndef EMSESP_DEBUG +#define NETWORK_RECONNECTION_DELAY_LONG 60000 // 1 minute +#else +#define NETWORK_RECONNECTION_DELAY_LONG 10000 // 10 seconds - for debugging +#endif -#define DNS_PORT 53 +#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 // copied from Tasmota #if CONFIG_IDF_TARGET_ESP32S2 @@ -75,13 +80,18 @@ 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, - WIFI, - ETHERNET, - AP, + NONE = 0, // 0 + WIFI, // 1 + ETHERNET, // 2 + AP, // 3 +}; + +enum class NetPhase : uint8_t { + ETHERNET = 0, + WIFI = 1, + AP = 2, }; class Network { @@ -89,10 +99,6 @@ class Network { void begin(); void loop(); - uint16_t getWifiReconnects() const { - return connectcount_; - } - uint32_t network_ip() const { return network_ip_; } @@ -121,8 +127,8 @@ class Network { return has_ipv6_ && (network_iface_ == NetIface::WIFI || network_iface_ == NetIface::ETHERNET); } - uint16_t getWifiReconnects() { - return connectcount_; + uint16_t getNetworkReconnects() { + return reconnect_count_; } std::string getLocalIP() const; @@ -149,10 +155,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]); @@ -162,6 +183,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 @@ -169,15 +191,21 @@ 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; 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 // Network and AP settings bool enableMDNS_; @@ -216,7 +244,7 @@ class Network { // for the captive portal in AP mode #ifndef EMSESP_STANDALONE - DNSServer * ap_dnsServer_; + DNSServer * ap_dnsServer_ = nullptr; #endif }; diff --git a/src/core/system.cpp b/src/core/system.cpp index b420e13b1..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 @@ -998,7 +1000,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 @@ -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"); @@ -2451,7 +2458,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()); 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..ae1d21eb8 100644 --- a/src/web/WebStatusService.cpp +++ b/src/web/WebStatusService.cpp @@ -369,6 +369,18 @@ 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) +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 +390,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 +429,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; @@ -453,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()); 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