feat(ethernet): Unit B + B+ — enable_on_boot lifecycle with lazy-init

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.
This commit is contained in:
kbx81
2026-05-21 21:38:45 -05:00
parent ed289390df
commit 1f0af903ea
4 changed files with 124 additions and 3 deletions
+5
View File
@@ -17,6 +17,7 @@ from esphome.const import (
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
CONF_ENABLE_ON_BOOT,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -352,6 +353,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),
}
@@ -503,6 +505,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:
@@ -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};
@@ -137,6 +137,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
@@ -366,9 +384,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() {
@@ -482,6 +532,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) {
@@ -704,6 +756,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;
@@ -771,6 +827,13 @@ 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 (sendspin, ethernet_info, mdns, etc.) may ask for the MAC
// before/regardless of whether ethernet is enabled. Fall back to the system MAC
// assigned to the ETH interface — same value the driver would have returned.
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");
@@ -790,6 +853,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);
@@ -798,6 +863,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);
@@ -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