From 2454ad1645321a8cde03f5a0e167c6f2b0ed5b2c Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Mon, 1 Jun 2026 15:30:07 -0500 Subject: [PATCH] [ethernet] Add enable_on_boot lifecycle + lazy-init to reclaim DMA-capable SRAM (#16607) Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- esphome/components/ethernet/__init__.py | 28 +++++++ esphome/components/ethernet/automation.h | 30 ++++++++ .../components/ethernet/ethernet_component.h | 32 ++++++++ .../ethernet/ethernet_component_esp32.cpp | 76 ++++++++++++++++++- .../ethernet/ethernet_component_rp2040.cpp | 17 +++++ .../ethernet/test-lifecycle.esp32-idf.yaml | 39 ++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 esphome/components/ethernet/automation.h create mode 100644 tests/components/ethernet/test-lifecycle.esp32-idf.yaml diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 22f5eb33e1..784f5dee8c 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -2,6 +2,7 @@ from dataclasses import dataclass import logging from esphome import automation, pins +from esphome.automation import Condition import esphome.codegen as cg from esphome.components.network import ip_address_literal from esphome.config_helpers import filter_source_files_from_platform @@ -13,6 +14,7 @@ from esphome.const import ( CONF_DNS1, CONF_DNS2, CONF_DOMAIN, + CONF_ENABLE_ON_BOOT, CONF_GATEWAY, CONF_ID, CONF_INTERRUPT_PIN, @@ -217,6 +219,10 @@ MANUAL_IP_SCHEMA = cv.Schema( EthernetComponent = ethernet_ns.class_("EthernetComponent", cg.Component) ManualIP = ethernet_ns.struct("ManualIP") +EthernetConnectedCondition = ethernet_ns.class_("EthernetConnectedCondition", Condition) +EthernetEnabledCondition = ethernet_ns.class_("EthernetEnabledCondition", Condition) +EthernetEnableAction = ethernet_ns.class_("EthernetEnableAction", automation.Action) +EthernetDisableAction = ethernet_ns.class_("EthernetDisableAction", automation.Action) def _is_framework_spi_polling_mode_supported() -> bool: @@ -348,6 +354,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), } @@ -494,6 +501,9 @@ async def to_code(config): cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]])) cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) + # 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)) CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE] if CONF_MANUAL_IP in config: @@ -715,3 +725,21 @@ def _filter_source_files() -> list[str]: FILTER_SOURCE_FILES = _filter_source_files + + +async def _new_pvariable_to_code(config, id_, template_arg, args): + return cg.new_Pvariable(id_, template_arg) + + +for _name, _cls in ( + ("ethernet.connected", EthernetConnectedCondition), + ("ethernet.enabled", EthernetEnabledCondition), +): + automation.register_condition(_name, _cls, cv.Schema({}))(_new_pvariable_to_code) +for _name, _cls in ( + ("ethernet.enable", EthernetEnableAction), + ("ethernet.disable", EthernetDisableAction), +): + automation.register_action(_name, _cls, cv.Schema({}), synchronous=True)( + _new_pvariable_to_code + ) diff --git a/esphome/components/ethernet/automation.h b/esphome/components/ethernet/automation.h new file mode 100644 index 0000000000..c16abc5bda --- /dev/null +++ b/esphome/components/ethernet/automation.h @@ -0,0 +1,30 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ETHERNET +#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 EthernetEnabledCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_eth_component->is_enabled(); } +}; + +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 17c84ee954..7d06377f90 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -124,6 +124,17 @@ class EthernetComponent final : public Component { void on_powerdown() override { powerdown(); } bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; } + // Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled). + // enable_on_boot defaults to true; when false, setup() runs all the driver/netif + // installation but skips esp_eth_start(), keeping the link cold until enable() is + // called. This is the primary lever for memory reclamation in multi-interface + // configurations where only one interface should carry traffic at a time. + void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; } + void enable(); + void disable(); + bool is_disabled() { return this->disabled_; } + bool is_enabled() { return !this->disabled_; } + void set_type(EthernetType type); #ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); @@ -194,6 +205,16 @@ class EthernetComponent final : public Component { void finish_connect_(); void dump_connect_params_(); +#ifdef USE_ESP32 + // ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver + // install, netif attach, and event handler registration (which together allocate + // ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue) + // until ethernet actually needs to come up. Idempotent — guarded by the + // ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or + // from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi. + void ethernet_lazy_init_(); +#endif + #ifdef USE_ETHERNET_IP_STATE_LISTENERS void notify_ip_state_listeners_(); #endif @@ -287,6 +308,17 @@ class EthernetComponent final : public Component { bool started_{false}; bool connected_{false}; bool got_ipv4_address_{false}; + // Codegen-time YAML option. When false, setup() defers esp_eth_start(). + bool enable_on_boot_{true}; + // Mirror of "is the link intentionally stopped" — set when setup() honors + // enable_on_boot=false, cleared by enable(), set again by disable(). + bool disabled_{false}; +#ifdef USE_ESP32 + // Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable() + // to be called at runtime after enable_on_boot:false without re-allocating, and + // ensures setup() skips the heavy init when enable_on_boot_ is false. + bool ethernet_initialized_{false}; +#endif #if LWIP_IPV6 uint8_t ipv6_count_{0}; bool ipv6_setup_done_{false}; diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index 6481c8c1f4..544ec79c32 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -138,6 +138,24 @@ void EthernetComponent::setup() { delay(300); // NOLINT } + if (this->enable_on_boot_) { + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + // lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do. + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + ESPHL_ERROR_CHECK(err, "ETH start error"); + } else { + ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)"); + this->disabled_ = true; + } +} + +void EthernetComponent::ethernet_lazy_init_() { + if (this->ethernet_initialized_) + return; + esp_err_t err; #ifdef USE_ETHERNET_SPI @@ -371,9 +389,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"); + this->ethernet_initialized_ = true; +} + +void EthernetComponent::enable() { + if (!this->disabled_) + return; + + ESP_LOGD(TAG, "Enabling"); + this->ethernet_lazy_init_(); + if (!this->ethernet_initialized_) { + ESP_LOGE(TAG, "Cannot enable - init failed"); + return; + } + esp_err_t err = esp_eth_start(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err)); + return; + } + this->disabled_ = false; + // The ETH_EVENT_START handler will set started_=true; the loop state machine + // will then drive the STOPPED -> CONNECTING -> CONNECTED transitions. + this->enable_loop(); +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + + ESP_LOGD(TAG, "Disabling"); + esp_err_t err = esp_eth_stop(this->eth_handle_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err)); + } + this->disabled_ = true; + // ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED. } void EthernetComponent::dump_config() { @@ -487,6 +537,8 @@ void EthernetComponent::dump_config() { network::IPAddresses EthernetComponent::get_ip_addresses() { network::IPAddresses addresses; + if (!this->ethernet_initialized_) + return addresses; // all-zero IPs esp_netif_ip_info_t ip; esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip); if (err != ESP_OK) { @@ -709,6 +761,10 @@ void EthernetComponent::start_connect_() { } void EthernetComponent::dump_connect_params_() { + if (!this->ethernet_initialized_) { + ESP_LOGCONFIG(TAG, " uninitialized/disabled"); + return; + } esp_netif_ip_info_t ip; esp_netif_get_ip_info(this->eth_netif_, &ip); const ip_addr_t *dns_ip1; @@ -776,6 +832,16 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy #endif void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) { + if (!this->ethernet_initialized_) { + // External callers (mdns, ethernet_info, etc.) may ask for the MAC before/regardless + // of whether ethernet is enabled. Use the configured MAC if set, else the system ETH MAC. + if (this->fixed_mac_.has_value()) { + memcpy(mac, this->fixed_mac_->data(), 6); + } else { + esp_read_mac(mac, ESP_MAC_ETH); + } + return; + } esp_err_t err; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac); ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error"); @@ -795,6 +861,8 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer( } eth_duplex_t EthernetComponent::get_duplex_mode() { + if (!this->ethernet_initialized_) + return ETH_DUPLEX_HALF; esp_err_t err; eth_duplex_t duplex_mode; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode); @@ -803,6 +871,8 @@ eth_duplex_t EthernetComponent::get_duplex_mode() { } eth_speed_t EthernetComponent::get_link_speed() { + if (!this->ethernet_initialized_) + return ETH_SPEED_10M; esp_err_t err; eth_speed_t speed; err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed); diff --git a/esphome/components/ethernet/ethernet_component_rp2040.cpp b/esphome/components/ethernet/ethernet_component_rp2040.cpp index ef7bd46332..250297ddb5 100644 --- a/esphome/components/ethernet/ethernet_component_rp2040.cpp +++ b/esphome/components/ethernet/ethernet_component_rp2040.cpp @@ -361,6 +361,23 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; } void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; } void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; } +void EthernetComponent::enable() { + // RP2040 uses arduino-pico's LwipIntfDev which manages link state internally; + // there is no clean enable/disable hook today. The YAML option is accepted on + // RP2040 for schema parity but has no effect. + if (!this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = false; +} + +void EthernetComponent::disable() { + if (this->disabled_) + return; + ESP_LOGW(TAG, "enable_on_boot/disable not supported"); + this->disabled_ = true; +} + } // namespace esphome::ethernet #endif // USE_ETHERNET && USE_RP2040 diff --git a/tests/components/ethernet/test-lifecycle.esp32-idf.yaml b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml new file mode 100644 index 0000000000..904a916789 --- /dev/null +++ b/tests/components/ethernet/test-lifecycle.esp32-idf.yaml @@ -0,0 +1,39 @@ +ethernet: + id: eth + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + enable_on_boot: false + manual_ip: + static_ip: 192.168.178.56 + gateway: 192.168.178.1 + subnet: 255.255.255.0 + mac_address: "02:AA:BB:CC:DD:01" + interface: spi2 + +esphome: + on_boot: + priority: 200 + then: + - if: + condition: + not: + ethernet.enabled: + then: + - ethernet.enable: + +button: + - platform: template + name: "Disable Ethernet" + on_press: + - ethernet.disable: + +binary_sensor: + - platform: template + name: "Ethernet Connected" + lambda: |- + return id(eth).is_connected();