Compare commits

...

48 Commits

Author SHA1 Message Date
kbx81
70c6021986 Merge branch 'central-netif' into multi-interface-poc
# Conflicts:
#	esphome/components/wifi/wifi_component.cpp
2026-05-23 20:19:32 -05:00
Keith Burzinski
1485675928 Merge branch 'dev' into central-netif 2026-05-23 20:09:33 -05:00
Keith Burzinski
3ed1356bb6 type
Co-authored-by: J. Nick Koston <nick+github@koston.org>
2026-05-23 20:08:08 -05:00
Kevin Ahrendt
5cb145a8c3 [ethernet] Offload W5500 bulk SPI transfers from the busy-wait path (#16596) 2026-05-23 17:53:53 -04:00
Kevin Ahrendt
74001ccf05 [wifi] Wake main loop when requesting high performance mode (#16598) 2026-05-23 17:39:20 -04:00
Kevin Ahrendt
58931f2610 [audio] Add clear_buffered_data method to RingBufferAudioSource (#16594) 2026-05-23 17:37:59 -04:00
Jonathan Swoboda
f616103621 [esp32] Replace per-class -Wno-error=X demotes with blanket -Wno-error for ESP-IDF toolchain (#16599) 2026-05-23 15:44:25 -04:00
J. Nick Koston
188ff7ebfd [bluetooth_proxy] Recover slot stuck in DISCONNECTING when CLOSE_EVT is dropped (#16588) 2026-05-23 14:30:12 -05:00
J. Nick Koston
d6bc4fea1c Merge branch 'dev' into central-netif 2026-05-23 13:34:04 -05:00
Clyde Stubbs
be99553fd4 [ci] Fix flash memory overflow on tests (#16587) 2026-05-23 14:26:53 +10:00
kbx81
f818bddac8 Merge remote-tracking branch 'upstream/dev' into multi-interface-poc 2026-05-21 21:39:48 -05:00
kbx81
1f0af903ea 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.
2026-05-21 21:38:45 -05:00
kbx81
ed289390df feat(wifi): Unit B+ — defer esp_wifi_init() to lazy-init
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.
2026-05-21 21:32:38 -05:00
kbx81
8f3010ac64 feat(network): Unit A — explicit default-route management
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.
2026-05-21 21:29:47 -05:00
kbx81
9d9af645ac Merge remote-tracking branch 'upstream/dev' into multi-interface-poc 2026-05-20 23:52:07 -05:00
Rapsssito
9bfae9e782 Remove redundant esp_netif_init 2026-05-20 09:07:57 +02:00
kbx81
eb64707d94 fix(network): lower NETWORK_PRIORITY_BASE below NetworkComponent's own priority
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).
2026-05-19 23:16:01 -05:00
kbx81
7814e99b6f fix(network): enable USE_SETUP_PRIORITY_OVERRIDE when priority is configured
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.
2026-05-19 23:10:32 -05:00
kbx81
8ad6813d44 Merge PR #14255: network priority
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.
2026-05-19 22:45:14 -05:00
kbx81
1aa0a489f6 Merge PR #14012: centralize ESP32 network init 2026-05-19 22:37:40 -05:00
Keith Burzinski
be3ccd29f6 Merge branch 'dev' into central-netif 2026-05-19 18:03:15 -05:00
Rodrigo Martín
73b8491936 Update esphome/components/network/network_component.h
Co-authored-by: Keith Burzinski <kbx81x@gmail.com>
2026-05-19 17:50:02 +02:00
Rapsssito
1332ebe729 Exclude from non ESP32 devices 2026-05-19 09:20:08 +02:00
kbx81
028a54422e preen 2026-05-18 18:31:31 -05:00
kbx81
de53e7a6b1 preen 2026-05-18 18:24:54 -05:00
kbx81
0d1d00b654 Merge branch 'dev' into central-netif 2026-05-18 18:19:46 -05:00
pre-commit-ci-lite[bot]
578196ab85 [pre-commit.ci lite] apply automatic fixes 2026-02-28 22:24:08 +00:00
Roy Walker
d9b712ee5f Fix timeout to use ESPhome built-in function. 2026-02-28 16:22:28 -06:00
Roy Walker
1a61cd622e Add support for timeouts before next network connection is turned up and add support for openthread and modem. 2026-02-28 13:41:55 -06:00
pre-commit-ci-lite[bot]
26bdf58daf [pre-commit.ci lite] apply automatic fixes 2026-02-26 17:36:14 +00:00
Roy Walker
c915a2b8f5 Remove duplicate logger and fix import. 2026-02-26 11:34:18 -06:00
Roy Walker
7fdb95c2ef Merge branch 'rwalker777-network-priority' of https://github.com/rwalker777/esphome into rwalker777-network-priority 2026-02-26 11:31:10 -06:00
Roy Walker
e44365abca Remove duplicate CONF_OUTPUT_POWER from import. 2026-02-26 11:30:20 -06:00
rwalker777
532641d523 Merge branch 'esphome:dev' into rwalker777-network-priority 2026-02-26 11:30:07 -06:00
rwalker777
bc36892e7d Merge branch 'dev' into rwalker777-network-priority 2026-02-24 13:45:27 -06:00
Roy Walker
3a02c2f8af Fix validation on priority import. 2026-02-24 11:16:21 -06:00
pre-commit-ci-lite[bot]
4a1f9af319 [pre-commit.ci lite] apply automatic fixes 2026-02-24 17:08:56 +00:00
rwalker777
9e29bdfdad Merge branch 'esphome:dev' into rwalker777-network-priority 2026-02-24 11:03:34 -06:00
Roy Walker
20c975103b Fix Wifi not connecting with Ethernet config but disconnected. 2026-02-22 20:30:34 -06:00
Roy Walker
549b9f85ae Fix wifi so it doesn't double register. 2026-02-22 19:20:13 -06:00
Roy Walker
0fe2310db4 Fix wifi and ethernet coexisting. 2026-02-22 18:42:35 -06:00
Roy Walker
5af3e5caef Fix stab at network priority support. 2026-02-22 18:22:47 -06:00
Rapsssito
47854ff9de Add missing imports 2026-02-16 13:31:19 +01:00
Rapsssito
8a1ddfb1cc Typo 2026-02-16 13:21:13 +01:00
Rapsssito
cde89212fc Typo 2026-02-16 13:17:00 +01:00
Rapsssito
0a518c1e4c Switch to a network component 2026-02-16 13:13:23 +01:00
Rapsssito
8c7d2d984e Just store if it is initialized 2026-02-16 12:47:14 +01:00
Rapsssito
8390a98614 [ethernet, network, openthread, wifi] centralize esp32 netif intialization 2026-02-16 12:36:22 +01:00
25 changed files with 836 additions and 59 deletions

View File

@@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) {
}
}
void RingBufferAudioSource::clear_buffered_data() {
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
this->current_data_ = nullptr;
this->current_available_ = 0;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
this->splice_length_ = 0;
this->ring_buffer_->reset();
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.

View File

@@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer {
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
void clear_buffered_data();
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data

View File

@@ -135,12 +135,26 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);

View File

@@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);

View File

@@ -1816,12 +1816,9 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=maybe-uninitialized")
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_build_flag("-Wno-error=reorder")
cg.add_build_flag("-Wno-error=volatile")
cg.add_build_flag("-Wno-error=cpp")
# Undo IDF's blanket -Werror so third-party libraries and user
# lambdas don't need a -Wno-error=<class> entry per warning class.
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")

View File

@@ -72,6 +72,7 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -418,6 +419,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {

View File

@@ -140,6 +140,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);

View File

@@ -17,7 +17,7 @@ from esphome.core import HexInt
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
AUTO_LOAD = ["network"]
byte_vector = cg.std_vector.template(cg.uint8)
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)

View File

@@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {
@@ -174,8 +168,6 @@ void ESPNowComponent::enable() {
void ESPNowComponent::enable_() {
if (!this->is_wifi_enabled()) {
esp_event_loop_create_default();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));

View File

@@ -3,7 +3,11 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.network import ip_address_literal
from esphome.components.network import (
KEY_NETWORK_PRIORITY,
get_network_priority,
ip_address_literal,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -13,6 +17,7 @@ from esphome.const import (
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
CONF_ENABLE_ON_BOOT,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -27,6 +32,7 @@ from esphome.const import (
CONF_PAGE_ID,
CONF_PIN,
CONF_POLLING_INTERVAL,
CONF_PRIORITY,
CONF_RESET_PIN,
CONF_SPI,
CONF_STATIC_IP,
@@ -48,7 +54,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__)
@@ -348,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),
}
@@ -487,6 +493,11 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Apply network priority if configured, otherwise use the existing default
prio = get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
if CORE.is_esp32:
await _to_code_esp32(var, config)
elif CORE.is_rp2040:
@@ -494,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:
@@ -576,10 +590,16 @@ 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)
# Disable WiFi when using Ethernet alone to save memory.
# When network: priority: lists both interfaces, WiFi must remain enabled.
net_priority = CORE.data.get(KEY_NETWORK_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
running_with_wifi = "wifi" in priority_ifaces and "ethernet" in priority_ifaces
if not running_with_wifi:
# 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)
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")
@@ -665,6 +685,17 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> ConfigType:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:
full = fv.full_config.get()
net_priority = full.get("network", {}).get(CONF_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
has_priority_config = "ethernet" in priority_ifaces and "wifi" in priority_ifaces
if "wifi" in full and not has_priority_config:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
return config

View File

@@ -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};

View File

@@ -5,6 +5,7 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "w5500_custom_spi.h"
#include <lwip/dns.h>
#include <cinttypes>
@@ -137,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
@@ -163,11 +182,7 @@ void EthernetComponent::setup() {
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
#endif
err = esp_netif_init();
ESPHL_ERROR_CHECK(err, "ETH netif init error");
err = esp_event_loop_create_default();
ESPHL_ERROR_CHECK(err, "ETH event loop error");
// Network interface setup handled by network component
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
this->eth_netif_ = esp_netif_new(&cfg);
@@ -207,6 +222,10 @@ void EthernetComponent::setup() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
w5500_config.poll_period_ms = this->polling_interval_;
#endif
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
// runs the driver's init().
install_w5500_async_spi(w5500_config);
#elif defined(USE_ETHERNET_DM9051)
dm9051_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
@@ -370,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() {
@@ -486,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) {
@@ -708,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;
@@ -775,6 +832,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");
@@ -794,6 +858,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);
@@ -802,6 +868,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);

View File

@@ -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

View File

@@ -0,0 +1,118 @@
#include "w5500_custom_spi.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring>
#include <new>
namespace esphome::ethernet {
namespace {
// Per-device context returned by init() and handed back to read/write/deinit.
struct W5500CustomSpiContext {
spi_device_handle_t handle;
SemaphoreHandle_t lock;
};
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
void *w5500_custom_spi_init(const void *spi_config) {
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
if (ctx == nullptr) {
return nullptr;
}
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
// byte in the address phase; mirror what the stock driver configures.
spi_device_interface_config_t devcfg = *config->spi_devcfg;
devcfg.command_bits = 16;
devcfg.address_bits = 8;
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
delete ctx;
return nullptr;
}
ctx->lock = xSemaphoreCreateMutex();
if (ctx->lock == nullptr) {
spi_bus_remove_device(ctx->handle);
delete ctx;
return nullptr;
}
return ctx;
}
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_bus_remove_device(ctx->handle);
vSemaphoreDelete(ctx->lock);
delete ctx;
return ESP_OK;
}
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
// accesses stay on the cheaper polling path. Used by both read and write.
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
esp_err_t ret;
if (len > W5500_SPI_BULK_THRESHOLD) {
ret = spi_device_transmit(ctx->handle, trans);
} else {
ret = spi_device_polling_transmit(ctx->handle, trans);
}
xSemaphoreGive(ctx->lock);
return ret;
}
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.tx_buffer = data;
return w5500_custom_spi_transfer(ctx, &trans, len);
}
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
// overwrites of adjacent registers (same guard the stock driver uses).
const bool use_rxdata = len <= 4;
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.rx_buffer = data;
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
if (use_rxdata && (ret == ESP_OK)) {
memcpy(data, trans.rx_data, len);
}
return ret;
}
} // namespace
void install_w5500_async_spi(eth_w5500_config_t &config) {
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
// spi_devcfg back out of it. The self-reference is valid because both the config and the
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
config.custom_spi_driver.config = &config;
config.custom_spi_driver.init = w5500_custom_spi_init;
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
config.custom_spi_driver.read = w5500_custom_spi_read;
config.custom_spi_driver.write = w5500_custom_spi_write;
}
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500

View File

@@ -0,0 +1,35 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <esp_idf_version.h>
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
// is no longer reachable through esp_eth.h and needs the explicit header.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <esp_eth_mac_w5500.h>
#else
#include <esp_eth.h>
#endif
namespace esphome::ethernet {
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
//
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
// is cheaper than an interrupt round-trip.
//
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
// alive until esp_eth_mac_new_w5500() returns.
void install_w5500_async_spi(eth_w5500_config_t &config);
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500

View File

@@ -5,8 +5,15 @@ import esphome.codegen as cg
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
import esphome.config_validation as cv
from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT
from esphome.const import (
CONF_ENABLE_IPV6,
CONF_ID,
CONF_MIN_IPV6_ADDR_COUNT,
CONF_PRIORITY,
CONF_TIMEOUT,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
CODEOWNERS = ["@esphome/core"]
AUTO_LOAD = ["mdns"]
@@ -18,7 +25,30 @@ _LOGGER = logging.getLogger(__name__)
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
# Network priority tracking infrastructure
# Components can query this to determine their relative setup priority and fallback timeout.
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts:
# [{"interface": "ethernet", "timeout": 30000}, {"interface": "wifi", "timeout": None}, ...]
# where timeout is in milliseconds, or None meaning "start the next interface immediately".
KEY_NETWORK_PRIORITY = "network_priority"
VALID_NETWORK_TYPES = ["ethernet", "openthread", "wifi", "modem"]
# Setup priority base values — first in list gets the highest priority.
#
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
# (250.0), so a single-entry priority list yields exactly the same setup order
# as a config with no priority block. Subsequent entries step down by a small
# amount to break ties without crossing other priority bands.
#
# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH
# (300.0), which NetworkComponent itself uses — otherwise the highest-priority
# interface could tie with NetworkComponent and run before esp_netif_init().
NETWORK_PRIORITY_BASE = 250.0
NETWORK_PRIORITY_STEP = 5.0
network_ns = cg.esphome_ns.namespace("network")
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
IPAddress = network_ns.class_("IPAddress")
@@ -105,8 +135,160 @@ def has_high_performance_networking() -> bool:
return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False)
def _get_priority_entry(iface: str) -> dict | None:
"""Return the priority entry dict for the given interface, or None if not configured."""
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
if priority_list is None:
return None
iface_lower = iface.lower()
for entry in priority_list:
if entry["interface"] == iface_lower:
return entry
return None
def get_network_priority(iface: str) -> float | None:
"""Get the setup priority for the given network interface type.
Returns the float setup priority for ``iface`` based on the order declared
under ``network: priority:``. Interfaces listed first receive a higher
setup priority so they are initialised before lower-priority ones.
If no ``network: priority:`` has been configured this returns ``None`` and
the calling component should fall back to its own default setup priority.
Args:
iface: Interface type string — one of ``"ethernet"``, ``"wifi"``,
``"openthread"`` or ``"modem"`` (case-insensitive).
Returns:
float setup priority, or None if no priority list was configured.
Example usage inside a component's ``to_code``::
from esphome.components import network
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
prio = network.get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
...
"""
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
if priority_list is None:
return None
iface_lower = iface.lower()
for idx, entry in enumerate(priority_list):
if entry["interface"] == iface_lower:
return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP)
return None
def get_network_timeout(iface: str) -> int | None:
"""Get the fallback timeout in milliseconds for the given network interface.
Returns the timeout (in ms) that the runtime should wait for ``iface`` to
connect before attempting to bring up the next interface in the priority
list. Returns ``None`` if no timeout was configured for this interface,
meaning the next interface should start immediately.
Args:
iface: Interface type string — one of ``"ethernet"``, ``"wifi"``,
``"openthread"`` or ``"modem"`` (case-insensitive).
Returns:
int timeout in milliseconds, or None if no timeout is configured.
Example usage inside a component's ``to_code``::
from esphome.components import network
async def to_code(config):
...
timeout_ms = network.get_network_timeout("ethernet")
if timeout_ms is not None:
cg.add(var.set_fallback_timeout(timeout_ms))
...
"""
entry = _get_priority_entry(iface)
if entry is None:
return None
return entry.get("timeout")
def _validate_timeout(value):
"""Accept any common ESPHome/HA time period format, or a plain integer as seconds.
Accepted formats: 30s, 10sec, 1min, 500ms, 1h, 1.5h, 30 (plain int → 30s).
"""
if isinstance(value, int):
# Plain integer — treat as seconds, e.g. timeout: 30 means 30s
return cv.positive_time_period_milliseconds(f"{value}s")
return cv.positive_time_period_milliseconds(value)
def _priority_entry_schema(value):
"""Validate a single priority list entry in either plain string or mapping form.
Plain string form (no timeout):
- ethernet
Mapping form with optional timeout:
- ethernet:
timeout: 30s
"""
if isinstance(value, str):
return cv.one_of(*VALID_NETWORK_TYPES, lower=True)(value)
if isinstance(value, dict):
if len(value) != 1:
raise cv.Invalid(
"Each priority entry must have exactly one interface name as its key"
)
iface = next(iter(value))
cv.one_of(*VALID_NETWORK_TYPES, lower=True)(iface)
opts = cv.Schema(
{
cv.Optional(CONF_TIMEOUT): _validate_timeout,
}
)(value[iface] or {})
return {iface: opts}
raise cv.Invalid(
f"Expected an interface name string or a mapping, got {type(value).__name__}"
)
def _normalize_priority_entry(value) -> dict:
"""Normalize a validated priority entry to a canonical dict.
Returns a dict with keys:
- ``interface``: str, lowercase interface name
- ``timeout``: int milliseconds, or None
"""
if isinstance(value, str):
return {"interface": value, "timeout": None}
# Mapping form — exactly one key (the interface name)
iface, opts = next(iter(value.items()))
timeout = opts.get(CONF_TIMEOUT)
timeout_ms = int(timeout.total_milliseconds) if timeout is not None else None
return {"interface": iface, "timeout": timeout_ms}
def _validate_priority_list(value):
"""Validate and normalize the full priority list, rejecting duplicates."""
raw = cv.ensure_list(_priority_entry_schema)(value)
entries = [_normalize_priority_entry(e) for e in raw]
interfaces = [e["interface"] for e in entries]
if len(interfaces) != len(set(interfaces)):
raise cv.Invalid("Duplicate entries are not allowed in 'priority'")
return entries
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
@@ -130,15 +312,53 @@ CONFIG_SCHEMA = cv.Schema(
),
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_PRIORITY): _validate_priority_list,
}
)
def _final_validate(config):
"""Check that every interface named in 'priority' has a corresponding component block."""
full = fv.full_config.get()
for entry in config.get(CONF_PRIORITY, []):
iface = entry["interface"]
if iface not in full:
raise cv.Invalid(
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
f"component is configured",
[CONF_PRIORITY],
)
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.NETWORK)
async def to_code(config):
cg.add_define("USE_NETWORK")
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
# Store the user-declared network priority list in CORE.data so that ethernet,
# wifi and other network components can query it via get_network_priority() and
# get_network_timeout() during their own to_code phase.
if CONF_PRIORITY in config:
priority_list = config[CONF_PRIORITY]
CORE.data[KEY_NETWORK_PRIORITY] = priority_list
# Enable Component::set_setup_priority() so the per-interface to_code
# calls below have a defined symbol to link against. Without this define
# the implementation in core/component.cpp is compiled out.
cg.add_define("USE_SETUP_PRIORITY_OVERRIDE")
def _fmt(entry):
if entry["timeout"] is not None:
return f"{entry['interface']} (timeout: {entry['timeout']}ms)"
return entry["interface"]
_LOGGER.info(
"Network interface priority: %s",
" > ".join(_fmt(e) for e in priority_list),
)
# Apply high performance networking settings
# Config can explicitly enable/disable, or default to component-driven behavior
enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE)
@@ -224,3 +444,22 @@ async def to_code(config):
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
if CORE.is_rp2040:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
# Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it
# emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config
# (above) separate from C++ object lifecycle and allows wiring in interface
# pointers via get_variable().
if CORE.is_esp32:
CORE.add_job(network_component_to_code, config)
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
async def network_component_to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Pass the priority list to the C++ component. NetworkComponent::add_priority_entry
# captures the interface-name string literal pointer; CORE.data[KEY_NETWORK_PRIORITY]
# holds the normalized list of dicts (`{"interface": str, "timeout": int|None}`).
for entry in CORE.data.get(KEY_NETWORK_PRIORITY, []):
timeout_ms = entry["timeout"] if entry["timeout"] is not None else 0
cg.add(var.add_priority_entry(entry["interface"], timeout_ms))

View File

@@ -0,0 +1,119 @@
#include "network_component.h"
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/log.h"
#include <cstring>
#include "esp_err.h"
#include "esp_event.h"
#include "esp_netif.h"
namespace esphome::network {
static const char *const TAG = "network";
void NetworkComponent::setup() {
// Initialize ESP-IDF network interfaces and ensure the default event loop exists
esp_err_t err;
err = esp_netif_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
err = esp_event_loop_create_default();
// ESP_ERR_INVALID_STATE is returned if the default loop already exists,
// which is fine since we just want to make sure it exists
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
// Register an IP_EVENT handler so we can re-arbitrate the default netif on every
// interface up/down. ESP-IDF's built-in auto-selection picks by route_prio (WiFi STA = 100
// > Ethernet = 50), which inverts the user's stated priority for same-subnet configurations.
// We register AFTER esp-idf's internal handler, so our esp_netif_set_default_netif() call
// wins and stays sticky thanks to esp-idf's "manual override" flag.
err = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &NetworkComponent::event_handler_, this);
if (err != ESP_OK) {
ESP_LOGW(TAG, "IP_EVENT handler register failed: %s — default route arbitration disabled",
esp_err_to_name(err));
}
// Defensive: arbitrate now in case an interface came up before our handler registered
// (unlikely given our AFTER_BLUETOOTH priority but cheap).
this->update_default_netif_();
}
void NetworkComponent::add_priority_entry(const char *interface, uint32_t timeout_ms) {
if (this->priority_list_.size() >= MAX_NETWORK_PRIORITY_ENTRIES) {
ESP_LOGW(TAG, "Priority list full; ignoring '%s'", interface);
return;
}
this->priority_list_.push_back({interface, timeout_ms});
}
const char *NetworkComponent::interface_to_ifkey_(const char *interface) {
// Standard ESP-IDF netif keys. esphome's wifi/ethernet/openthread components create
// netifs using these defaults.
if (std::strcmp(interface, "ethernet") == 0)
return "ETH_DEF";
if (std::strcmp(interface, "wifi") == 0)
return "WIFI_STA_DEF"; // STA carries uplink; AP never wins default route
if (std::strcmp(interface, "openthread") == 0)
return "OT_DEF";
if (std::strcmp(interface, "modem") == 0)
return "PPP_DEF";
return nullptr;
}
void NetworkComponent::event_handler_(void *arg, esp_event_base_t /*base*/, int32_t /*id*/, void * /*data*/) {
auto *self = static_cast<NetworkComponent *>(arg);
self->update_default_netif_();
}
void NetworkComponent::update_default_netif_() {
// No priority list configured → leave ESP-IDF's route_prio-based auto-selection alone.
// Single-interface configs behave exactly as before.
if (this->priority_list_.empty()) {
return;
}
for (const auto &entry : this->priority_list_) {
const char *ifkey = interface_to_ifkey_(entry.interface);
if (ifkey == nullptr)
continue;
esp_netif_t *netif = esp_netif_get_handle_from_ifkey(ifkey);
if (netif == nullptr)
continue; // component for this interface hasn't run setup() yet
// is_netif_up returns true only when the netif has link + IP, which is what
// we want for "this interface can carry traffic right now."
if (!esp_netif_is_netif_up(netif))
continue;
if (netif != this->active_netif_) {
ESP_LOGI(TAG, "Default interface: %s", entry.interface);
esp_netif_set_default_netif(netif);
this->active_interface_ = entry.interface;
this->active_netif_ = netif;
}
return;
}
// No priority-listed interface is currently up.
if (this->active_netif_ != nullptr) {
ESP_LOGD(TAG, "No active interface in priority list");
this->active_interface_ = nullptr;
this->active_netif_ = nullptr;
// We intentionally don't clear esp-idf's default — the next interface that comes
// up will trigger our handler again and we'll re-pick.
}
}
} // namespace esphome::network
#endif

View File

@@ -0,0 +1,54 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esp_event.h"
#include "esp_netif.h"
namespace esphome::network {
// Cap matches the number of interface types the priority list accepts in YAML
// (ethernet, wifi, openthread, modem). StaticVector keeps zero heap allocation.
inline constexpr size_t MAX_NETWORK_PRIORITY_ENTRIES = 4;
struct NetworkPriorityEntry {
const char *interface; // YAML name: "ethernet", "wifi", "openthread", "modem"
uint32_t timeout_ms; // 0 = no timeout; consumed by Unit D (runtime fallback)
};
class NetworkComponent : public Component {
public:
void setup() override;
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Codegen-time priority list construction. Called once per `network: priority:` entry
// in YAML order. The interface name pointer must have static storage duration.
void add_priority_entry(const char *interface, uint32_t timeout_ms);
// Currently-active interface in priority order (the one set as default netif).
// Returns nullptr if no priority list is configured or no interface is up.
const char *get_active_interface() const { return this->active_interface_; }
esp_netif_t *get_active_netif() const { return this->active_netif_; }
protected:
// Maps a YAML interface name to its ESP-IDF netif if-key.
// Returns nullptr if the interface name is not recognized.
static const char *interface_to_ifkey_(const char *interface);
// ESP-IDF event handler trampoline. Fires on IP_EVENT_* and re-arbitrates the default netif.
static void event_handler_(void *arg, esp_event_base_t base, int32_t id, void *data);
// Walk priority_list_ in order. Set the highest-priority netif that is up as the
// ESP-IDF default. No-op if priority_list_ is empty (single-interface configs).
void update_default_netif_();
StaticVector<NetworkPriorityEntry, MAX_NETWORK_PRIORITY_ENTRIES> priority_list_;
const char *active_interface_{nullptr};
esp_netif_t *active_netif_{nullptr};
};
} // namespace esphome::network
#endif

View File

@@ -35,9 +35,8 @@ void OpenThreadComponent::setup() {
esp_vfs_eventfd_config_t eventfd_config = {
.max_fds = 3,
};
// Network interface setup handled by network component
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config));
xTaskCreate(

View File

@@ -12,6 +12,7 @@ from esphome.components.esp32 import (
only_on_variant,
)
from esphome.components.network import (
get_network_priority,
has_high_performance_networking,
ip_address_literal,
)
@@ -42,7 +43,6 @@ from esphome.const import (
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_ON_ERROR,
CONF_OUTPUT_POWER,
CONF_PASSWORD,
CONF_POWER_SAVE_MODE,
CONF_PRIORITY,
@@ -440,6 +440,7 @@ def _validate(config):
return config
CONF_OUTPUT_POWER = "output_power"
CONF_PASSIVE_SCAN = "passive_scan"
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -573,6 +574,10 @@ def wifi_network(config, ap, static_ip):
@coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
prio = get_network_priority("wifi")
if prio is not None:
cg.add(var.set_setup_priority(prio))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
# Track if any network uses Enterprise authentication

View File

@@ -632,11 +632,11 @@ void WiFiComponent::setup() {
#endif
if (this->enable_on_boot_) {
#ifdef USE_ESP32
this->wifi_lazy_init_();
#endif
this->start();
} else {
#ifdef USE_ESP32
esp_netif_init();
#endif
this->state_ = WIFI_COMPONENT_STATE_DISABLED;
}
}
@@ -1278,6 +1278,11 @@ void WiFiComponent::enable() {
ESP_LOGD(TAG, "Enabling");
this->state_ = WIFI_COMPONENT_STATE_OFF;
#ifdef USE_ESP32
// Idempotent — only allocates DMA buffers + netifs on the first call. After this,
// start() can safely run.
this->wifi_lazy_init_();
#endif
this->start();
}
@@ -2193,7 +2198,15 @@ bool WiFiComponent::request_high_performance() {
}
// Give the semaphore (non-blocking). This increments the count.
return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
// Wake the main loop so the switch to high-performance mode is applied on the
// next tick instead of waiting up to loop_interval.
if (success) {
App.wake_loop_threadsafe();
}
return success;
}
bool WiFiComponent::release_high_performance() {

View File

@@ -694,6 +694,12 @@ class WiFiComponent final : public Component {
bool wifi_apply_hostname_();
bool wifi_sta_connect_(const WiFiAP &ap);
void wifi_pre_setup_();
#ifdef USE_ESP32
// ESP-IDF only: defers esp_wifi_init() + netif creation (which allocate ~15-30KB of
// DMA-capable internal SRAM) until wifi actually needs to come up. Idempotent.
// Called from setup() only when enable_on_boot_=true, and from enable() on first use.
void wifi_lazy_init_();
#endif
WiFiSTAConnectStatus wifi_sta_connect_status_() const;
bool is_connected_() const {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&
@@ -889,6 +895,12 @@ class WiFiComponent final : public Component {
bool rrm_{false};
#endif
bool enable_on_boot_{true};
#ifdef USE_ESP32
// Tracks whether esp_wifi_init() + netif creation has happened. Allows enable()
// to be called at runtime without re-allocating, and ensures the heavy init is
// skipped entirely when enable_on_boot_ is false until first enable().
bool wifi_initialized_{false};
#endif
bool got_ipv4_address_{false};
bool keep_scan_results_{false};
bool has_completed_scan_after_captive_portal_start_{

View File

@@ -145,23 +145,15 @@ void WiFiComponent::wifi_pre_setup_() {
get_mac_address_raw(mac);
set_mac_address(mac);
}
esp_err_t err = esp_netif_init();
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err));
return;
}
// Network interface setup handled by network component
s_wifi_event_group = xEventGroupCreate();
if (s_wifi_event_group == nullptr) {
ESP_LOGE(TAG, "xEventGroupCreate failed");
return;
}
err = esp_event_loop_create_default();
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err));
return;
}
esp_event_handler_instance_t instance_wifi_id, instance_ip_id;
err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
esp_err_t err =
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err));
return;
@@ -171,6 +163,16 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err));
return;
}
// NOTE: netif creation + esp_wifi_init() used to live here. They allocate ~15-30KB of
// DMA-capable internal SRAM, which competes with W5500 SPI DMA and I2S DMA on
// memory-tight devices. They are now deferred to wifi_lazy_init_(), called from
// setup() when enable_on_boot_ is true, or from enable() on first runtime enable.
// This makes enable_on_boot:false genuinely skip the wifi DMA allocation.
}
void WiFiComponent::wifi_lazy_init_() {
if (this->wifi_initialized_)
return;
s_sta_netif = esp_netif_create_default_wifi_sta();
@@ -183,7 +185,7 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGW(TAG, "starting wifi without nvs");
cfg.nvs_enable = false;
}
err = esp_wifi_init(&cfg);
esp_err_t err = esp_wifi_init(&cfg);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err));
return;
@@ -193,6 +195,7 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
return;
}
this->wifi_initialized_ = true;
}
bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {

View File

@@ -1,6 +1,8 @@
<<: !include common.yaml
esp32_ble_tracker:
esp32_ble:
max_connections: 9
bluetooth_proxy:

View File

@@ -4,6 +4,7 @@ esphome:
esp32:
board: esp32-c6-devkitc-1
flash_size: 8MB
framework:
type: esp-idf