Brings ethernet to feature parity with wifi's per-interface lifecycle, and
applies the same lazy-init pattern as wifi's Unit B+ so enable_on_boot:false
genuinely reclaims DMA-capable internal SRAM.
Unit B — lifecycle API surface (mirrors WiFiComponent):
- New `enable_on_boot: true` (default) YAML option on ethernet.
- New set_enable_on_boot(), enable(), disable(), is_disabled(), is_enabled()
methods on EthernetComponent.
- ESP32 path: enable() calls esp_eth_start(), disable() calls esp_eth_stop().
- RP2040 path: stub methods that log a warning — arduino-pico's
LwipIntfDev doesn't expose a clean start/stop hook; schema parity only.
Unit B+ — lazy-init refactor (mirrors WiFiComponent::wifi_lazy_init_()):
- New ethernet_lazy_init_() method (idempotent, guarded by
ethernet_initialized_ flag) holds the entire heavy init body that used to
live in setup(): SPI bus init, netif creation, MAC/PHY allocation, eth
driver install, netif attach, event handler registration.
- setup() becomes thin: 300ms power-stabilization delay, then if
enable_on_boot_=true call lazy_init + esp_eth_start, else mark
disabled_=true and return.
- enable() calls ethernet_lazy_init_() first, then esp_eth_start() — so a
runtime enable after enable_on_boot:false works end-to-end.
Safe-default getter guards — external callers (sendspin, ethernet_info,
mdns, etc.) may invoke MAC/IP/duplex queries before/regardless of whether
ethernet is enabled. Without guards these call into esp_eth_ioctl(null, ...)
and esp_netif_get_*(null, ...), producing error spam + erroneous
mark_failed() calls during dump_config():
- get_eth_mac_address_raw() falls back to esp_read_mac(ESP_MAC_ETH) — the
hardware MAC, same value the driver would have returned.
- get_duplex_mode() returns ETH_DUPLEX_HALF.
- get_link_speed() returns ETH_SPEED_10M.
- get_ip_addresses() returns empty (zero) addresses.
- dump_connect_params_() early-returns with "(uninitialized)" log line.
For a user's reboot-to-toggle workflow with both interfaces declared but
only one active per boot: the inactive interface costs zero DMA-capable
memory. WiFi-side reclaims ~15-30KB DMA-capable, ethernet-side reclaims
~3-8KB (W5500 SPI driver is gentler than wifi).
Field-tested on ESP32-S3 + W5500. Verifies clean dump_config() output and
no false "ethernet was marked as failed" state when ethernet is dormant.
WiFi already had enable_on_boot + enable()/disable()/is_disabled() lifecycle,
but enable_on_boot:false didn't actually save any memory. wifi_pre_setup_()
called esp_wifi_init() and esp_netif_create_default_wifi_sta() unconditionally
during setup(), which allocates ~15-30KB of DMA-capable internal SRAM (RX/TX
buffers, driver state, PHY init). The flag only skipped esp_wifi_start() in
the followup branch — the driver was already resident, just not associated.
This commit splits wifi_pre_setup_() into two parts:
- wifi_pre_setup_() (light, kept in setup() always): MAC setup, event group
creation, WIFI_EVENT/IP_EVENT handler registration. No DMA allocation.
- wifi_lazy_init_() (heavy, NEW): esp_netif_create_default_wifi_sta()/_ap(),
esp_wifi_init(), esp_wifi_set_storage(). The DMA-allocating calls.
Guarded by wifi_initialized_ flag for idempotency.
setup() calls wifi_lazy_init_() only when enable_on_boot_=true. The else
branch sets WIFI_COMPONENT_STATE_DISABLED without any heavy init — the
dormant interface costs zero DMA-capable memory.
enable() calls wifi_lazy_init_() before start(), so a runtime enable after
boot-time disable does the heavy init on demand. Idempotent — subsequent
enable/disable cycles don't re-allocate.
disable() is unchanged — it stops wifi but doesn't deinit. A future
"release_on_disable" variant could call esp_wifi_deinit() to actually free
the memory at runtime, but that requires coordinating with consumers
holding wifi-bound sockets and is out of scope here.
ESP-IDF only. Other platforms (Arduino on ESP32, ESP8266) keep the existing
behavior — their wifi_pre_setup_() lives in different per-platform files.
Field-tested on ESP32-S3 with W5500 SPI ethernet + audio + bluetooth_proxy.
Before Unit B+: ~14KB free internal during peak load, crash on W5500 SPI
DMA buffer allocation. After Unit B+: ~32KB free internal, Min Free 78KB
in some test configurations — sufficient headroom for the other DMA
consumers (I2S audio, BT controller) to operate.
Builds on PR #14012's NetworkComponent + PR #14255's priority list to make
the user's stated interface priority actually drive runtime default-route
selection. Without this, ESP-IDF's auto-selection picks the default netif by
each netif's hardcoded `route_prio` field (WiFi STA = 100, Ethernet = 50,
WiFi AP = 10) — which inverts the user's intent on same-subnet
multi-homing configurations where wifi+ethernet share a broadcast domain.
Changes:
- NetworkComponent gains an IP_EVENT handler registered in setup() that
re-arbitrates the default netif on every interface up/down. The handler
walks the priority list in order, picks the highest-priority netif that
is up, and calls esp_netif_set_default_netif() on it. ESP-IDF then sets
its internal "manual override" flag so subsequent auto-selection events
don't undo our choice.
- New StaticVector<NetworkPriorityEntry, 4> stores the priority list with
zero heap allocation. The interface-name string pointer is a YAML literal
with static storage duration.
- The timeout_ms field is parsed and stored but not yet consumed by Unit A;
it's wired up for Unit D (runtime timeout fallback).
- New getters get_active_interface() / get_active_netif() expose the
currently-active interface for Unit C consumers.
- Python codegen iterates CORE.data[KEY_NETWORK_PRIORITY] and emits
add_priority_entry() calls per YAML order.
Field-tested on ESP32-S3 with W5500 SPI ethernet + WiFi STA on the same
subnet. The log line "[network] Default interface: <name>" confirms the
arbitration logic fires correctly on IP_EVENT_*_GOT_IP.
Standalone — no schema changes, single-interface configs unaffected.
PR #14255 sets NETWORK_PRIORITY_BASE = 300.0, but PR #14012's
NetworkComponent uses setup_priority::AFTER_BLUETOOTH = 300.0f. When the
highest-priority interface (first in the priority list) tied with
NetworkComponent at 300, the runtime tie-break was determined by
registration order — and NetworkComponent registers AFTER ethernet (its
codegen runs at CoroPriority.NETWORK_SERVICES = 55, below COMMUNICATION
= 60 used by ethernet/wifi).
Result: ethernet's setup() ran before NetworkComponent::setup(),
esp_netif_init() had not yet been called, esp_netif_new() returned
NULL, and EthernetComponent::setup() dereferenced NULL in
esp_netif_attach() — LoadProhibited crash at boot.
Drop the base to 250.0 (matches the historical setup_priority::WIFI /
::ETHERNET default, so a single-entry priority list behaves identically
to a no-priority-block config) and shrink the step to 5.0 to keep all
interfaces in the same priority band, above BEFORE_CONNECTION (220.0)
and below AFTER_BLUETOOTH (300.0).
PR #14255 generates calls to Component::set_setup_priority(float) from
ethernet/wifi to_code(), but that method's body in core/component.cpp is
gated by #ifdef USE_SETUP_PRIORITY_OVERRIDE. Without the define the
declaration exists but no implementation is linked, producing:
undefined reference to `esphome::Component::set_setup_priority(float)`
The existing convention in cpp_helpers.register_component() is to add
the define whenever CONF_SETUP_PRIORITY appears in a component's YAML.
Mirror that here: when the user declares `network: priority:`, the
priority-driven setup_priority overrides will be emitted, so the define
must be on.
Resolves conflicts with PR #14012 (centralized netif init):
- wifi_component_esp_idf.cpp: dropped pr-14255's ESP_ERR_INVALID_STATE
tolerance hunk (made moot by #14012 removing the call entirely).
- ethernet/__init__.py: kept dev's refactored _to_code_esp32 structure;
added pr-14255's priority lookup and conditional CONFIG_ESP_WIFI_ENABLED
gating; preserved dev's top-level import shape.
- network/__init__.py: merged CONF_ID import (#14012) with CONF_PRIORITY
+ CONF_TIMEOUT imports (pr-14255).
Also fixed two latent bugs in pr-14255 where `"wifi" in net_priority`
compared a string against a list of dicts (always False). Replaced with
set comprehension over the normalized interface names.