diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000000..bcb9fe81890 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,179 @@ +# Allow `wifi:` and `ethernet:` to coexist with boot-time link detection + +## Context + +A user wants a single YAML config that: +- At boot, checks whether the Ethernet cable is plugged in. +- If the cable is plugged in, brings up Ethernet only. +- If the cable is unplugged, brings up WiFi (with `improv_serial` / `esp32_improv` provisioning fallback). +- Restarts the device whenever the cable plug state changes. + +This is impossible on `dev` today. Two specific things in the codebase prevent it: + +1. `esphome/components/ethernet/__init__.py:52` declares `CONFLICTS_WITH = ["wifi"]`, so config validation rejects any YAML that has both. +2. `esphome/components/ethernet/__init__.py:581-583` unconditionally turns off the ESP-IDF WiFi stack (`CONFIG_ESP_WIFI_ENABLED = False`, `CONFIG_SW_COEXIST_ENABLE = False`) when ethernet is configured, so even if the Python conflict were removed, the WiFi driver would not be linked into the firmware. + +ESP-IDF itself supports running WiFi and Ethernet simultaneously — they share `esp_netif` / lwIP and routing is decided per netif. ESPHome opted out of dual-stack early to save flash/RAM, but the user's use case (one or the other selected at boot) is reasonable and worth supporting. + +The plan removes those two blocks and adds the minimum scaffolding needed to make a "select interface at boot based on PHY link" YAML work. + +## Approach + +Five focused changes in ESPHome, then the user's YAML works directly. + +### 1. Drop the `CONFLICTS_WITH` and make WiFi sdkconfig conditional + +File: `esphome/components/ethernet/__init__.py` + +- Line 52: remove `CONFLICTS_WITH = ["wifi"]`. +- Lines 580-583: only force `CONFIG_ESP_WIFI_ENABLED=False` / `CONFIG_SW_COEXIST_ENABLE=False` when the wifi component is **not** in the config. Use `CORE.config` (or a `final_validate` hook) to check. + ```python + if "wifi" not in CORE.config: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) + add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + else: + add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", True) + ``` + When both are enabled, `CONFIG_SW_COEXIST_ENABLE` is harmless (it gates BT/WiFi sharing) but does cost a few KB; keep an eye on flash budgets and add a warning in `dump_config` if both coexist. + +### 2. Add `enable_on_boot` to the ethernet schema + +File: `esphome/components/ethernet/__init__.py` and `ethernet_component.h` / `.cpp` + +Mirror the existing wifi pattern: +- Schema (`__init__.py` around the rest of `CONFIG_SCHEMA`): `cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean`. +- C++ (`ethernet_component.h:117+`): add `bool enable_on_boot_{true};` field and `set_enable_on_boot(bool)` setter — see the wifi equivalent at `wifi_component.h:536` and `wifi_component.h:891`. +- `setup()` in `ethernet_component_esp32.cpp` should still install the driver (so the PHY can be probed) but should leave `started_ = false` if `enable_on_boot_` is false. The state machine at `ethernet_component_esp32.cpp:81-131` already gates everything off `started_`, so this is a small change. + +### 3. Add `ethernet.enable` / `ethernet.disable` actions and `ethernet.connected` / `ethernet.has_link` conditions + +New file: `esphome/components/ethernet/automation.h` (model after `esphome/components/wifi/automation.h:26-78`). + +- `EthernetEnableAction`: sets `started_ = true`, kicks the loop. +- `EthernetDisableAction`: calls existing `powerdown()` (`ethernet_component.h:144`, already implemented at `ethernet_component_esp32.cpp:812-819`) and sets `started_ = false`. +- `EthernetConnectedCondition`: returns `is_connected()` (already exists at `ethernet_component.h:125`). +- `EthernetHasLinkCondition`: returns the PHY link bit. ESP-IDF exposes this via `esp_eth_ioctl(eth_handle_, ETH_CMD_G_PHY_ADDR, ...)` plus a PHY register read of `MII_BMSR` bit 2; the existing code already does PHY reads (`ethernet_component_esp32.cpp:837,857` for KSZ80XX). Wrap that into a `bool has_link()` method on `EthernetComponent` and have the condition call it. + +Register them in `__init__.py` next to the existing trigger registration: +```python +@automation.register_action("ethernet.enable", EthernetEnableAction, ...) +@automation.register_action("ethernet.disable", EthernetDisableAction, ...) +@automation.register_condition("ethernet.connected", EthernetConnectedCondition, ...) +@automation.register_condition("ethernet.has_link", EthernetHasLinkCondition, ...) +``` + +### 4. Make the ethernet driver install (PHY probe) cheap and fast + +File: `esphome/components/ethernet/ethernet_component_esp32.cpp` + +`setup()` already does `esp_eth_driver_install` regardless of state. The new `has_link()` method must work right after `setup()` finishes, before `esp_eth_start()`. ESP-IDF reads PHY registers as soon as the driver is installed and `phy->reset()` runs, so this is already true — just expose it. No new init flow needed; the only requirement is that the user's `on_boot` priority runs **after** ethernet `setup_priority`. Ethernet's `get_setup_priority()` returns `setup_priority::WIFI` (verify in `ethernet_component_esp32.cpp`); user's `on_boot` should use `priority: 250` (after WiFi/Ethernet setup, before connection). + +### 5. Document the expected boot sequence + +Update `esphome/components/ethernet/__init__.py` docstring / `dump_config()` to spell out: +- When both `wifi:` and `ethernet:` are present, the user owns the choice via `enable_on_boot: false` + an `on_boot` automation. ESPHome will not auto-select. +- Flash overhead is roughly +N KB (measure during PR; document in PR description per CLAUDE.md breaking-change guidance). + +## Resulting YAML config + +Once the changes above land, this YAML works: + +```yaml +esphome: + name: dual-net + on_boot: + # Run after both ethernet and wifi setup() but before either is told to connect. + priority: 250 + then: + - if: + condition: + ethernet.has_link: + then: + - logger.log: "Ethernet cable detected, using Ethernet" + - ethernet.enable: + else: + - logger.log: "No Ethernet link, using WiFi" + - wifi.enable: + +esp32: + board: esp32dev + framework: + type: esp-idf + +ethernet: + type: LAN8720 + mdc_pin: GPIO23 + mdio_pin: GPIO18 + clk_mode: GPIO0_IN + phy_addr: 0 + enable_on_boot: false # NEW — added in change #2 + on_disconnect: + then: + - logger.log: "Ethernet disconnected, restarting" + - delay: 2s + - button.press: restart_btn + +wifi: + ssid: !secret wifi_ssid + password: !secret wifi_password + enable_on_boot: false # already exists today + ap: # AP fallback for captive_portal / improv + ssid: "Dual-Net Fallback" + +captive_portal: +improv_serial: + +# esp32_improv is optional — only if you have a button and want BLE provisioning +# esp32_improv: +# authorizer: !push_button gpio0 + +button: + - platform: restart + id: restart_btn + name: "Restart" + +# Detect cable insertion when running on WiFi: poll PHY link, restart if it changes. +interval: + - interval: 5s + then: + - if: + condition: + and: + - not: ethernet.connected + - ethernet.has_link + then: + - logger.log: "Ethernet cable plugged in, restarting" + - button.press: restart_btn +``` + +Notes on the YAML: +- `ethernet.has_link` is the new cheap PHY-only check (change #3); it works even when ethernet is disabled because the driver was installed in `setup()`. +- `ethernet.enable` / `wifi.enable` are mutually exclusive in this YAML — only one path runs at boot. +- The `interval` block handles "cable plugged in while running on WiFi" → restart so ethernet takes over on the next boot. The existing `on_disconnect` trigger handles the inverse (cable unplugged while on ethernet → restart). +- `improv_serial` works as long as wifi is enabled (it depends on wifi per `improv_serial/__init__.py:10`). + +## Critical files to modify + +- `esphome/components/ethernet/__init__.py` — drop `CONFLICTS_WITH` (line 52), make sdkconfig conditional (lines 580-583), add `enable_on_boot` schema option, register new actions/conditions. +- `esphome/components/ethernet/ethernet_component.h` — add `enable_on_boot_` field, `set_enable_on_boot`, `has_link()` method. +- `esphome/components/ethernet/ethernet_component_esp32.cpp` — implement `has_link()` via existing PHY read path (model after KSZ80XX read at lines 837/857), gate `started_` initialization on `enable_on_boot_`. +- `esphome/components/ethernet/ethernet_component_rp2040.cpp` — implement `has_link()` using the existing `linkStatus()` Arduino API (already used at line 117 in that file). +- `esphome/components/ethernet/automation.h` — new file, model after `esphome/components/wifi/automation.h`. +- `tests/components/ethernet/` — add `common.yaml` test that combines `wifi:` + `ethernet:` and uses the new actions/conditions. + +## Reused existing infrastructure + +- WiFi already has `enable_on_boot` (`wifi_component.h:536, 891`), `wifi.enable` / `wifi.disable` actions (`wifi/automation.h:26, 31`), and `wifi.connected` / `wifi.enabled` / `wifi.ap_active` conditions. +- Ethernet already has `is_connected()` (`ethernet_component.h:125`), `powerdown()` (`ethernet_component.h:144`), `on_connect` / `on_disconnect` triggers (`ethernet_component.h:185-190`). +- `improv_serial` (`esphome/components/improv_serial/__init__.py`) and `captive_portal` (`esphome/components/captive_portal/__init__.py`) need no changes; they just need wifi to be enabled at boot. +- Restart action: `button.press` on a `platform: restart` button (used in the YAML above) — no need for a new action; this matches existing ESPHome patterns. + +## Verification + +1. Config validation: `esphome config dual-net.yaml` should pass (today it errors with `Component conflicts with wifi`). +2. Compile both paths: `esphome compile` with cable connected and disconnected — verify only one stack negotiates an IP. +3. Hotplug: boot with cable, then unplug → device restarts within ~5s, comes up on WiFi. Boot without cable, then plug in → device restarts within ~5s, comes up on Ethernet. +4. Improv fallback: boot without cable and with bad WiFi credentials → `improv_serial` should accept new credentials over USB. +5. Run `pytest tests/component_tests/ethernet` and the YAML compile-test under `tests/components/ethernet/test.esp32-idf.yaml` (add a new `dual_net` variant). +6. Flash size sanity check: compare `.bin` size against `dev` for an ethernet-only config to confirm the conditional sdkconfig change doesn't regress single-stack builds. +7. Memory check at runtime: confirm with `top` / `esp_get_free_heap_size()` that boot-time selection actually leaves the unused stack idle (WiFi shouldn't allocate buffers if `wifi.enable` is never called). diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 10f9a73863d..a89d96ebd39 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -13,6 +13,7 @@ from esphome.const import ( CONF_DNS1, CONF_DNS2, CONF_DOMAIN, + CONF_ENABLE_ON_BOOT, CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, @@ -49,7 +50,6 @@ from esphome.core import ( import esphome.final_validate as fv from esphome.types import ConfigType -CONFLICTS_WITH = ["wifi"] AUTO_LOAD = ["network"] LOGGER = logging.getLogger(__name__) @@ -219,6 +219,15 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") +EthernetConnectedCondition = ethernet_ns.class_( + "EthernetConnectedCondition", automation.Condition +) +EthernetHasLinkCondition = ethernet_ns.class_( + "EthernetHasLinkCondition", automation.Condition +) +EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) +EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) + def _is_framework_spi_polling_mode_supported() -> bool: """Check if ESP-IDF framework supports SPI polling mode (ESP32 only). @@ -349,6 +358,7 @@ BASE_SCHEMA = cv.Schema( cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, cv.Optional(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean, cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True), cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True), } @@ -508,6 +518,10 @@ async def to_code(config): if mac_address := config.get(CONF_MAC_ADDRESS): cg.add(var.set_fixed_mac(mac_address.parts)) + # enable_on_boot defaults to true in C++ - only set if false + if not config[CONF_ENABLE_ON_BOOT]: + cg.add(var.set_enable_on_boot(False)) + cg.add_define("USE_ETHERNET") if on_connect_config := config.get(CONF_ON_CONNECT): @@ -577,10 +591,12 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None: ) cg.add(var.add_phy_register(reg)) - # Disable WiFi when using Ethernet to save memory - add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) - # Also disable WiFi/BT coexistence since WiFi is disabled - add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) + # When wifi is not also configured, disable the WiFi stack to save flash/RAM. + # When both are configured the user wants dual-stack (e.g. boot-time link + # selection), so leave WiFi enabled. + if "wifi" not in CORE.config: + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False) + add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False) # Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time) include_builtin_idf_component("esp_eth") @@ -716,3 +732,31 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files + + +@automation.register_condition( + "ethernet.connected", EthernetConnectedCondition, cv.Schema({}) +) +async def ethernet_connected_to_code(config, condition_id, template_arg, args): + return cg.new_Pvariable(condition_id, template_arg) + + +@automation.register_condition( + "ethernet.has_link", EthernetHasLinkCondition, cv.Schema({}) +) +async def ethernet_has_link_to_code(config, condition_id, template_arg, args): + return cg.new_Pvariable(condition_id, template_arg) + + +@automation.register_action( + "ethernet.enable", EthernetEnableAction, cv.Schema({}), synchronous=True +) +async def ethernet_enable_to_code(config, action_id, template_arg, args): + return cg.new_Pvariable(action_id, template_arg) + + +@automation.register_action( + "ethernet.disable", EthernetDisableAction, cv.Schema({}), synchronous=True +) +async def ethernet_disable_to_code(config, action_id, template_arg, args): + return cg.new_Pvariable(action_id, template_arg) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h new file mode 100644 index 00000000000..24efd78406e --- /dev/null +++ b/esphome/components/ethernet/automation.h @@ -0,0 +1,31 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ETHERNET +#include "esphome/core/automation.h" +#include "ethernet_component.h" + +namespace esphome::ethernet { + +template class EthernetConnectedCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_connected(); } +}; + +template class EthernetHasLinkCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->has_link(); } +}; + +template class EthernetEnableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->enable(); } +}; + +template class EthernetDisableAction : public Action { + public: + void play(const Ts &...x) override { global_eth_component->disable(); } +}; + +} // namespace esphome::ethernet +#endif diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 17c84ee9543..6c48a56f8ab 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -124,6 +124,18 @@ class EthernetComponent final : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + // Returns true when the PHY reports the cable is plugged in and link is up. + // Safe to call once setup() has completed - does not require the connection + // state machine to have run. + bool has_link(); + + // Start the ethernet connection state machine. No-op if already started. + void enable(); + // Stop the ethernet connection state machine and power down the interface. + void disable(); + + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); @@ -284,6 +296,7 @@ class EthernetComponent final : public Component { // Group all uint8_t types together (enums and bools) EthernetType type_{ETHERNET_TYPE_UNKNOWN}; EthernetComponentState state_{EthernetComponentState::STOPPED}; + bool enable_on_boot_{true}; bool started_{false}; bool connected_{false}; bool got_ipv4_address_{false}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d4585bf100b..747f338f091 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -370,9 +370,41 @@ void EthernetComponent::setup() { ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error"); #endif /* USE_NETWORK_IPV6 */ - /* start Ethernet driver state machine */ - err = esp_eth_start(this->eth_handle_); - ESPHL_ERROR_CHECK(err, "ETH start error"); + if (this->enable_on_boot_) { + /* start Ethernet driver state machine */ + err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); + } else { + ESP_LOGCONFIG(TAG, "Ethernet not started (enable_on_boot is false)"); + } +} + +void EthernetComponent::enable() { + if (this->started_ || this->eth_handle_ == nullptr) + return; + esp_err_t err = esp_eth_start(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ETH start error: (%d) %s", err, esp_err_to_name(err)); + return; + } + this->enable_loop_soon_any_context(); +} + +void EthernetComponent::disable() { + if (!this->started_ || this->eth_handle_ == nullptr) + return; + esp_err_t err = esp_eth_stop(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ETH stop error: (%d) %s", err, esp_err_to_name(err)); + return; + } + this->enable_loop_soon_any_context(); +} + +bool EthernetComponent::has_link() { + // connected_ is set by ETHERNET_EVENT_CONNECTED and cleared by ETHERNET_EVENT_DISCONNECTED. + // It reflects the PHY link state once the driver has been started. + return this->connected_; } void EthernetComponent::dump_config() { diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index ef7bd463328..54263491fb9 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -95,6 +95,34 @@ void EthernetComponent::setup() { // when the link is actually up. Setting it prematurely causes // a "Starting → Stopped → Starting" log sequence because the chip // needs time after begin() before the PHY link is ready. + + if (!this->enable_on_boot_) { + ESP_LOGCONFIG(TAG, "Ethernet not started (enable_on_boot is false)"); + } +} + +void EthernetComponent::enable() { + this->enable_on_boot_ = true; + this->enable_loop_soon_any_context(); +} + +void EthernetComponent::disable() { + this->enable_on_boot_ = false; + this->started_ = false; + this->connected_ = false; + this->state_ = EthernetComponentState::STOPPED; + this->enable_loop_soon_any_context(); +} + +bool EthernetComponent::has_link() { + if (this->eth_ == nullptr) + return false; +#if defined(USE_ETHERNET_W5100) + // W5100 cannot detect link state - assume up after successful begin() + return true; +#else + return this->eth_->linkStatus() == LinkON; +#endif } void EthernetComponent::loop() { @@ -107,7 +135,7 @@ void EthernetComponent::loop() { // connected() reads netif->ip_addr without LwIPLock, but this is a single // 32-bit aligned read (atomic on ARM) — worst case is a one-iteration-stale // value, which is benign for polling. - if (this->eth_ != nullptr && now - this->last_link_check_ >= LINK_CHECK_INTERVAL) { + if (this->eth_ != nullptr && this->enable_on_boot_ && now - this->last_link_check_ >= LINK_CHECK_INTERVAL) { this->last_link_check_ = now; #if defined(USE_ETHERNET_W5100) // W5100 can't detect link (isLinkDetectable() returns false), so linkStatus() diff --git a/tests/components/ethernet/common-dual-net.yaml b/tests/components/ethernet/common-dual-net.yaml new file mode 100644 index 00000000000..2da6d107cd5 --- /dev/null +++ b/tests/components/ethernet/common-dual-net.yaml @@ -0,0 +1,70 @@ +esphome: + on_boot: + # Run after both ethernet and wifi setup() but before either has had time + # to connect. Decide which interface to use based on the PHY link state. + priority: 250 + then: + - delay: 3s # give the PHY time to autonegotiate + - if: + condition: + ethernet.has_link: + then: + - logger.log: "Ethernet cable detected, using Ethernet" + - wifi.disable + - ethernet.enable + else: + - logger.log: "No Ethernet link, using WiFi" + - ethernet.disable + - wifi.enable + +ethernet: + type: LAN8720 + mdc_pin: 23 + mdio_pin: 32 + clk: + pin: 0 + mode: CLK_EXT_IN + phy_addr: 0 + power_pin: 33 + enable_on_boot: false + on_disconnect: + then: + - logger.log: "Ethernet disconnected, restarting" + - delay: 2s + - button.press: restart_btn + +wifi: + ssid: MySSID + password: password1 + enable_on_boot: false + ap: + ssid: "Dual-Net Fallback" + +button: + - platform: restart + id: restart_btn + name: "Restart" + +# Detect cable insertion when running on WiFi: poll PHY link, restart if it +# changes. The ethernet driver is installed but stopped; calling has_link +# requires it to be enabled briefly. Restart on a positive transition. +interval: + - interval: 30s + then: + - if: + condition: + and: + - wifi.connected: + - not: + ethernet.connected: + then: + - ethernet.enable + - delay: 3s + - if: + condition: + ethernet.has_link: + then: + - logger.log: "Ethernet cable plugged in, restarting" + - button.press: restart_btn + else: + - ethernet.disable diff --git a/tests/components/ethernet/test-dual-net.esp32-idf.yaml b/tests/components/ethernet/test-dual-net.esp32-idf.yaml new file mode 100644 index 00000000000..e9243169eca --- /dev/null +++ b/tests/components/ethernet/test-dual-net.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common-dual-net.yaml