From f9a814ca11c7d5088718d640eb45df28cfbe763f Mon Sep 17 00:00:00 2001 From: Elvin Luff Date: Wed, 11 Nov 2026 05:31:36 +0000 Subject: [PATCH] feat: Implement W5500 SPI ethernet support (#286) * feat: implement feature gate & w5500 SPI ethernet * feat: eth conn restart handling, wifi-ap connection type, BLE sensor gate * fix: bring eth down when disabled * fix: disable WiFi scan when ethernet is active * feat: refactor uplink types into separate classes * fix: compare enum, not string * chore: remove dead code * fix: remove out of scope persistNow(), move w5500 to networksettings, add base for future ethernet implementations * fix: keep wifi armed while reconnecting, reset IP when moving from static to DHCP, refactor wifi loop to use return * fix: clear out connectionType string for enum --- lib/NetworkUtils/NetworkAp.h | 113 ++++++ lib/NetworkUtils/NetworkConnection.h | 1 + lib/NetworkUtils/NetworkEthernet.h | 269 +++++++++++++ lib/NetworkUtils/NetworkMgr.h | 564 ++++++++++----------------- lib/NetworkUtils/NetworkUplink.h | 143 +++++++ lib/NetworkUtils/NetworkWifi.h | 431 ++++++++++++++++++++ platformio.ini | 3 + sdkconfig.defaults | 5 + src/HaHelper.h | 11 +- src/MainTask.h | 3 +- src/PortalTask.h | 224 ++++++++--- src/Settings.h | 23 +- src/defines.h | 42 +- src/main.cpp | 17 +- src/strings.h | 13 + src/utils.h | 80 +++- src_data/locales/cn.json | 25 +- src_data/locales/en.json | 25 +- src_data/locales/it.json | 25 +- src_data/locales/nl.json | 25 +- src_data/locales/ru.json | 25 +- src_data/pages/index.html | 17 +- src_data/pages/network.html | 121 +++++- src_data/pages/sensors.html | 40 +- src_data/scripts/utils.js | 6 +- 25 files changed, 1801 insertions(+), 450 deletions(-) create mode 100644 lib/NetworkUtils/NetworkAp.h create mode 100644 lib/NetworkUtils/NetworkEthernet.h create mode 100644 lib/NetworkUtils/NetworkUplink.h create mode 100644 lib/NetworkUtils/NetworkWifi.h diff --git a/lib/NetworkUtils/NetworkAp.h b/lib/NetworkUtils/NetworkAp.h new file mode 100644 index 0000000..ff4a0a5 --- /dev/null +++ b/lib/NetworkUtils/NetworkAp.h @@ -0,0 +1,113 @@ +#pragma once +#include +#include + +namespace NetworkUtils { + /** + * @brief Fallback AP: the setup network, raised when the station cannot carry the uplink + */ + class ApUplink : public NetworkUplink { + public: + ApUplink(UplinkContext* context, WifiUplink* wifi) : NetworkUplink(context), wifi(wifi) {} + + void loop() override { + // the association carries the traffic again: retire the AP once it is stable and idle + if (this->wifi->isActive()) { + if ( + this->isEnabled() && !this->hasClients() + && millis() - this->wifi->getConnectedTime() >= this->retireDelay + ) { + Log.sinfoln(FPSTR(L_NETWORK), F("Stop AP because STA connected")); + this->stop(); + } + + return; + } + + if (this->isEnabled()) { + this->attempt.disarm(); + + return; + } + + // a failed softAP() retries once the window runs out + if (this->attempt.isPending()) { + return; + } + + this->start(); + } + + /** + * @brief The AP interface is up, whether or not anyone is on it + * + * A parked radio means the AP is not carrying anything, whatever the mode still says. + */ + bool isActive() override { + return this->isEnabled() && !this->wifi->isSuspended(); + } + + bool isEnabled() { + return (WiFi.getMode() & WIFI_AP) != 0; + } + + bool hasClients() { + if (!this->isEnabled()) { + return false; + } + + return WiFi.softAPgetStationNum() > 0; + } + + void start() { + Log.sinfoln( + FPSTR(L_NETWORK), + this->wifi->hasCredentials() + ? F("Disconnected for a long time, start AP") + : F("No STA credentials, start AP") + ); + + WiFi.mode(WIFI_AP_STA); + this->context->delayCallback(250); + + if (!WiFi.softAP(this->context->apName, this->context->apPassword, this->context->apChannel)) { + this->attempt.arm(this->startTimeout); + } + } + + void stop() { + WiFi.mode(WIFI_STA); + this->attempt.disarm(); + } + + IPAddress localIP() override { + return WiFi.softAPIP(); + } + + IPAddress subnetMask() override { + #ifdef ARDUINO_ARCH_ESP32 + return WiFi.softAPSubnetMask(); + #else + return IPAddress(255, 255, 255, 0); + #endif + } + + IPAddress gatewayIP() override { + return WiFi.softAPIP(); + } + + IPAddress dnsIP() override { + return WiFi.softAPIP(); + } + + String macAddress() override { + return WiFi.softAPmacAddress(); + } + + protected: + const unsigned long retireDelay = 15000; + const unsigned long startTimeout = 5000; + + WifiUplink* wifi; + }; +} diff --git a/lib/NetworkUtils/NetworkConnection.h b/lib/NetworkUtils/NetworkConnection.h index 313d5e4..c3667ba 100644 --- a/lib/NetworkUtils/NetworkConnection.h +++ b/lib/NetworkUtils/NetworkConnection.h @@ -1,3 +1,4 @@ +#pragma once #if defined(ARDUINO_ARCH_ESP8266) #include #include "lwip/etharp.h" diff --git a/lib/NetworkUtils/NetworkEthernet.h b/lib/NetworkUtils/NetworkEthernet.h new file mode 100644 index 0000000..978af7a --- /dev/null +++ b/lib/NetworkUtils/NetworkEthernet.h @@ -0,0 +1,269 @@ +#pragma once +#include + +#ifdef NETWORK_HAS_ETHERNET + #include +#endif + +namespace NetworkUtils { + /** + * @brief Ethernet uplink: the W5500 driver, its cable and its DHCP lease + */ + class EthernetUplink : public NetworkUplink { + public: + explicit EthernetUplink(UplinkContext* context) : NetworkUplink(context) {} + + void loop() override { + #ifdef NETWORK_HAS_ETHERNET + if (this->restartFlag) { + this->restartFlag = false; + + // The W5500 driver cannot be re-initialized in place: Arduino's ETH.end() leaves + // the SPI bus allocated, so a wiring change only takes effect after a restart. + if (this->started) { + Log.swarningln(FPSTR(L_NETWORK), F("Ethernet: settings changed, restart required to apply them")); + } + } + + if (!this->enabled || !this->hasPins()) { + if (this->active || this->linkUp) { + this->active = false; + this->linkUp = false; + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: disabled, handing the uplink back to WiFi")); + } + + this->attempt.disarm(); + + return; + } + + if (!this->started) { + if (this->startUnavailable) { + return; + } + + Log.sinfoln( + FPSTR(L_NETWORK), + F("Ethernet: init W5500 (CS: %u, IRQ: %u, RST: %u, CLK: %u, MISO: %u, MOSI: %u)"), + this->cs, + this->irq, + this->rst, + this->clk, + this->miso, + this->mosi + ); + + if (!ETH.begin(ETH_PHY_W5500, 1, this->cs, this->irq, this->rst, SPI2_HOST, this->clk, this->miso, this->mosi, 12)) { + // A failed init leaves the SPI bus allocated, so retrying cannot succeed; + // wait for a restart instead of flapping. + this->startUnavailable = true; + this->attempt.disarm(); + + Log.serrorln(FPSTR(L_NETWORK), F("Ethernet: W5500 init failed, check the wiring and restart")); + + return; + } + + this->started = true; + this->running = true; + this->prevLinkCheck = 0; + this->attempt.arm(this->linkGrace); + + if (!this->context->useDhcp) { + ETH.config(this->context->staticIp, this->context->staticGateway, this->context->staticSubnet, this->context->staticDns); + } + + if (strlen(this->context->hostname) && UplinkContext::isHostnameValid(this->context->hostname)) { + ETH.setHostname(this->context->hostname); + } + + return; + } + + if (millis() - this->prevLinkCheck < this->linkCheckInterval) { + return; + } + + this->prevLinkCheck = millis(); + + if (!ETH.linkUp()) { + if (this->linkUp) { + this->linkUp = false; + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: cable disconnected")); + } + + this->attempt.disarm(); + + if (this->active) { + this->active = false; + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: link lost, falling back to WiFi")); + this->context->delayCallback(250); + } + + return; + } + + if (!this->linkUp) { + this->linkUp = true; + this->attempt.arm(this->leaseGrace); + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: cable connected")); + } + + if (!ETH.connected() || this->active) { + return; + } + + this->active = true; + this->attempt.disarm(); + + Log.sinfoln( + FPSTR(L_NETWORK), + F("Ethernet: using ethernet instead of WiFi, IP: %s, link: %u Mbps, duplex: %u"), + ETH.localIP().toString().c_str(), + ETH.linkSpeed(), + ETH.fullDuplex() ? 1 : 0 + ); + #endif + } + + /** + * @brief Ethernet carries the uplink: cable plugged in and DHCP lease obtained + */ + bool isActive() override { + #ifdef NETWORK_HAS_ETHERNET + if (!this->enabled || !this->started) { + return false; + } + + return ETH.linkUp() && ETH.connected(); + #else + return false; + #endif + } + + /** + * @brief Enable/disable the interface, taking the driver in and out of the FSM + */ + void setEnabled(bool value) { + this->enabled = value; + + #ifdef NETWORK_HAS_ETHERNET + // The driver stays installed: stop() only takes the link and the netif down, while the + // SPI bus, the MAC and the netif itself stay allocated, so start() resumes without a restart + if (this->started && ETH.handle() != nullptr && value != this->running) { + esp_err_t ret = value ? esp_eth_start(ETH.handle()) : esp_eth_stop(ETH.handle()); + + if (ret == ESP_OK) { + this->running = value; + + } else { + Log.serrorln(FPSTR(L_NETWORK), F("Ethernet: driver %s failed"), value ? "start" : "stop"); + } + } + #endif + } + + /** + * @brief Configure the W5500 wiring. Use GPIO_IS_NOT_CONFIGURED to disable + */ + void setPins(uint8_t clk, uint8_t mosi, uint8_t miso, uint8_t cs, uint8_t irq, uint8_t rst) { + if ( + clk != this->clk || mosi != this->mosi || miso != this->miso + || cs != this->cs || irq != this->irq || rst != this->rst + ) { + this->markSettingsChanged(); + } + + this->clk = clk; + this->mosi = mosi; + this->miso = miso; + this->cs = cs; + this->irq = irq; + this->rst = rst; + } + + /** + * @brief A setting the driver reads changed: applying it needs a restart + */ + void markSettingsChanged() { + if (this->started) { + this->restartFlag = true; + } + } + + bool hasPins() { + return this->clk != GPIO_IS_NOT_CONFIGURED + && this->mosi != GPIO_IS_NOT_CONFIGURED + && this->miso != GPIO_IS_NOT_CONFIGURED + && this->cs != GPIO_IS_NOT_CONFIGURED + && this->irq != GPIO_IS_NOT_CONFIGURED + && this->rst != GPIO_IS_NOT_CONFIGURED; + } + + IPAddress localIP() override { + #ifdef NETWORK_HAS_ETHERNET + return ETH.localIP(); + #else + return IPAddress(); + #endif + } + + IPAddress subnetMask() override { + #ifdef NETWORK_HAS_ETHERNET + return ETH.subnetMask(); + #else + return IPAddress(); + #endif + } + + IPAddress gatewayIP() override { + #ifdef NETWORK_HAS_ETHERNET + return ETH.gatewayIP(); + #else + return IPAddress(); + #endif + } + + IPAddress dnsIP() override { + #ifdef NETWORK_HAS_ETHERNET + return ETH.dnsIP(); + #else + return IPAddress(); + #endif + } + + String macAddress() override { + #ifdef NETWORK_HAS_ETHERNET + return ETH.macAddress(); + #else + return String(); + #endif + } + + protected: + const unsigned long linkCheckInterval = 1000; + const unsigned long linkGrace = 5000; + const unsigned long leaseGrace = 20000; + + bool enabled = false; + bool started = false; + bool running = false; + bool active = false; + bool linkUp = false; + bool restartFlag = false; + bool startUnavailable = false; + + uint8_t clk = GPIO_IS_NOT_CONFIGURED; + uint8_t mosi = GPIO_IS_NOT_CONFIGURED; + uint8_t miso = GPIO_IS_NOT_CONFIGURED; + uint8_t cs = GPIO_IS_NOT_CONFIGURED; + uint8_t irq = GPIO_IS_NOT_CONFIGURED; + uint8_t rst = GPIO_IS_NOT_CONFIGURED; + + unsigned long prevLinkCheck = 0; + }; +} diff --git a/lib/NetworkUtils/NetworkMgr.h b/lib/NetworkUtils/NetworkMgr.h index aadf3dd..1029810 100644 --- a/lib/NetworkUtils/NetworkMgr.h +++ b/lib/NetworkUtils/NetworkMgr.h @@ -1,380 +1,258 @@ -#if defined(ARDUINO_ARCH_ESP8266) -#include -#include "lwip/etharp.h" -#elif defined(ARDUINO_ARCH_ESP32) -#include -#include -#endif +#pragma once +#include #include +#include +#include +#include namespace NetworkUtils { + /** + * @brief Picks the uplink that carries the traffic and drives it + * + * The hierarchy is ethernet > WiFi > fallback AP: the first interface able to do it owns the + * pass, and every status getter reads that decision instead of re-testing the interfaces. + */ class NetworkMgr { public: - typedef std::function YieldCallback; - typedef std::function DelayCallback; - - NetworkMgr() { - NetworkConnection::setup(this->useDhcp); - this->resetWifi(); + NetworkMgr() : wifi(&this->context), ap(&this->context, &this->wifi), ethernet(&this->context) { + NetworkConnection::setup(this->context.useDhcp); + this->wifi.resetWifi(); } NetworkMgr* setYieldCallback(YieldCallback callback = nullptr) { - this->yieldCallback = callback; + this->context.yieldCallback = callback; return this; } NetworkMgr* setDelayCallback(DelayCallback callback = nullptr) { - this->delayCallback = callback; + this->context.delayCallback = callback; return this; } NetworkMgr* setHostname(const char* value) { - this->hostname = value; + this->context.hostname = value; + return this; } NetworkMgr* setApCredentials(const char* ssid, const char* password = nullptr, uint8_t channel = 0) { - this->apName = ssid; - this->apPassword = password; - this->apChannel = channel; + this->context.apName = ssid; + this->context.apPassword = password; + this->context.apChannel = channel; return this; } NetworkMgr* setStaCredentials(const char* ssid = nullptr, const char* password = nullptr, uint8_t channel = 0) { - this->staSsid = ssid; - this->staPassword = password; - this->staChannel = channel; + this->context.staSsid = ssid; + this->context.staPassword = password; + this->context.staChannel = channel; return this; } NetworkMgr* setUseDhcp(bool value) { - this->useDhcp = value; - NetworkConnection::setup(this->useDhcp); + if (value != this->context.useDhcp) { + this->ethernet.markSettingsChanged(); + } + + this->context.useDhcp = value; + NetworkConnection::setup(this->context.useDhcp); return this; } NetworkMgr* setStaticConfig(const char* ip, const char* gateway, const char* subnet, const char* dns) { - this->staticIp.fromString(ip); - this->staticGateway.fromString(gateway); - this->staticSubnet.fromString(subnet); - this->staticDns.fromString(dns); + IPAddress valueIp, valueGateway, valueSubnet, valueDns; + + valueIp.fromString(ip); + valueGateway.fromString(gateway); + valueSubnet.fromString(subnet); + valueDns.fromString(dns); + + if ( + valueIp != this->context.staticIp || valueGateway != this->context.staticGateway + || valueSubnet != this->context.staticSubnet || valueDns != this->context.staticDns + ) { + this->ethernet.markSettingsChanged(); + } + + this->context.staticIp = valueIp; + this->context.staticGateway = valueGateway; + this->context.staticSubnet = valueSubnet; + this->context.staticDns = valueDns; return this; } NetworkMgr* setStaticConfig(IPAddress& ip, IPAddress& gateway, IPAddress& subnet, IPAddress& dns) { - this->staticIp = ip; - this->staticGateway = gateway; - this->staticSubnet = subnet; - this->staticDns = dns; + if ( + ip != this->context.staticIp || gateway != this->context.staticGateway + || subnet != this->context.staticSubnet || dns != this->context.staticDns + ) { + this->ethernet.markSettingsChanged(); + } + + this->context.staticIp = ip; + this->context.staticGateway = gateway; + this->context.staticSubnet = subnet; + this->context.staticDns = dns; return this; } - bool hasStaCredentials() { - return this->staSsid != nullptr; + /** + * @brief Enable/disable the ethernet interface (W5500 over SPI) + */ + NetworkMgr* setEthernet(bool enabled) { + this->ethernet.setEnabled(enabled); + + return this; + } + + /** + * @brief Configure W5500 wiring. Use GPIO_IS_NOT_CONFIGURED to disable + */ + NetworkMgr* setEthernetPins(uint8_t clk, uint8_t mosi, uint8_t miso, uint8_t cs, uint8_t irq, uint8_t rst) { + this->ethernet.setPins(clk, mosi, miso, cs, irq, rst); + + return this; + } + + /** + * @brief Uplinks in the order they are tried: ethernet, WiFi, fallback AP + */ + enum class Uplink : uint8_t { None = 0, Ap, Wifi, Ethernet }; + + /** + * @brief The uplink that currently carries traffic + * + * Always the first interface in the hierarchy that can do it, so every consumer reads the + * same decision instead of re-testing ethernet and overriding the WiFi answer. + */ + Uplink getUplink() { + if (this->ethernet.isActive()) { + return Uplink::Ethernet; + } + + if (this->wifi.isActive()) { + return Uplink::Wifi; + } + + if (this->ap.isActive()) { + return Uplink::Ap; + } + + return Uplink::None; } bool isConnected() { - return this->isStaEnabled() && NetworkConnection::getStatus() == NetworkConnection::Status::CONNECTED; + return this->getUplink() >= Uplink::Wifi; } - bool isConnecting() { - return this->isStaEnabled() && NetworkConnection::getStatus() == NetworkConnection::Status::CONNECTING; + /** + * @brief WiFi signal quality in percent, 0 when WiFi is not carrying the uplink + */ + uint8_t getSignalQuality() { + return this->getUplink() == Uplink::Wifi ? rssiToSignalQuality(this->wifi.rssi()) : 0; } - bool isStaEnabled() { - return (WiFi.getMode() & WIFI_STA) != 0; + /** + * @return "ethernet", "wifi", "wifi-ap" or "none" + */ + const char* getConnectionType() { + return uplinkName(this->getUplink()); + } + + /** + * @brief Uplink token, already in locale-key shape + */ + static const char* uplinkName(Uplink uplink) { + switch (uplink) { + case Uplink::Ethernet: + return "ethernet"; + + case Uplink::Wifi: + return "wifi"; + + case Uplink::Ap: + return "wifi-ap"; + + default: + return "none"; + } } bool isApEnabled() { - return (WiFi.getMode() & WIFI_AP) != 0; - } - - bool hasApClients() { - if (!this->isApEnabled()) { - return false; - } - - return WiFi.softAPgetStationNum() > 0; + return this->ap.isEnabled(); } short int getRssi() { - return WiFi.RSSI(); + return this->station()->rssi(); } IPAddress getApIp() { - return WiFi.softAPIP(); + return this->ap.localIP(); } IPAddress getStaIp() { - return WiFi.localIP(); + return this->station()->localIP(); } IPAddress getStaSubnet() { - return WiFi.subnetMask(); + return this->station()->subnetMask(); } IPAddress getStaGateway() { - return WiFi.gatewayIP(); + return this->station()->gatewayIP(); } IPAddress getStaDns() { - return WiFi.dnsIP(); + return this->station()->dnsIP(); } String getStaMac() { - return WiFi.macAddress(); + return this->station()->macAddress(); } const char* getStaSsid() { - return this->staSsid; - } - - const char* getStaPassword() { - return this->staPassword; + return this->context.staSsid; } uint8_t getStaChannel() { - return this->staChannel; - } - - bool resetWifi() { - // set policy manual for work 13 ch - { - #ifdef ARDUINO_ARCH_ESP8266 - wifi_country_t country = {"JP", 1, 14, WIFI_COUNTRY_POLICY_MANUAL}; - wifi_set_country(&country); - #elif defined(ARDUINO_ARCH_ESP32) - const wifi_country_t country = {"JP", 1, 14, CONFIG_ESP32_PHY_MAX_WIFI_TX_POWER, WIFI_COUNTRY_POLICY_MANUAL}; - esp_wifi_set_country(&country); - #endif - } - - WiFi.persistent(false); - #if !defined(ESP_ARDUINO_VERSION_MAJOR) || ESP_ARDUINO_VERSION_MAJOR < 3 - WiFi.setAutoConnect(false); - #endif - WiFi.setAutoReconnect(false); - - #ifdef ARDUINO_ARCH_ESP8266 - WiFi.setSleepMode(WIFI_NONE_SLEEP); - #elif defined(ARDUINO_ARCH_ESP32) - WiFi.setSleep(USE_BLE ? WIFI_PS_MIN_MODEM : WIFI_PS_NONE); - #endif - - WiFi.softAPdisconnect(); - #ifdef ARDUINO_ARCH_ESP8266 - /*if (wifi_softap_dhcps_status() == DHCP_STARTED) { - wifi_softap_dhcps_stop(); - }*/ - #endif - - WiFi.disconnect(false, true); - #ifdef ARDUINO_ARCH_ESP8266 - /*if (wifi_station_dhcpc_status() == DHCP_STARTED) { - wifi_station_dhcpc_stop(); - }*/ - - wifi_station_dhcpc_set_maxtry(5); - #endif - - #if defined(ARDUINO_ARCH_ESP32) && ESP_ARDUINO_VERSION_MAJOR < 3 - // Nothing. Because memory leaks when turn off WiFi on ESP32 SDK < 3.0.0 - return true; - #else - return WiFi.mode(WIFI_OFF); - #endif + return this->context.staChannel; } void reconnect() { - this->reconnectFlag = true; - } - - bool connect(bool force = false, unsigned int timeout = 1000u) { - if (this->isConnected() && !force) { - return true; - } - - if (force && !this->isApEnabled()) { - this->resetWifi(); - NetworkConnection::reset(); - - } else { - /*#ifdef ARDUINO_ARCH_ESP8266 - if (wifi_station_dhcpc_status() == DHCP_STARTED) { - wifi_station_dhcpc_stop(); - } - #endif*/ - - this->disconnect(); - } - - if (!this->hasStaCredentials()) { - return false; - } - - this->delayCallback(250); - - #ifdef ARDUINO_ARCH_ESP32 - if (this->setWifiHostname(this->hostname)) { - Log.straceln(FPSTR(L_NETWORK), F("Set hostname '%s': success"), this->hostname); - - } else { - Log.serrorln(FPSTR(L_NETWORK), F("Set hostname '%s': fail"), this->hostname); - } - #endif - - if (!WiFi.mode((WiFiMode_t)(WiFi.getMode() | WIFI_STA))) { - return false; - } - - this->delayCallback(250); - - #ifdef ARDUINO_ARCH_ESP8266 - if (this->setWifiHostname(this->hostname)) { - Log.straceln(FPSTR(L_NETWORK), F("Set hostname '%s': success"), this->hostname); - - } else { - Log.serrorln(FPSTR(L_NETWORK), F("Set hostname '%s': fail"), this->hostname); - } - - this->delayCallback(250); - #endif - - #ifdef ARDUINO_ARCH_ESP32 - WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); - WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); - #endif - - if (!this->useDhcp) { - WiFi.begin(this->staSsid, this->staPassword, this->staChannel, nullptr, false); - WiFi.config(this->staticIp, this->staticGateway, this->staticSubnet, this->staticDns); - WiFi.reconnect(); - - } else { - WiFi.begin(this->staSsid, this->staPassword, this->staChannel, nullptr, true); - } - - unsigned long beginConnectionTime = millis(); - while (millis() - beginConnectionTime < timeout) { - this->delayCallback(100); - - NetworkConnection::Status status = NetworkConnection::getStatus(); - if (status != NetworkConnection::Status::CONNECTING && status != NetworkConnection::Status::NONE) { - return status == NetworkConnection::Status::CONNECTED; - } - } - - return false; - } - - void disconnect() { - #ifdef ARDUINO_ARCH_ESP32 - WiFi.disconnectAsync(false, true); - - const unsigned long start = millis(); - while (WiFi.isConnected() && (millis() - start) < 5000) { - this->delayCallback(100); - } - #else - WiFi.disconnect(false, true); - #endif + this->wifi.reconnect(); } void loop() { - if (this->reconnectFlag) { - this->delayCallback(5000); + // The top of the hierarchy polls every pass so it can take the uplink whenever it can + this->ethernet.loop(); - Log.sinfoln(FPSTR(L_NETWORK), F("Reconnecting...")); - this->reconnectFlag = false; - this->disconnect(); - NetworkConnection::reset(); - this->delayCallback(1000); + this->trackUplink(); + this->updateWifiRadio(); - } else if (this->isConnected() && !this->hasStaCredentials()) { - Log.sinfoln(FPSTR(L_NETWORK), F("Reset")); - this->resetWifi(); - NetworkConnection::reset(); - this->delayCallback(1000); - - } else if (this->isConnected()) { - if (!this->connected) { - this->connectedTime = millis(); - this->connected = true; - - Log.sinfoln( - FPSTR(L_NETWORK), - F("Connected, downtime: %lu s., IP: %s, RSSI: %hhd"), - (millis() - this->disconnectedTime) / 1000, - WiFi.localIP().toString().c_str(), - WiFi.RSSI() - ); - } - - if (this->isApEnabled() && millis() - this->connectedTime > this->reconnectInterval && !this->hasApClients()) { - Log.sinfoln(FPSTR(L_NETWORK), F("Stop AP because STA connected")); - - WiFi.mode(WIFI_STA); - return; - } - - #ifdef ARDUINO_ARCH_ESP8266 - if (millis() - this->prevArpGratuitous > 60000) { - this->stationKeepAliveNow(); - this->prevArpGratuitous = millis(); - } - #endif - - } else { - if (this->connected) { - this->disconnectedTime = millis(); - this->connected = false; - - Log.sinfoln( - FPSTR(L_NETWORK), - F("Disconnected, reason: %d, uptime: %lu s."), - NetworkConnection::getDisconnectReason(), - (millis() - this->connectedTime) / 1000 - ); - } - - if (!this->hasStaCredentials() && !this->isApEnabled()) { - Log.sinfoln(FPSTR(L_NETWORK), F("No STA credentials, start AP")); - - WiFi.mode(WIFI_AP_STA); - this->delayCallback(250); - WiFi.softAP(this->apName, this->apPassword, this->apChannel); - - } else if (!this->isApEnabled() && millis() - this->disconnectedTime > this->failedConnectTimeout) { - Log.sinfoln(FPSTR(L_NETWORK), F("Disconnected for a long time, start AP")); - - WiFi.mode(WIFI_AP_STA); - this->delayCallback(250); - WiFi.softAP(this->apName, this->apPassword, this->apChannel); - - } else if (this->isConnecting() && millis() - this->prevReconnectingTime > this->resetConnectionTimeout) { - Log.swarningln(FPSTR(L_NETWORK), F("Connection timeout, reset wifi...")); - this->resetWifi(); - NetworkConnection::reset(); - this->delayCallback(250); - - } else if (!this->isConnecting() && this->hasStaCredentials() && (!this->prevReconnectingTime || millis() - this->prevReconnectingTime > this->reconnectInterval)) { - Log.sinfoln(FPSTR(L_NETWORK), F("Try connect...")); - - NetworkConnection::reset(); - if (!this->connect(true, this->connectionTimeout)) { - Log.straceln(FPSTR(L_NETWORK), F("Connection failed. Status: %d, reason: %d, raw reason: %d"), NetworkConnection::getStatus(), NetworkConnection::getDisconnectReason(), NetworkConnection::rawDisconnectReason); - } - - this->prevReconnectingTime = millis(); - } + if (this->getUplink() == Uplink::Ethernet) { + return; } + + // a pending ethernet attempt holds the hierarchy: nothing below it starts until it resolves + if (this->ethernet.isPending() && !this->wifi.isActive() && !this->ap.isActive()) { + return; + } + + this->wifi.loop(); + + // the ladder only reaches the AP once the station is out of the running + if (!this->wifi.isActive() && this->wifi.isPending()) { + return; + } + + this->ap.loop(); } static uint8_t rssiToSignalQuality(short int rssi) { @@ -382,95 +260,55 @@ namespace NetworkUtils { } protected: - const unsigned int reconnectInterval = 15000; - const unsigned int failedConnectTimeout = 185000; - const unsigned int connectionTimeout = 10000; - const unsigned int resetConnectionTimeout = 90000; + UplinkContext context; + WifiUplink wifi; + ApUplink ap; + EthernetUplink ethernet; - YieldCallback yieldCallback = []() { - ::yield(); - }; - DelayCallback delayCallback = [](unsigned int time) { - ::delay(time); - }; + Uplink uplink = Uplink::None; - const char* hostname = "esp"; - const char* apName = "ESP"; - const char* apPassword = nullptr; - uint8_t apChannel = 1; - - const char* staSsid = nullptr; - const char* staPassword = nullptr; - uint8_t staChannel = 0; - - bool useDhcp = true; - IPAddress staticIp; - IPAddress staticGateway; - IPAddress staticSubnet; - IPAddress staticDns; - - bool connected = false; - bool reconnectFlag = false; - unsigned long prevArpGratuitous = 0; - unsigned long prevReconnectingTime = 0; - unsigned long connectedTime = 0; - unsigned long disconnectedTime = 0; - - - bool setWifiHostname(const char* hostname) { - if (!this->isHostnameValid(hostname)) { - return false; - } - - if (strcmp(WiFi.getHostname(), hostname) == 0) { - return true; - } - - return WiFi.setHostname(hostname); - } - - #ifdef ARDUINO_ARCH_ESP8266 /** - * @brief - * https://github.com/arendst/Tasmota/blob/e6515883f0ee5451931b6280ff847b117de5a231/tasmota/tasmota_support/support_wifi.ino#L1196 + * @brief The interface holding the station address: the wired one while it carries the + * uplink, the station otherwise */ - static void stationKeepAliveNow(void) { - for (netif* interface = netif_list; interface != nullptr; interface = interface->next) { - if ( - (interface->flags & NETIF_FLAG_LINK_UP) - && (interface->flags & NETIF_FLAG_UP) - && interface->num == STATION_IF - && (!ip4_addr_isany_val(*netif_ip4_addr(interface))) - ) { - etharp_gratuitous(interface); - break; - } + NetworkUplink* station() { + if (this->getUplink() == Uplink::Ethernet) { + return &this->ethernet; } + + return &this->wifi; } - #endif /** - * @brief check RFC compliance + * @brief Notice an uplink change so the hierarchy is walked from the top again + */ + void trackUplink() { + const Uplink current = this->getUplink(); + + if (current == this->uplink) { + return; + } + + Log.sinfoln(FPSTR(L_NETWORK), F("Uplink: %s -> %s"), uplinkName(this->uplink), uplinkName(current)); + + this->uplink = current; + } + + /** + * @brief Keep the WiFi radio in step with the uplink * - * @param value - * @return true - * @return false + * WiFi is only in service while it carries the traffic. The AP is for first configuration: + * once a cable carries the uplink the radio goes off, and if that uplink never comes up the + * ladder falls back to the AP again on its own. */ - static bool isHostnameValid(const char* value) { - size_t len = strlen(value); - if (len > 24) { - return false; - } else if (value[len - 1] == '-') { - return false; + void updateWifiRadio() { + if (this->ethernet.isActive()) { + this->wifi.suspend(); + + return; } - for (size_t i = 0; i < len; i++) { - if (!isalnum(value[i]) && value[i] != '-') { - return false; - } - } - - return true; + this->wifi.resume(); } }; -} \ No newline at end of file +} diff --git a/lib/NetworkUtils/NetworkUplink.h b/lib/NetworkUtils/NetworkUplink.h new file mode 100644 index 0000000..06e3f70 --- /dev/null +++ b/lib/NetworkUtils/NetworkUplink.h @@ -0,0 +1,143 @@ +#pragma once +#if defined(ARDUINO_ARCH_ESP8266) + #include + #include "lwip/etharp.h" +#elif defined(ARDUINO_ARCH_ESP32) + #include + #include +#endif + +// W5500 support needs both the ESP32 core (for ETH.h) and the per-env USE_ETHERNET flag. +// USE_ETHERNET defaults to false in defines.h, so this must be a value test, not #ifdef. +#if USE_ETHERNET && defined(ARDUINO_ARCH_ESP32) + #define NETWORK_HAS_ETHERNET +#endif + +namespace NetworkUtils { + typedef std::function YieldCallback; + typedef std::function DelayCallback; + + /** + * @brief Settings and callbacks every uplink reads + */ + struct UplinkContext { + const char* hostname = "esp"; + + const char* staSsid = nullptr; + const char* staPassword = nullptr; + uint8_t staChannel = 0; + + const char* apName = "ESP"; + const char* apPassword = nullptr; + uint8_t apChannel = 1; + + bool useDhcp = true; + IPAddress staticIp; + IPAddress staticGateway; + IPAddress staticSubnet; + IPAddress staticDns; + + YieldCallback yieldCallback = []() { + ::yield(); + }; + DelayCallback delayCallback = [](unsigned int time) { + ::delay(time); + }; + + /** + * @brief check RFC compliance + * + * @param value + * @return true + * @return false + */ + static bool isHostnameValid(const char* value) { + size_t len = strlen(value); + if (len > 24) { + return false; + } else if (value[len - 1] == '-') { + return false; + } + + for (size_t i = 0; i < len; i++) { + if (!isalnum(value[i]) && value[i] != '-') { + return false; + } + } + + return true; + } + }; + + /** + * @brief How long an uplink may still take the pass + * + * Armed when the uplink starts trying, disarmed when it resolves either way. A window that has + * run out means the uplink gave up, which lets the class below it take over. + */ + class UplinkAttempt { + public: + void arm(unsigned long window) { + this->start = millis() ? millis() : 1; + this->window = window; + } + + void disarm() { + this->start = 0; + this->window = 0; + } + + bool isArmed() const { + return this->window > 0; + } + + bool isPending() const { + return this->window > 0 && (millis() - this->start) < this->window; + } + + protected: + unsigned long start = 0; + unsigned long window = 0; + }; + + /** + * @brief One interface that is able to carry the uplink + */ + class NetworkUplink { + public: + explicit NetworkUplink(UplinkContext* context) : context(context) {} + + virtual ~NetworkUplink() {} + + /** + * @brief Run one pass of this interface's state machine + */ + virtual void loop() = 0; + + /** + * @brief The interface is able to carry traffic right now + */ + virtual bool isActive() = 0; + + /** + * @brief The interface may still become active, so nothing below it should start + */ + virtual bool isPending() { + return !this->isActive() && this->attempt.isPending(); + } + + virtual IPAddress localIP() = 0; + virtual IPAddress subnetMask() = 0; + virtual IPAddress gatewayIP() = 0; + virtual IPAddress dnsIP() = 0; + virtual String macAddress() = 0; + + virtual short int rssi() { + return 0; + } + + protected: + UplinkContext* context; + UplinkAttempt attempt; + }; +} diff --git a/lib/NetworkUtils/NetworkWifi.h b/lib/NetworkUtils/NetworkWifi.h new file mode 100644 index 0000000..8d6fe12 --- /dev/null +++ b/lib/NetworkUtils/NetworkWifi.h @@ -0,0 +1,431 @@ +#pragma once +#include +#include + +namespace NetworkUtils { + /** + * @brief WiFi uplink: the station association, its radio, and the fallback AP + */ + class WifiUplink : public NetworkUplink { + public: + explicit WifiUplink(UplinkContext* context) : NetworkUplink(context) {} + + void loop() override { + if (this->reconnectFlag) { + this->context->delayCallback(5000); + + Log.sinfoln(FPSTR(L_NETWORK), F("Reconnecting...")); + this->reconnectFlag = false; + this->disconnect(); + NetworkConnection::reset(); + this->context->delayCallback(1000); + + return; + } + + if (this->isAssociated() && !this->hasCredentials()) { + Log.sinfoln(FPSTR(L_NETWORK), F("Reset")); + this->resetWifi(); + NetworkConnection::reset(); + this->context->delayCallback(1000); + + return; + } + + if (this->isAssociated()) { + this->attempt.disarm(); + + if (!this->connected) { + this->connectedTime = millis(); + this->connected = true; + + Log.sinfoln( + FPSTR(L_NETWORK), + F("Connected, downtime: %lu s., IP: %s, RSSI: %hhd"), + (millis() - this->disconnectedTime) / 1000, + WiFi.localIP().toString().c_str(), + WiFi.RSSI() + ); + } + + #ifdef ARDUINO_ARCH_ESP8266 + if (millis() - this->prevArpGratuitous > 60000) { + this->stationKeepAliveNow(); + this->prevArpGratuitous = millis(); + } + #endif + + return; + } + + // the association is down: track the downtime and keep asking for the network + if (this->connected) { + this->disconnectedTime = millis(); + this->connected = false; + this->attempt.arm(this->failedConnectTimeout); + + Log.sinfoln( + FPSTR(L_NETWORK), + F("Disconnected, reason: %d, uptime: %lu s."), + NetworkConnection::getDisconnectReason(), + (millis() - this->connectedTime) / 1000 + ); + } + + if (!this->hasCredentials()) { + this->attempt.disarm(); + + return; + } + + if (!this->attempt.isArmed()) { + this->attempt.arm(this->failedConnectTimeout); + } + + if (this->isConnecting() && millis() - this->prevReconnectingTime > this->resetConnectionTimeout) { + Log.swarningln(FPSTR(L_NETWORK), F("Connection timeout, reset wifi...")); + this->resetWifi(); + NetworkConnection::reset(); + this->context->delayCallback(250); + + return; + } + + if (!this->isConnecting() && (!this->prevReconnectingTime || millis() - this->prevReconnectingTime > this->reconnectInterval)) { + Log.sinfoln(FPSTR(L_NETWORK), F("Try connect...")); + + NetworkConnection::reset(); + if (!this->connect(true, this->connectionTimeout)) { + Log.straceln(FPSTR(L_NETWORK), F("Connection failed. Status: %d, reason: %d, raw reason: %d"), NetworkConnection::getStatus(), NetworkConnection::getDisconnectReason(), NetworkConnection::rawDisconnectReason); + } + + this->prevReconnectingTime = millis(); + } + } + + /** + * @brief WiFi carries the uplink: radio running and associated + */ + bool isActive() override { + return !this->suspended && this->isAssociated(); + } + + bool isAssociated() { + return this->isStaEnabled() && NetworkConnection::getStatus() == NetworkConnection::Status::CONNECTED; + } + + bool isConnecting() { + return this->isStaEnabled() && NetworkConnection::getStatus() == NetworkConnection::Status::CONNECTING; + } + + bool isStaEnabled() { + return (WiFi.getMode() & WIFI_STA) != 0; + } + + bool isApEnabled() { + return (WiFi.getMode() & WIFI_AP) != 0; + } + + /** + * @brief When the association first came up, for the fallback AP's retirement delay + */ + unsigned long getConnectedTime() { + return this->connectedTime; + } + + /** + * @brief Stop the radio while another uplink carries the traffic + * + * esp_wifi_stop() parks the radio without tearing the driver and its netifs down. + * WiFi.mode(WIFI_OFF) does the same job but also deinitialises, which panicked when it + * was called from this task with lwIP live — keep the driver allocated. + */ + void suspend() { + #ifdef ARDUINO_ARCH_ESP32 + if (this->suspended) { + return; + } + + if (WiFi.getMode() == WIFI_MODE_NULL) { + return; + } + + // a scan holds the radio: parking it mid-scan leaves the driver half torn down + if (WiFi.scanComplete() == WIFI_SCAN_RUNNING) { + return; + } + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: uplink active, switching the WiFi radio off")); + + esp_wifi_stop(); + this->suspended = true; + #endif + } + + /** + * @brief Bring the radio back when no other uplink carries the traffic + */ + void resume() { + #ifdef ARDUINO_ARCH_ESP32 + if (!this->suspended) { + return; + } + + this->suspended = false; + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: uplink lost, switching the WiFi radio back on")); + + esp_wifi_start(); + + this->connected = false; + this->disconnectedTime = millis(); + this->prevReconnectingTime = 0; + this->attempt.arm(this->failedConnectTimeout); + + NetworkConnection::reset(); + + if (this->hasCredentials()) { + WiFi.begin(this->context->staSsid, this->context->staPassword); + + Log.sinfoln(FPSTR(L_NETWORK), F("Ethernet: reconnecting to WiFi \"%s\""), this->context->staSsid); + } + #endif + } + + bool isSuspended() { + return this->suspended; + } + + bool resetWifi() { + // set policy manual for work 13 ch + { + #ifdef ARDUINO_ARCH_ESP8266 + wifi_country_t country = {"JP", 1, 14, WIFI_COUNTRY_POLICY_MANUAL}; + wifi_set_country(&country); + #elif defined(ARDUINO_ARCH_ESP32) + const wifi_country_t country = {"JP", 1, 14, CONFIG_ESP32_PHY_MAX_WIFI_TX_POWER, WIFI_COUNTRY_POLICY_MANUAL}; + esp_wifi_set_country(&country); + #endif + } + + WiFi.persistent(false); + #if !defined(ESP_ARDUINO_VERSION_MAJOR) || ESP_ARDUINO_VERSION_MAJOR < 3 + WiFi.setAutoConnect(false); + #endif + WiFi.setAutoReconnect(false); + + #ifdef ARDUINO_ARCH_ESP8266 + WiFi.setSleepMode(WIFI_NONE_SLEEP); + #elif defined(ARDUINO_ARCH_ESP32) + WiFi.setSleep(USE_BLE ? WIFI_PS_MIN_MODEM : WIFI_PS_NONE); + #endif + + WiFi.softAPdisconnect(); + #ifdef ARDUINO_ARCH_ESP8266 + /*if (wifi_softap_dhcps_status() == DHCP_STARTED) { + wifi_softap_dhcps_stop(); + }*/ + #endif + + WiFi.disconnect(false, true); + #ifdef ARDUINO_ARCH_ESP8266 + /*if (wifi_station_dhcpc_status() == DHCP_STARTED) { + wifi_station_dhcpc_stop(); + }*/ + + wifi_station_dhcpc_set_maxtry(5); + #endif + + #if defined(ARDUINO_ARCH_ESP32) && ESP_ARDUINO_VERSION_MAJOR < 3 + // Nothing. Because memory leaks when turn off WiFi on ESP32 SDK < 3.0.0 + return true; + #else + return WiFi.mode(WIFI_OFF); + #endif + } + + /** + * @brief Ask for a fresh connect + * + * The attempt window is armed here so the ladder keeps the fallback AP out of the way while + * the station works on this reconnect; it expires like any other failed attempt. + */ + void reconnect() { + this->attempt.arm(this->failedConnectTimeout); + + this->reconnectFlag = true; + } + + bool connect(bool force = false, unsigned int timeout = 1000u) { + if (this->isAssociated() && !force) { + return true; + } + + if (force && !this->isApEnabled()) { + this->resetWifi(); + NetworkConnection::reset(); + + } else { + /*#ifdef ARDUINO_ARCH_ESP8266 + if (wifi_station_dhcpc_status() == DHCP_STARTED) { + wifi_station_dhcpc_stop(); + } + #endif*/ + + this->disconnect(); + } + + if (!this->hasCredentials()) { + return false; + } + + this->context->delayCallback(250); + + #ifdef ARDUINO_ARCH_ESP32 + if (this->setHostname(this->context->hostname)) { + Log.straceln(FPSTR(L_NETWORK), F("Set hostname '%s': success"), this->context->hostname); + + } else { + Log.serrorln(FPSTR(L_NETWORK), F("Set hostname '%s': fail"), this->context->hostname); + } + #endif + + if (!WiFi.mode((WiFiMode_t)(WiFi.getMode() | WIFI_STA))) { + return false; + } + + this->context->delayCallback(250); + + #ifdef ARDUINO_ARCH_ESP8266 + if (this->setHostname(this->context->hostname)) { + Log.straceln(FPSTR(L_NETWORK), F("Set hostname '%s': success"), this->context->hostname); + + } else { + Log.serrorln(FPSTR(L_NETWORK), F("Set hostname '%s': fail"), this->context->hostname); + } + + this->context->delayCallback(250); + #endif + + #ifdef ARDUINO_ARCH_ESP32 + WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); + WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); + #endif + + if (!this->context->useDhcp) { + WiFi.begin(this->context->staSsid, this->context->staPassword, this->context->staChannel, nullptr, false); + WiFi.config(this->context->staticIp, this->context->staticGateway, this->context->staticSubnet, this->context->staticDns); + WiFi.reconnect(); + + } else { + #ifdef ARDUINO_ARCH_ESP32 + // the interface keeps a static address from an earlier association, so the addressing has + // to be reset before the station reconnects or the switch back to DHCP never takes effect + const IPAddress noIp(0, 0, 0, 0); + WiFi.config(noIp, noIp, noIp); + #endif + + WiFi.begin(this->context->staSsid, this->context->staPassword, this->context->staChannel, nullptr, true); + } + + unsigned long beginConnectionTime = millis(); + while (millis() - beginConnectionTime < timeout) { + this->context->delayCallback(100); + + NetworkConnection::Status status = NetworkConnection::getStatus(); + if (status != NetworkConnection::Status::CONNECTING && status != NetworkConnection::Status::NONE) { + return status == NetworkConnection::Status::CONNECTED; + } + } + + return false; + } + + void disconnect() { + #ifdef ARDUINO_ARCH_ESP32 + WiFi.disconnectAsync(false, true); + + const unsigned long start = millis(); + while (WiFi.isConnected() && (millis() - start) < 5000) { + this->context->delayCallback(100); + } + #else + WiFi.disconnect(false, true); + #endif + } + + bool setHostname(const char* hostname) { + if (!UplinkContext::isHostnameValid(hostname)) { + return false; + } + + if (strcmp(WiFi.getHostname(), hostname) == 0) { + return true; + } + + return WiFi.setHostname(hostname); + } + + bool hasCredentials() { + return this->context->staSsid != nullptr; + } + + IPAddress localIP() override { + return WiFi.localIP(); + } + + IPAddress subnetMask() override { + return WiFi.subnetMask(); + } + + IPAddress gatewayIP() override { + return WiFi.gatewayIP(); + } + + IPAddress dnsIP() override { + return WiFi.dnsIP(); + } + + String macAddress() override { + return WiFi.macAddress(); + } + + short int rssi() override { + return WiFi.RSSI(); + } + + protected: + const unsigned int reconnectInterval = 15000; + const unsigned int failedConnectTimeout = 185000; + const unsigned int connectionTimeout = 10000; + const unsigned int resetConnectionTimeout = 90000; + + bool connected = false; + bool reconnectFlag = false; + bool suspended = false; + unsigned long prevArpGratuitous = 0; + unsigned long prevReconnectingTime = 0; + unsigned long connectedTime = 0; + unsigned long disconnectedTime = 0; + + #ifdef ARDUINO_ARCH_ESP8266 + /** + * @brief + * https://github.com/arendst/Tasmota/blob/e6515883f0ee5451931b6280ff847b117de5a231/tasmota/tasmota_support/support_wifi.ino#L1196 + */ + static void stationKeepAliveNow(void) { + for (netif* interface = netif_list; interface != nullptr; interface = interface->next) { + if ( + (interface->flags & NETIF_FLAG_LINK_UP) + && (interface->flags & NETIF_FLAG_UP) + && interface->num == STATION_IF + && (!ip4_addr_isany_val(*netif_ip4_addr(interface))) + ) { + etharp_gratuitous(interface); + break; + } + } + } + #endif + }; +} diff --git a/platformio.ini b/platformio.ini index 3542931..f62b5fd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -324,6 +324,8 @@ check_tool = ${esp32_defaults.check_tool} check_flags = ${esp32_defaults.check_flags} [env:esp32_c6] +; W5500 ethernet (USE_ETHERNET) is hardware-validated on this target only; every other env +; inherits the `false` default from src/defines.h and compiles the driver out. platform = ${esp32_defaults.platform} framework = arduino, espidf platform_packages = ${esp32_defaults.platform_packages} @@ -344,6 +346,7 @@ build_type = ${esp32_defaults.build_type} build_flags = ${esp32_defaults.build_flags} -D USE_BLE=1 + -D USE_ETHERNET=1 -D DEFAULT_OT_IN_GPIO=15 -D DEFAULT_OT_OUT_GPIO=23 -D DEFAULT_SENSOR_OUTDOOR_GPIO=0 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index cd05530..609263d 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -9,6 +9,11 @@ CONFIG_COMPILER_OPTIMIZATION_SIZE=y CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_ESPTOOLPY_HEADER_FLASHSIZE_UPDATE=y +# +# Ethernet (W5500 over SPI) +CONFIG_ETH_USE_SPI_ETHERNET=y +CONFIG_ETH_SPI_ETHERNET_W5500=y + # # BT config CONFIG_BT_ENABLED=y diff --git a/src/HaHelper.h b/src/HaHelper.h index 88a5d4d..3e82e28 100644 --- a/src/HaHelper.h +++ b/src/HaHelper.h @@ -25,6 +25,12 @@ public: } bool publishDynamicSensor(Sensors::Settings& sSensor, Sensors::ValueType vType = Sensors::ValueType::PRIMARY, UnitSystem unit = UnitSystem::METRIC, bool enabledByDefault = true) { + #if !USE_BLE + if (sSensor.type == Sensors::Type::BLUETOOTH) { + return false; + } + #endif + JsonDocument doc; // set device class & unit of measurement @@ -1178,7 +1184,10 @@ public: bool publishNetworkRssi(bool enabledByDefault = true) { JsonDocument doc; - doc[FPSTR(HA_AVAILABILITY)][FPSTR(HA_TOPIC)] = this->statusTopic.c_str(); + doc[FPSTR(HA_AVAILABILITY)][0][FPSTR(HA_TOPIC)] = this->statusTopic.c_str(); + doc[FPSTR(HA_AVAILABILITY)][1][FPSTR(HA_TOPIC)] = this->stateTopic.c_str(); + doc[FPSTR(HA_AVAILABILITY)][1][FPSTR(HA_VALUE_TEMPLATE)] = F("{{ iif(value_json.master.network.connectionType == 'wifi', 'online', 'offline') }}"); + doc[FPSTR(HA_AVAILABILITY_MODE)] = F("all"); doc[FPSTR(HA_ENABLED_BY_DEFAULT)] = enabledByDefault; doc[FPSTR(HA_UNIQUE_ID)] = this->getUniqueIdWithPrefix(FPSTR(S_RSSI)); doc[FPSTR(HA_DEFAULT_ENTITY_ID)] = this->getEntityIdWithPrefix(FPSTR(HA_ENTITY_SENSOR), FPSTR(S_RSSI)); diff --git a/src/MainTask.h b/src/MainTask.h index 3f5e875..44fde67 100644 --- a/src/MainTask.h +++ b/src/MainTask.h @@ -102,7 +102,8 @@ protected: vars.mqtt.connected = tMqtt->isConnected(); vars.network.connected = network->isConnected(); - vars.network.rssi = network->isConnected() ? WiFi.RSSI() : 0; + vars.network.rssi = network->getRssi(); + vars.network.connectionType = network->getUplink(); if (settings.system.logLevel >= TinyLogger::Level::SILENT && settings.system.logLevel <= TinyLogger::Level::VERBOSE) { if (Log.getLevel() != settings.system.logLevel) { diff --git a/src/PortalTask.h b/src/PortalTask.h index 662e0e5..6142d54 100644 --- a/src/PortalTask.h +++ b/src/PortalTask.h @@ -29,6 +29,7 @@ public: this->webServer = new WebServer(80); this->bufferedWebServer = new BufferedWebServer(this->webServer, 32u); this->dnsServer = new DNSServer(); + this->mDnsRetryTime = millis() - this->mDnsRetryInterval; } ~PortalTask() { @@ -56,6 +57,11 @@ protected: bool dnsServerEnabled = false; unsigned long webServerChangeState = 0; bool mDnsState = false; + #if USE_ETHERNET + NetworkMgr::Uplink mdnsConnectionType = NetworkMgr::Uplink::None; + #endif + unsigned long mDnsRetryTime = 0; + const unsigned long mDnsRetryInterval = 5000; #if defined(ARDUINO_ARCH_ESP32) const char* getTaskName() override { @@ -296,12 +302,14 @@ protected: networkSettings.staticConfig.gateway, networkSettings.staticConfig.subnet, networkSettings.staticConfig.dns - ); + ) + ->setEthernet(networkSettings.ethernet.enabled); changed = true; } if (!doc[FPSTR(S_SETTINGS)].isNull() && jsonToSettings(doc[FPSTR(S_SETTINGS)], settings)) { fsSettings.update(); + applyEthernetSettings(); changed = true; } @@ -315,14 +323,14 @@ protected: if (sensorId < 0 || sensorId > 255 || !Sensors::isValidSensorId(sensorId)) { continue; } - + if (jsonToSensorSettings(sensorId, sensor.value(), Sensors::settings[sensorId])) { fsSensorsSettings.update(); changed = true; } } } - + doc.clear(); doc.shrinkToFit(); @@ -341,6 +349,7 @@ protected: JsonDocument doc; networkSettingsToJson(networkSettings, doc); + this->ethernetToJson(doc); doc.shrinkToFit(); this->bufferedWebServer->send(200, F("application/json"), doc); @@ -350,7 +359,7 @@ protected: if (this->isAuthRequired() && !this->isValidCredentials()) { return this->webServer->send(401); } - + if (vars.states.restarting) { return this->webServer->send(503); } @@ -376,12 +385,20 @@ protected: } bool changed = jsonToNetworkSettings(doc, networkSettings); + + // ethernet is only usable with a complete W5500 pin set + if (!this->isEthernetConfigured() && networkSettings.ethernet.enabled) { + networkSettings.ethernet.enabled = false; + changed = true; + } + doc.clear(); doc.shrinkToFit(); networkSettingsToJson(networkSettings, doc); + this->ethernetToJson(doc); doc.shrinkToFit(); - + this->bufferedWebServer->send(changed ? 201 : 200, F("application/json"), doc); if (changed) { @@ -399,7 +416,10 @@ protected: networkSettings.staticConfig.subnet, networkSettings.staticConfig.dns ) + ->setEthernet(networkSettings.ethernet.enabled) ->reconnect(); + + this->applyEthernetSettings(); } }); @@ -408,6 +428,12 @@ protected: return this->webServer->send(401); } + // scanning needs WiFi, which is not the uplink while ethernet carries the traffic + if (network->getUplink() == NetworkMgr::Uplink::Ethernet) { + this->webServer->send(409); + return; + } + auto apCount = WiFi.scanComplete(); if (apCount <= 0) { if (apCount != WIFI_SCAN_RUNNING) { @@ -417,11 +443,11 @@ protected: WiFi.scanNetworks(true, true, true); #endif } - + this->webServer->send(404); return; } - + JsonDocument doc; for (short int i = 0; i < apCount; i++) { const String& ssid = WiFi.SSID(i); @@ -454,7 +480,7 @@ protected: JsonDocument doc; settingsToJson(settings, doc); doc.shrinkToFit(); - + this->bufferedWebServer->send(200, F("application/json"), doc); }); @@ -462,7 +488,7 @@ protected: if (this->isAuthRequired() && !this->isValidCredentials()) { return this->webServer->send(401); } - + if (vars.states.restarting) { return this->webServer->send(503); } @@ -501,6 +527,7 @@ protected: doc.shrinkToFit(); fsSettings.update(); + applyEthernetSettings(); tMqtt->resetPublishedSettingsTime(); } }); @@ -516,7 +543,7 @@ protected: if (this->webServer->hasArg(F("detailed"))) { detailed = this->webServer->arg(F("detailed")).toInt() > 0; } - + JsonDocument doc; for (uint8_t sensorId = 0; sensorId <= Sensors::getMaxSensorId(); sensorId++) { if (detailed) { @@ -530,7 +557,7 @@ protected: doc[sensorId] = Sensors::settings[sensorId].name; } } - + doc.shrinkToFit(); this->bufferedWebServer->send(200, F("application/json"), doc); }); @@ -540,7 +567,7 @@ protected: if (this->isAuthRequired() && !this->isValidCredentials()) { return this->webServer->send(401); } - + if (!this->webServer->hasArg(F("id"))) { return this->webServer->send(400); } @@ -561,12 +588,12 @@ protected: doc.shrinkToFit(); this->bufferedWebServer->send(200, F("application/json"), doc); }); - + this->webServer->on(F("/api/sensor"), HTTP_POST, [this]() { if (this->isAuthRequired() && !this->isValidCredentials()) { return this->webServer->send(401); } - + if (vars.states.restarting) { return this->webServer->send(503); } @@ -623,7 +650,7 @@ protected: auto& sSettings = Sensors::settings[sensorId]; sensorSettingsToJson(sensorId, sSettings, doc); doc.shrinkToFit(); - + this->bufferedWebServer->send(changed ? 201 : 200, F("application/json"), doc); } @@ -674,13 +701,13 @@ protected: varsToJson(vars, doc); doc.shrinkToFit(); - + this->bufferedWebServer->send(changed ? 201 : 200, F("application/json"), doc); if (changed) { doc.clear(); doc.shrinkToFit(); - + tMqtt->resetPublishedVarsTime(); } }); @@ -690,6 +717,16 @@ protected: JsonDocument doc; + // capabilities: only the flags this build actually has; the UI shows features on an explicit true + auto docFeatures = doc[FPSTR(S_FEATURES)].to(); + #if USE_ETHERNET + docFeatures[FPSTR(S_ETHERNET)] = true; + #endif + + #if USE_BLE + docFeatures[FPSTR(S_BLE)] = true; + #endif + auto docSystem = doc[FPSTR(S_SYSTEM)].to(); docSystem[FPSTR(S_RESET_REASON)] = getResetReason(); docSystem[FPSTR(S_UPTIME)] = millis() / 1000; @@ -698,9 +735,15 @@ protected: docNetwork[FPSTR(S_HOSTNAME)] = networkSettings.hostname; docNetwork[FPSTR(S_MAC)] = network->getStaMac(); docNetwork[FPSTR(S_CONNECTED)] = isConnected; - docNetwork[FPSTR(S_SSID)] = network->getStaSsid(); - docNetwork[FPSTR(S_SIGNAL_QUALITY)] = isConnected ? NetworkMgr::rssiToSignalQuality(network->getRssi()) : 0; - docNetwork[FPSTR(S_CHANNEL)] = isConnected ? network->getStaChannel() : 0; + docNetwork[FPSTR(S_CONNECTION_TYPE)] = network->getConnectionType(); + // SSID, signal and channel describe the WiFi uplink only: with ethernet up the radio is + // parked and those readings are stale, so report them as not applicable rather than 0 dBm + const bool wifiUplink = network->getUplink() == NetworkMgr::Uplink::Wifi; + const char* staSsid = network->getStaSsid(); + + docNetwork[FPSTR(S_SSID)] = (wifiUplink && staSsid) ? staSsid : ""; + docNetwork[FPSTR(S_SIGNAL_QUALITY)] = network->getSignalQuality(); + docNetwork[FPSTR(S_CHANNEL)] = wifiUplink ? network->getStaChannel() : 0; docNetwork[FPSTR(S_IP)] = isConnected ? network->getStaIp().toString() : ""; docNetwork[FPSTR(S_SUBNET)] = isConnected ? network->getStaSubnet().toString() : ""; docNetwork[FPSTR(S_GATEWAY)] = isConnected ? network->getStaGateway().toString() : ""; @@ -727,7 +770,7 @@ protected: docHeap[FPSTR(S_MIN_FREE)] = getFreeHeap(true); docHeap[FPSTR(S_MAX_FREE_BLOCK)] = getMaxFreeBlockHeap(); docHeap[FPSTR(S_MIN_MAX_FREE_BLOCK)] = getMaxFreeBlockHeap(true); - + auto docChip = doc[FPSTR(S_CHIP)].to(); #ifdef ARDUINO_ARCH_ESP8266 docChip[FPSTR(S_MODEL)] = esp_is_8285() ? F("ESP8285") : F("ESP8266"); @@ -766,6 +809,9 @@ protected: this->webServer->on(F("/api/debug"), HTTP_GET, [this]() { JsonDocument doc; + auto docNetwork = doc[FPSTR(S_NETWORK)].to(); + docNetwork[FPSTR(S_CONNECTION_TYPE)] = network->getConnectionType(); + auto docBuild = doc[FPSTR(S_BUILD)].to(); docBuild[FPSTR(S_VERSION)] = BUILD_VERSION; docBuild[FPSTR(S_DATE)] = __DATE__ " " __TIME__; @@ -787,7 +833,7 @@ protected: docHeap[FPSTR(S_MIN_FREE)] = getFreeHeap(true); docHeap[FPSTR(S_MAX_FREE_BLOCK)] = getMaxFreeBlockHeap(); docHeap[FPSTR(S_MIN_MAX_FREE_BLOCK)] = getMaxFreeBlockHeap(true); - + auto docChip = doc[FPSTR(S_CHIP)].to(); #ifdef ARDUINO_ARCH_ESP8266 docChip[FPSTR(S_MODEL)] = esp_is_8285() ? F("ESP8285") : F("ESP8266"); @@ -846,7 +892,7 @@ protected: docCrash[FPSTR(S_EPC)] = epcStr; } } - + doc.shrinkToFit(); char filename[64]; @@ -892,54 +938,31 @@ protected: #ifdef ARDUINO_ARCH_ESP32 this->delay(250); #endif - + this->startWebServer(); - Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Started: AP up or STA connected")); - - // Enabling mDNS - if (!this->mDnsState && settings.portal.mdns) { - if (MDNS.begin(networkSettings.hostname)) { - MDNS.addService("http", "tcp", 80); - this->mDnsState = true; - - Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS enabled and service added")); - } - } + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Started: AP up or network connected")); #ifdef ARDUINO_ARCH_ESP8266 ::optimistic_yield(1000); #endif - } else if (this->stateWebServer() && !network->isApEnabled() && !network->isStaEnabled()) { + } else if (this->stateWebServer() && !network->isApEnabled() && !network->isConnected()) { this->stopWebServer(); - Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Stopped: AP and STA down")); - - // Disabling mDNS - if (this->mDnsState) { - MDNS.end(); - this->mDnsState = false; - - Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS disabled")); - } + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Stopped: no network")); #ifdef ARDUINO_ARCH_ESP8266 ::optimistic_yield(1000); #endif } - // Disabling mDNS if disabled in settings - if (this->mDnsState && !settings.portal.mdns) { - MDNS.end(); - this->mDnsState = false; - - Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS disabled")); - } + // mDNS + this->loopMdns(); // dns server if (!this->stateDnsServer() && !network->isConnected() && network->isApEnabled() && this->stateWebServer()) { this->startDnsServer(); Log.straceln(FPSTR(L_PORTAL_DNSSERVER), F("Started: AP up")); - + #ifdef ARDUINO_ARCH_ESP8266 ::optimistic_yield(1000); #endif @@ -982,6 +1005,99 @@ protected: return !network->isApEnabled() && settings.portal.auth && strlen(settings.portal.password); } + /** + * @brief Keep mDNS in sync with the active uplink + * @details The responder binds its records to the interface that is up when it starts, so when + * switching between interfaces, mDNS must be restarted + */ + void loopMdns() { + #if USE_ETHERNET + const NetworkMgr::Uplink connectionType = network->getUplink(); + + if (connectionType != this->mdnsConnectionType) { + this->mdnsConnectionType = connectionType; + + if (this->mDnsState) { + MDNS.end(); + this->mDnsState = false; + + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS restarting: connection is now %s"), NetworkMgr::uplinkName(connectionType)); + } + } + #endif + + if (!settings.portal.mdns || !this->stateWebServer()) { + if (this->mDnsState) { + MDNS.end(); + this->mDnsState = false; + + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS disabled")); + } + + return; + } + + if (this->mDnsState || millis() - this->mDnsRetryTime < this->mDnsRetryInterval) { + return; + } + + this->mDnsRetryTime = millis(); + + if (MDNS.begin(networkSettings.hostname)) { + MDNS.addService("http", "tcp", 80); + this->mDnsState = true; + + Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS enabled and service added")); + } + } + + /** + * @brief Add the computed W5500 state to a network settings document + * @details The key is absent entirely on builds without ethernet, rather than an empty object: + * presence of the section is what tells a client the target can do it. The stored fields + * (enabled flag, pinout) come from networkSettingsToJson, this adds only what is derived. + */ + void ethernetToJson(JsonDocument& dst) { + #if USE_ETHERNET + dst[FPSTR(S_ETHERNET)][FPSTR(S_CONFIGURED)] = this->isEthernetConfigured(); + #endif + } + + /** + * @brief Ethernet can only be used when all six W5500 pins are configured + */ + bool isEthernetConfigured() { + #if USE_ETHERNET + return networkSettings.ethernet.w5500.clk != GPIO_IS_NOT_CONFIGURED + && networkSettings.ethernet.w5500.mosi != GPIO_IS_NOT_CONFIGURED + && networkSettings.ethernet.w5500.miso != GPIO_IS_NOT_CONFIGURED + && networkSettings.ethernet.w5500.cs != GPIO_IS_NOT_CONFIGURED + && networkSettings.ethernet.w5500.irq != GPIO_IS_NOT_CONFIGURED + && networkSettings.ethernet.w5500.rst != GPIO_IS_NOT_CONFIGURED; + #else + // nothing to configure: the W5500 driver is not compiled into this target + return false; + #endif + } + + /** + * @brief Push the W5500 wiring from the settings into the network manager + */ + void applyEthernetSettings() { + #if USE_ETHERNET + network + ->setEthernet(networkSettings.ethernet.enabled) + ->setEthernetPins( + networkSettings.ethernet.w5500.clk, + networkSettings.ethernet.w5500.mosi, + networkSettings.ethernet.w5500.miso, + networkSettings.ethernet.w5500.cs, + networkSettings.ethernet.w5500.irq, + networkSettings.ethernet.w5500.rst + ); + #endif + } + bool isValidCredentials() { return this->webServer->authenticate(settings.portal.login, settings.portal.password); } @@ -1075,4 +1191,4 @@ protected: strftime(localNowValue, sizeof(localNowValue), PSTR("%Y-%m-%d-%H-%M-%S"), localNow); snprintf_P(filename, maxSizeFilename, PSTR("%s_%s_%s.json"), networkSettings.hostname, localNowValue, type); } -}; \ No newline at end of file +}; diff --git a/src/Settings.h b/src/Settings.h index 10b6850..c75ded3 100644 --- a/src/Settings.h +++ b/src/Settings.h @@ -1,3 +1,5 @@ +// NetworkSettings struct is append-only. The file is persisted across updates, +// and removing/modifying existing keys will lead to file corruption. struct NetworkSettings { char hostname[25] = DEFAULT_HOSTNAME; bool useDhcp = true; @@ -20,6 +22,22 @@ struct NetworkSettings { char password[65] = DEFAULT_STA_PASSWORD; uint8_t channel = 0; } sta; + + struct { + bool enabled = DEFAULT_ETHERNET_ENABLED; + + // nothing reads this yet: the W5500 is the only implementation the firmware can drive + EthernetImplementation implementation = EthernetImplementation::W5500; + + struct { + uint8_t clk = DEFAULT_ETH_CLK_GPIO; + uint8_t mosi = DEFAULT_ETH_MOSI_GPIO; + uint8_t miso = DEFAULT_ETH_MISO_GPIO; + uint8_t cs = DEFAULT_ETH_CS_GPIO; + uint8_t irq = DEFAULT_ETH_IRQ_GPIO; + uint8_t rst = DEFAULT_ETH_RST_GPIO; + } w5500; + } ethernet; } networkSettings; struct Settings { @@ -132,7 +150,7 @@ struct Settings { uint8_t minTemp = DEFAULT_DHW_MIN_TEMP; uint8_t maxTemp = DEFAULT_DHW_MAX_TEMP; uint8_t maxModulation = 100; - + struct { uint8_t highTemp = 95; uint8_t lowTemp = 90; @@ -272,6 +290,7 @@ struct Variables { struct { bool connected = false; int8_t rssi = 0; + NetworkUtils::NetworkMgr::Uplink connectionType = NetworkUtils::NetworkMgr::Uplink::None; } network; struct { @@ -440,4 +459,4 @@ struct Variables { bool restarting = false; bool upgrading = false; } states; -} vars; \ No newline at end of file +} vars; diff --git a/src/defines.h b/src/defines.h index 89443da..5654cdb 100644 --- a/src/defines.h +++ b/src/defines.h @@ -54,6 +54,11 @@ #define USE_BLE false #endif +// ethernet (W5500 over SPI) is only validated on the esp32_c6 target: off everywhere else +#ifndef USE_ETHERNET + #define USE_ETHERNET false +#endif + #ifndef DEFAULT_HOSTNAME #define DEFAULT_HOSTNAME "" #endif @@ -82,6 +87,34 @@ #define DEFAULT_STATUS_LED_GPIO GPIO_IS_NOT_CONFIGURED #endif +#ifndef DEFAULT_ETHERNET_ENABLED + #define DEFAULT_ETHERNET_ENABLED false +#endif + +#ifndef DEFAULT_ETH_CLK_GPIO + #define DEFAULT_ETH_CLK_GPIO GPIO_IS_NOT_CONFIGURED +#endif + +#ifndef DEFAULT_ETH_MOSI_GPIO + #define DEFAULT_ETH_MOSI_GPIO GPIO_IS_NOT_CONFIGURED +#endif + +#ifndef DEFAULT_ETH_MISO_GPIO + #define DEFAULT_ETH_MISO_GPIO GPIO_IS_NOT_CONFIGURED +#endif + +#ifndef DEFAULT_ETH_CS_GPIO + #define DEFAULT_ETH_CS_GPIO GPIO_IS_NOT_CONFIGURED +#endif + +#ifndef DEFAULT_ETH_IRQ_GPIO + #define DEFAULT_ETH_IRQ_GPIO GPIO_IS_NOT_CONFIGURED +#endif + +#ifndef DEFAULT_ETH_RST_GPIO + #define DEFAULT_ETH_RST_GPIO GPIO_IS_NOT_CONFIGURED +#endif + #ifndef DEFAULT_PORTAL_LOGIN #define DEFAULT_PORTAL_LOGIN "" #endif @@ -147,7 +180,7 @@ #endif #ifndef PROGMEM - #define PROGMEM + #define PROGMEM #endif #ifdef ARDUINO_ARCH_ESP32 @@ -168,4 +201,9 @@ enum class HysteresisAction : uint8_t { SET_ZERO_TARGET = 1 }; -char buffer[255]; \ No newline at end of file +enum class EthernetImplementation : uint8_t { + NONE = 0, + W5500 = 1 +}; + +char buffer[255]; diff --git a/src/main.cpp b/src/main.cpp index b415649..9ffb67d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -74,7 +74,7 @@ void setup() { return tm{sec, min, hour}; }); - + Serial.begin(115200); #if ARDUINO_USB_MODE Serial.setTxBufferSize(512); @@ -84,6 +84,9 @@ void setup() { // // Network settings + // The network file has to survive updates, this ensures it doesn't get wiped when the structure is modified + fsNetworkSettings.addWithoutWipe(true); + switch (fsNetworkSettings.read()) { case FD_FS_ERR: Log.swarningln(FPSTR(L_NETWORK_SETTINGS), F("Filesystem error, load default")); @@ -154,6 +157,18 @@ void setup() { break; } + // ethernet (W5500) is configured from the network settings, after they are loaded + network + ->setEthernet(networkSettings.ethernet.enabled) + ->setEthernetPins( + networkSettings.ethernet.w5500.clk, + networkSettings.ethernet.w5500.mosi, + networkSettings.ethernet.w5500.miso, + networkSettings.ethernet.w5500.cs, + networkSettings.ethernet.w5500.irq, + networkSettings.ethernet.w5500.rst + ); + // generate mqtt prefix if it is empty if (!strlen(settings.mqtt.prefix)) { strcpy(settings.mqtt.prefix, getChipId("otgateway_").c_str()); diff --git a/src/strings.h b/src/strings.h index 4019263..b858e49 100644 --- a/src/strings.h +++ b/src/strings.h @@ -48,6 +48,7 @@ const char S_AUTO_FAULT_RESET[] PROGMEM = "autoFaultReset"; const char S_BACKTRACE[] PROGMEM = "backtrace"; const char S_BATTERY[] PROGMEM = "battery"; const char S_BAUDRATE[] PROGMEM = "baudrate"; +const char S_BLE[] PROGMEM = "ble"; const char S_BLOCKING[] PROGMEM = "blocking"; const char S_BSSID[] PROGMEM = "bssid"; const char S_BUILD[] PROGMEM = "build"; @@ -55,14 +56,18 @@ const char S_CASCADE_CONTROL[] PROGMEM = "cascadeControl"; const char S_CHANNEL[] PROGMEM = "channel"; const char S_CH2_ALWAYS_ENABLED[] PROGMEM = "ch2AlwaysEnabled"; const char S_CHIP[] PROGMEM = "chip"; +const char S_CLK[] PROGMEM = "clk"; const char S_CODE[] PROGMEM = "code"; const char S_CONNECTED[] PROGMEM = "connected"; +const char S_CONFIGURED[] PROGMEM = "configured"; +const char S_CONNECTION_TYPE[] PROGMEM = "connectionType"; const char S_CONTINUES[] PROGMEM = "continues"; const char S_COOLING[] PROGMEM = "cooling"; const char S_COOLING_SUPPORT[] PROGMEM = "coolingSupport"; const char S_CORE[] PROGMEM = "core"; const char S_CORES[] PROGMEM = "cores"; const char S_CRASH[] PROGMEM = "crash"; +const char S_CS[] PROGMEM = "cs"; const char S_CURRENT_TEMP[] PROGMEM = "currentTemp"; const char S_DATA[] PROGMEM = "data"; const char S_DATE[] PROGMEM = "date"; @@ -82,10 +87,12 @@ const char S_ENABLED[] PROGMEM = "enabled"; const char S_ENV[] PROGMEM = "env"; const char S_EPC[] PROGMEM = "epc"; const char S_EQUITHERM[] PROGMEM = "equitherm"; +const char S_ETHERNET[] PROGMEM = "ethernet"; const char S_EXPONENT[] PROGMEM = "exponent"; const char S_EXTERNAL_PUMP[] PROGMEM = "externalPump"; const char S_FACTOR[] PROGMEM = "factor"; const char S_FAULT[] PROGMEM = "fault"; +const char S_FEATURES[] PROGMEM = "features"; const char S_FREEZE_PROTECTION[] PROGMEM = "freezeProtection"; const char S_FREEZING[] PROGMEM = "freezing"; const char S_FILTERING[] PROGMEM = "filtering"; @@ -119,6 +126,7 @@ const char S_INPUT[] PROGMEM = "input"; const char S_INTERVAL[] PROGMEM = "interval"; const char S_INVERT_STATE[] PROGMEM = "invertState"; const char S_IP[] PROGMEM = "ip"; +const char S_IRQ[] PROGMEM = "irq"; const char S_I_FACTOR[] PROGMEM = "i_factor"; const char S_I_MULTIPLIER[] PROGMEM = "i_multiplier"; const char S_LOGIN[] PROGMEM = "login"; @@ -139,7 +147,9 @@ const char S_MIN_FREE[] PROGMEM = "minFree"; const char S_MIN_MAX_FREE_BLOCK[] PROGMEM = "minMaxFreeBlock"; const char S_MIN_POWER[] PROGMEM = "minPower"; const char S_MIN_TEMP[] PROGMEM = "minTemp"; +const char S_MISO[] PROGMEM = "miso"; const char S_MODEL[] PROGMEM = "model"; +const char S_MOSI[] PROGMEM = "mosi"; const char S_MODULATION[] PROGMEM = "modulation"; const char S_MQTT[] PROGMEM = "mqtt"; const char S_NAME[] PROGMEM = "name"; @@ -160,6 +170,7 @@ const char S_OVERHEAT_PROTECTION[] PROGMEM = "overheatProtectio const char S_PASSWORD[] PROGMEM = "password"; const char S_PID[] PROGMEM = "pid"; const char S_PORT[] PROGMEM = "port"; +const char S_PINS[] PROGMEM = "pins"; const char S_PORTAL[] PROGMEM = "portal"; const char S_POST_CIRCULATION_TIME[] PROGMEM = "postCirculationTime"; const char S_POWER[] PROGMEM = "power"; @@ -177,6 +188,7 @@ const char S_RESTART[] PROGMEM = "restart"; const char S_RETURN_TEMP[] PROGMEM = "returnTemp"; const char S_REV[] PROGMEM = "rev"; const char S_RSSI[] PROGMEM = "rssi"; +const char S_RST[] PROGMEM = "rst"; const char S_RX_LED_GPIO[] PROGMEM = "rxLedGpio"; const char S_SDK[] PROGMEM = "sdk"; const char S_SENSORS[] PROGMEM = "sensors"; @@ -190,6 +202,7 @@ const char S_SIZE[] PROGMEM = "size"; const char S_SLAVE[] PROGMEM = "slave"; const char S_SLOPE[] PROGMEM = "slope"; const char S_SSID[] PROGMEM = "ssid"; +const char S_STARTED[] PROGMEM = "started"; const char S_STA[] PROGMEM = "sta"; const char S_STATE[] PROGMEM = "state"; const char S_STATIC_CONFIG[] PROGMEM = "staticConfig"; diff --git a/src/utils.h b/src/utils.h index 9253d3b..4df88cc 100644 --- a/src/utils.h +++ b/src/utils.h @@ -296,8 +296,24 @@ void networkSettingsToJson(const NetworkSettings& src, JsonVariant dst) { dst[FPSTR(S_STA)][FPSTR(S_SSID)] = src.sta.ssid; dst[FPSTR(S_STA)][FPSTR(S_PASSWORD)] = src.sta.password; dst[FPSTR(S_STA)][FPSTR(S_CHANNEL)] = src.sta.channel; + + #if USE_ETHERNET + auto ethernet = dst[FPSTR(S_ETHERNET)].to(); + + ethernet[FPSTR(S_ENABLED)] = src.ethernet.enabled; + + auto pins = ethernet[FPSTR(S_PINS)].to(); + pins[FPSTR(S_CLK)] = src.ethernet.w5500.clk; + pins[FPSTR(S_MOSI)] = src.ethernet.w5500.mosi; + pins[FPSTR(S_MISO)] = src.ethernet.w5500.miso; + pins[FPSTR(S_CS)] = src.ethernet.w5500.cs; + pins[FPSTR(S_IRQ)] = src.ethernet.w5500.irq; + pins[FPSTR(S_RST)] = src.ethernet.w5500.rst; + #endif } +bool jsonToEthernetPins(const JsonVariantConst src, NetworkSettings& dst, bool& changed); + bool jsonToNetworkSettings(const JsonVariantConst src, NetworkSettings& dst) { bool changed = false; @@ -413,6 +429,19 @@ bool jsonToNetworkSettings(const JsonVariantConst src, NetworkSettings& dst) { } } + + // ethernet + if (src[FPSTR(S_ETHERNET)][FPSTR(S_ENABLED)].is()) { + bool value = src[FPSTR(S_ETHERNET)][FPSTR(S_ENABLED)].as(); + + if (value != dst.ethernet.enabled) { + dst.ethernet.enabled = value; + changed = true; + } + } + + jsonToEthernetPins(src[FPSTR(S_ETHERNET)][FPSTR(S_PINS)], dst, changed); + return changed; } @@ -573,6 +602,55 @@ inline void safeSettingsToJson(const Settings& src, JsonVariant dst) { settingsToJson(src, dst, true); } +/** + * @brief Parse a single GPIO value: an empty string means "not configured" + * + * @return true if the key existed in the source document + */ +bool jsonToGpioValue(const JsonVariantConst src, const __FlashStringHelper* key, uint8_t& dst, bool& changed) { + if (src[key].isNull()) { + return false; + } + + if (src[key].is() && src[key].as().size() == 0) { + if (dst != GPIO_IS_NOT_CONFIGURED) { + dst = GPIO_IS_NOT_CONFIGURED; + changed = true; + } + + return true; + } + + unsigned char value = src[key].as(); + + if (GPIO_IS_VALID(value) && value != dst) { + dst = value; + changed = true; + } + + return true; +} + +/** + * @brief Parse the W5500 pinout from an object holding clk/mosi/miso/cs/irq/rst + * + * @return true if the source object exists + */ +bool jsonToEthernetPins(const JsonVariantConst src, NetworkSettings& dst, bool& changed) { + if (src.isNull()) { + return false; + } + + jsonToGpioValue(src, FPSTR(S_CLK), dst.ethernet.w5500.clk, changed); + jsonToGpioValue(src, FPSTR(S_MOSI), dst.ethernet.w5500.mosi, changed); + jsonToGpioValue(src, FPSTR(S_MISO), dst.ethernet.w5500.miso, changed); + jsonToGpioValue(src, FPSTR(S_CS), dst.ethernet.w5500.cs, changed); + jsonToGpioValue(src, FPSTR(S_IRQ), dst.ethernet.w5500.irq, changed); + jsonToGpioValue(src, FPSTR(S_RST), dst.ethernet.w5500.rst, changed); + + return true; +} + bool jsonToSettings(const JsonVariantConst src, Settings& dst, bool safe = false) { bool changed = false; @@ -697,7 +775,6 @@ bool jsonToSettings(const JsonVariantConst src, Settings& dst, bool safe = false } } - // portal if (src[FPSTR(S_PORTAL)][FPSTR(S_AUTH)].is()) { bool value = src[FPSTR(S_PORTAL)][FPSTR(S_AUTH)].as(); @@ -2194,6 +2271,7 @@ void varsToJson(const Variables& src, JsonVariant dst) { master[FPSTR(S_NETWORK)][FPSTR(S_CONNECTED)] = src.network.connected; master[FPSTR(S_NETWORK)][FPSTR(S_RSSI)] = src.network.rssi; + master[FPSTR(S_NETWORK)][FPSTR(S_CONNECTION_TYPE)] = NetworkUtils::NetworkMgr::uplinkName(src.network.connectionType); master[FPSTR(S_MQTT)][FPSTR(S_CONNECTED)] = src.mqtt.connected; master[FPSTR(S_EMERGENCY)][FPSTR(S_STATE)] = src.emergency.state; master[FPSTR(S_EXTERNAL_PUMP)][FPSTR(S_STATE)] = src.externalPump.state; diff --git a/src_data/locales/cn.json b/src_data/locales/cn.json index d8e6145..961d724 100644 --- a/src_data/locales/cn.json +++ b/src_data/locales/cn.json @@ -151,6 +151,7 @@ "section": { "static": "静态设置", + "ethernet": "以太网", "availableNetworks": "可用网络", "staSettings": "WiFi 设置", "apSettings": "AP 设置" @@ -158,7 +159,20 @@ "scan": { "pos": "#", - "info": "Info" + "info": "Info", + "unavailable": "请启用 WiFi 以扫描网络" + }, + + "ethernet": { + "enable": "启用以太网", + "notConfigured": "填写全部六个 GPIO 引脚后才能启用以太网。", + "clk": "SPI 时钟 GPIO", + "mosi": "SPI MOSI GPIO", + "miso": "SPI MISO GPIO", + "cs": "SPI CS GPIO", + "irq": "中断 GPIO", + "rst": "复位 GPIO", + "note": "W5500 SPI 接线" }, "wifi": { @@ -176,7 +190,14 @@ "ip": "IP", "subnet": "子网掩码", "gateway": "网关", - "dns": "DNS 服务器" + "dns": "DNS 服务器", + "connection": "连接", + "connectionType": { + "ethernet": "以太网", + "wifi": "WiFi", + "wifi-ap": "WiFi 接入点", + "none": "无" + } }, "sta": { diff --git a/src_data/locales/en.json b/src_data/locales/en.json index 8b41923..905552e 100644 --- a/src_data/locales/en.json +++ b/src_data/locales/en.json @@ -152,6 +152,7 @@ "section": { "static": "Static settings", + "ethernet": "Ethernet", "availableNetworks": "Available networks", "staSettings": "WiFi settings", "apSettings": "AP settings" @@ -159,7 +160,20 @@ "scan": { "pos": "#", - "info": "Info" + "info": "Info", + "unavailable": "Please enable WiFi to scan for networks" + }, + + "ethernet": { + "enable": "Enable ethernet", + "notConfigured": "Fill in all six GPIO pins to enable ethernet.", + "clk": "SPI clock GPIO", + "mosi": "SPI MOSI GPIO", + "miso": "SPI MISO GPIO", + "cs": "SPI CS GPIO", + "irq": "Interrupt GPIO", + "rst": "Reset GPIO", + "note": "W5500 SPI wiring" }, "wifi": { @@ -177,7 +191,14 @@ "ip": "IP", "subnet": "Subnet", "gateway": "Gateway", - "dns": "DNS" + "dns": "DNS", + "connection": "Connection", + "connectionType": { + "ethernet": "Ethernet", + "wifi": "WiFi", + "wifi-ap": "WiFi AP", + "none": "None" + } }, "sta": { diff --git a/src_data/locales/it.json b/src_data/locales/it.json index 4bf8c4a..8515c24 100644 --- a/src_data/locales/it.json +++ b/src_data/locales/it.json @@ -151,6 +151,7 @@ "section": { "static": "Impostazioni statico", + "ethernet": "Ethernet", "availableNetworks": "Reti disponibili", "staSettings": "Impostazioni WiFi", "apSettings": "Impostazioni AP" @@ -158,7 +159,20 @@ "scan": { "pos": "#", - "info": "Info" + "info": "Info", + "unavailable": "Abilita il WiFi per cercare le reti" + }, + + "ethernet": { + "enable": "Abilita ethernet", + "notConfigured": "Compilare tutti e sei i pin GPIO per abilitare ethernet.", + "clk": "GPIO clock SPI", + "mosi": "GPIO MOSI SPI", + "miso": "GPIO MISO SPI", + "cs": "GPIO CS SPI", + "irq": "GPIO interrupt", + "rst": "GPIO reset", + "note": "Cablaggio SPI del W5500" }, "wifi": { @@ -176,7 +190,14 @@ "ip": "IP", "subnet": "Subnet", "gateway": "Gateway", - "dns": "DNS" + "dns": "DNS", + "connection": "Connessione", + "connectionType": { + "ethernet": "Ethernet", + "wifi": "WiFi", + "wifi-ap": "WiFi AP", + "none": "Nessuno" + } }, "sta": { diff --git a/src_data/locales/nl.json b/src_data/locales/nl.json index 85413ec..7a3e8ba 100644 --- a/src_data/locales/nl.json +++ b/src_data/locales/nl.json @@ -136,13 +136,27 @@ "name": "Netwerkinstellingen", "section": { "static": "Statische instellingen", + "ethernet": "Ethernet", "availableNetworks": "Beschikbare netwerken", "staSettings": "WiFi-instellingen", "apSettings": "AP-instellingen" }, "scan": { "pos": "#", - "info": "Info" + "info": "Info", + "unavailable": "Schakel WiFi in om naar netwerken te zoeken" + }, + + "ethernet": { + "enable": "Ethernet inschakelen", + "notConfigured": "Vul alle zes GPIO-pinnen in om ethernet in te schakelen.", + "clk": "SPI-klok GPIO", + "mosi": "SPI MOSI GPIO", + "miso": "SPI MISO GPIO", + "cs": "SPI CS GPIO", + "irq": "Interrupt GPIO", + "rst": "Reset GPIO", + "note": "W5500 SPI-bedrading" }, "wifi": { "ssid": "SSID", @@ -158,7 +172,14 @@ "ip": "IP", "subnet": "Subnet", "gateway": "Gateway", - "dns": "DNS" + "dns": "DNS", + "connection": "Verbinding", + "connectionType": { + "ethernet": "Ethernet", + "wifi": "WiFi", + "wifi-ap": "WiFi AP", + "none": "Geen" + } }, "sta": { "channel.note": "zet op 0 voor automatische selectie" diff --git a/src_data/locales/ru.json b/src_data/locales/ru.json index 3a5326f..90b6695 100644 --- a/src_data/locales/ru.json +++ b/src_data/locales/ru.json @@ -151,6 +151,7 @@ "section": { "static": "Статические параметры", + "ethernet": "Ethernet", "availableNetworks": "Доступные сети", "staSettings": "Настройки подключения", "apSettings": "Настройки точки доступа" @@ -158,7 +159,20 @@ "scan": { "pos": "#", - "info": "Инфо" + "info": "Инфо", + "unavailable": "Включите WiFi, чтобы искать сети" + }, + + "ethernet": { + "enable": "Включить ethernet", + "notConfigured": "Заполните все шесть выводов GPIO, чтобы включить ethernet.", + "clk": "GPIO тактирования SPI", + "mosi": "GPIO MOSI SPI", + "miso": "GPIO MISO SPI", + "cs": "GPIO CS SPI", + "irq": "GPIO прерывания", + "rst": "GPIO сброса", + "note": "Подключение W5500 по SPI" }, "wifi": { @@ -176,7 +190,14 @@ "ip": "IP адрес", "subnet": "Адрес подсети", "gateway": "Адрес шлюза", - "dns": "DNS адрес" + "dns": "DNS адрес", + "connection": "Подключение", + "connectionType": { + "ethernet": "Ethernet", + "wifi": "WiFi", + "wifi-ap": "WiFi AP", + "none": "Нет" + } }, "sta": { diff --git a/src_data/pages/index.html b/src_data/pages/index.html index 247d4bf..b9cefaf 100644 --- a/src_data/pages/index.html +++ b/src_data/pages/index.html @@ -50,15 +50,19 @@ network.params.mac + + network.params.connection + + network.wifi.connected - + network.wifi.ssid - + network.wifi.signal % @@ -192,6 +196,7 @@ setValue('#network-hostname', result.network.hostname); setValue('#network-mac', result.network.mac); + setValue('#network-connection', i18n(`network.params.connectionType.${result.network.connectionType}`)); setStatus( '#network-connected', result.network.connected ? "success" : "error", @@ -199,6 +204,14 @@ ); setValue('#network-ssid', result.network.ssid); setValue('#network-signal', result.network.signalQuality); + + // SSID and signal strength only describe the uplink when WiFi carries it; with + // ethernet up the radio is parked and both values are meaningless + const wifiUplink = result.network.connectionType === "wifi"; + + document.getElementById("network-ssid-row")?.classList.toggle("hidden", !wifiUplink); + document.getElementById("network-signal-row")?.classList.toggle("hidden", !wifiUplink); + setValue('#network-ip', result.network.ip); setValue('#network-subnet', result.network.subnet); setValue('#network-gateway', result.network.gateway); diff --git a/src_data/pages/network.html b/src_data/pages/network.html index 526da15..a2b80f5 100644 --- a/src_data/pages/network.html +++ b/src_data/pages/network.html @@ -79,6 +79,7 @@ +
@@ -100,6 +101,7 @@
+ @@ -162,6 +164,62 @@
+ +