Merge pull request #3057 from proddy/core3

updates to Network code
This commit is contained in:
Proddy
2026-05-03 23:04:19 +02:00
committed by GitHub
21 changed files with 351 additions and 210 deletions

View File

@@ -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):

View File

@@ -173,7 +173,7 @@ const NetworkSettings = () => {
<ValidatedTextField
fieldErrors={fieldErrors || {}}
name="ssid"
label={'SSID (' + LL.NETWORK_BLANK_SSID() + ')'}
label="SSID"
fullWidth
variant="outlined"
value={data.ssid}

View File

@@ -261,7 +261,6 @@ const cz: Translation = {
SCAN_AGAIN: 'Skenovat znovu',
NETWORK_SCANNER: 'Síťový skener',
NETWORK_NO_WIFI: 'Nenalezeny žádné WiFi sítě',
NETWORK_BLANK_SSID: 'ponechte prázdné pro deaktivaci WiFi a povolení ETH',
NETWORK_BLANK_BSSID: 'ponechte prázdné pokud použijete jen SSID',
TX_POWER: 'Vysílací výkon',
HOSTNAME: 'Název hostitele',

View File

@@ -261,7 +261,6 @@ const de: Translation = {
SCAN_AGAIN: 'Erneute Suche',
NETWORK_SCANNER: 'Netzwerksuche',
NETWORK_NO_WIFI: 'Keine WiFi-Netzwerke gefunden',
NETWORK_BLANK_SSID: 'Freilassen, um WiFi zu deaktivieren und ETH zu aktivieren.',
NETWORK_BLANK_BSSID: 'Freilassen, um nur SSID für die Verbindung zu nutzen.',
TX_POWER: 'Tx Leistung',
HOSTNAME: 'Hostname',

View File

@@ -261,7 +261,6 @@ const en: Translation = {
SCAN_AGAIN: 'Scan again',
NETWORK_SCANNER: 'Network Scanner',
NETWORK_NO_WIFI: 'No WiFi networks found',
NETWORK_BLANK_SSID: 'leave blank to disable WiFi and enable ETH',
NETWORK_BLANK_BSSID: 'leave blank to use only SSID',
TX_POWER: 'Tx Power',
HOSTNAME: 'Hostname',

View File

@@ -261,7 +261,6 @@ const fr: Translation = {
SCAN_AGAIN: 'Rescanner',
NETWORK_SCANNER: 'Scan réseau',
NETWORK_NO_WIFI: 'Pas de réseau WiFi trouvé',
NETWORK_BLANK_SSID: 'laisser vide pour désactiver le WiFi',
NETWORK_BLANK_BSSID: 'laisser vide pour utiliser uniquement le SSID',
TX_POWER: 'Puissance Tx',
HOSTNAME: "Nom d'hôte",

View File

@@ -261,7 +261,6 @@ const it: Translation = {
SCAN_AGAIN: 'Scansiona ancora',
NETWORK_SCANNER: 'Scansione Rete',
NETWORK_NO_WIFI: 'Nessuana rete WiFi trovata',
NETWORK_BLANK_SSID: 'lasciare vuoto per disattivare WiFi',
NETWORK_BLANK_BSSID: 'lasciare vuoto per usare solo SSID',
TX_POWER: 'Potenza Tx',
HOSTNAME: 'Nome ospite',

View File

@@ -261,7 +261,6 @@ const nl: Translation = {
SCAN_AGAIN: 'Opnieuw scannen',
NETWORK_SCANNER: 'Netwerk Scannen',
NETWORK_NO_WIFI: 'Geen WiFi netwerken gevonden',
NETWORK_BLANK_SSID: 'laat leeg om WiFi uit te schakelen',
NETWORK_BLANK_BSSID: 'laat leeg om alleen SSID te bebruiken',
TX_POWER: 'Tx Vermogen',
HOSTNAME: 'Hostnaam',

View File

@@ -261,7 +261,6 @@ const no: Translation = {
SCAN_AGAIN: 'Søk igjen',
NETWORK_SCANNER: 'Nettverk Scanner',
NETWORK_NO_WIFI: 'Ingen trådløse nett funnet',
NETWORK_BLANK_SSID: 'la feltet være blankt for å deaktivisere trådløst nettverk',
NETWORK_BLANK_BSSID: 'la feltet være blankt for å bruke kun SSID',
TX_POWER: 'Tx Effekt',
HOSTNAME: 'Hostname',

View File

@@ -261,7 +261,6 @@ const pl: BaseTranslation = {
SCAN_AGAIN: 'Skanuj ponownie',
NETWORK_SCANNER: 'Skaner sieci WiFi',
NETWORK_NO_WIFI: 'Brak sieci WiFi w zasięgu',
NETWORK_BLANK_SSID: 'pozostaw puste aby wyłączyć WiFi i włączyć ETH',
NETWORK_BLANK_BSSID: 'pozostaw puste aby używać tylko SSID',
TX_POWER: 'Moc nadawania',
HOSTNAME: 'Nazwa w sieci',

View File

@@ -261,7 +261,6 @@ const sk: Translation = {
SCAN_AGAIN: 'Skenovať znova',
NETWORK_SCANNER: 'Sieťový skener',
NETWORK_NO_WIFI: 'WiFi siete nenájdené',
NETWORK_BLANK_SSID: 'nechajte prázdne, ak chcete zakázať WiFi a povoliť ETH',
NETWORK_BLANK_BSSID: 'ponechajte prázdne, ak chcete používať iba SSID',
TX_POWER: 'Tx výkon',
HOSTNAME: 'Hostname',

View File

@@ -261,7 +261,6 @@ const sv: Translation = {
SCAN_AGAIN: 'Sök igen',
NETWORK_SCANNER: 'Hittade nätverk',
NETWORK_NO_WIFI: 'Inga WiFi-nätverk hittades',
NETWORK_BLANK_SSID: 'lämna blankt för att inaktivera WiFi',
NETWORK_BLANK_BSSID: 'lämna blankt för att bara använda SSID',
TX_POWER: 'Tx effekt',
HOSTNAME: 'Värdnamn',

View File

@@ -261,7 +261,6 @@ const tr: Translation = {
SCAN_AGAIN: 'Tekrar tara',
NETWORK_SCANNER: 'Ağ Tarayıcısı',
NETWORK_NO_WIFI: 'Hiçbir Kablosuz Ağ bulunamadı',
NETWORK_BLANK_SSID: 'Kablosuz ağı devre dışı bırakmak için boş bırakın',
NETWORK_BLANK_BSSID: 'sadece SSID kullanmak için boş bırakın',
TX_POWER: 'Aktarım gücü',
HOSTNAME: 'Ana Makine Adı',

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -20,14 +20,21 @@
#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};
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;
@@ -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<wifi_interface_t>(ESP_IF_WIFI_STA), WIFI_BW_HT20);
} else {
esp_wifi_set_bandwidth(static_cast<wifi_interface_t>(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<uint32_t>(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<wifi_interface_t>(ESP_IF_WIFI_STA), WIFI_BW_HT20);
} else {
esp_wifi_set_bandwidth(static_cast<wifi_interface_t>(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<wifi_interface_t>(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<uint32_t>(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
}

View File

@@ -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
};

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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