[ethernet] Allow coexistence with wifi for boot-time interface selection

Removes the unconditional CONFLICTS_WITH = ["wifi"] on the ethernet
component and only disables CONFIG_ESP_WIFI_ENABLED when wifi is not
also configured. This unblocks YAML configs that have both wifi: and
ethernet:, where the user picks one at boot based on cable presence.

Adds:
- enable_on_boot config option on ethernet (mirrors wifi)
- has_link() method that exposes PHY link state for runtime probing
- ethernet.enable / ethernet.disable actions
- ethernet.connected / ethernet.has_link conditions
- A dual-net YAML compile test (tests/components/ethernet/common-dual-net.yaml)

PLAN.md captures the full design and verification steps.

https://claude.ai/code/session_01LyXyqhVQMuab7tdhxRv5sf
This commit is contained in:
Claude
2026-05-02 12:40:27 +00:00
parent 5e9db1c8c6
commit 4927b98cf9
8 changed files with 407 additions and 9 deletions
+179
View File
@@ -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).
+49 -5
View File
@@ -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)
+31
View File
@@ -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<typename... Ts> class EthernetConnectedCondition : public Condition<Ts...> {
public:
bool check(const Ts &...x) override { return global_eth_component->is_connected(); }
};
template<typename... Ts> class EthernetHasLinkCondition : public Condition<Ts...> {
public:
bool check(const Ts &...x) override { return global_eth_component->has_link(); }
};
template<typename... Ts> class EthernetEnableAction : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_eth_component->enable(); }
};
template<typename... Ts> class EthernetDisableAction : public Action<Ts...> {
public:
void play(const Ts &...x) override { global_eth_component->disable(); }
};
} // namespace esphome::ethernet
#endif
@@ -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};
@@ -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() {
@@ -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()
@@ -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
@@ -0,0 +1 @@
<<: !include common-dual-net.yaml