From de0a0b2af4ffd080ef41f1642fae6487ab51aba0 Mon Sep 17 00:00:00 2001 From: David Coulson <23066302+davidcoulson@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:46:16 -0400 Subject: [PATCH] [ethernet] Fix IPv6 SLAAC: link-local on link-up, pass all-nodes multicast (#19752) Co-authored-by: Claude Opus 5.5 Co-authored-by: J. Nick Koston --- .../components/ethernet/ethernet_component.h | 3 + .../ethernet/ethernet_component_esp32.cpp | 62 ++++++++++++++++--- .../ethernet/test-ipv6.esp32-idf.yaml | 12 ++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 tests/components/ethernet/test-ipv6.esp32-idf.yaml diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 75dcfb45f6..71fa9d40b6 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -220,6 +220,9 @@ class EthernetComponent final : public Component { protected: void start_connect_(); void finish_connect_(); +#if LWIP_IPV6 + esp_err_t ensure_ip6_linklocal_(); +#endif void dump_connect_params_(); #ifdef USE_ESP32 diff --git a/esphome/components/ethernet/ethernet_component_esp32.cpp b/esphome/components/ethernet/ethernet_component_esp32.cpp index d15ef5a34c..2c3c5f1ef9 100644 --- a/esphome/components/ethernet/ethernet_component_esp32.cpp +++ b/esphome/components/ethernet/ethernet_component_esp32.cpp @@ -10,6 +10,10 @@ #include #include #include "esp_event.h" +#if USE_NETWORK_IPV6 +#include +#include +#endif #ifdef USE_PSRAM #include #endif @@ -509,6 +513,18 @@ void EthernetComponent::ethernet_lazy_init_() { } } #endif +#if USE_NETWORK_IPV6 && ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + // Since IDF 5.5 the internal EMAC drops multicast groups that were never added (before, + // it passed all multicast), and lwIP never adds all-nodes, so router advertisements + // were lost and SLAAC never ran. + { + uint8_t all_nodes[6] = {0x33, 0x33, 0x00, 0x00, 0x00, 0x01}; + if (esp_err_t filter_err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_ADD_MAC_FILTER, all_nodes); + filter_err != ESP_OK) { + ESP_LOGD(TAG, "IPv6 all-nodes multicast filter not added: %s", esp_err_to_name(filter_err)); + } + } +#endif // Register user defined event handers err = esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, &EthernetComponent::eth_event_handler, nullptr); @@ -755,6 +771,13 @@ void EthernetComponent::eth_event_handler(void *arg, esp_event_base_t event_base global_eth_component->notify_ip_state_listeners_(); } #endif +#if USE_NETWORK_IPV6 + // Start SLAAC on link-up, not after the DHCPv4 lease. This also restores the + // link-local after a link flap, which clears the IPv6 addresses. + if (esp_err_t ll_err = esp_netif_create_ip6_linklocal(global_eth_component->eth_netif_); ll_err != ESP_OK) { + ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal failed on link-up: %s", esp_err_to_name(ll_err)); + } +#endif /* USE_NETWORK_IPV6 */ break; case ETHERNET_EVENT_DISCONNECTED: event_name = "ETH disconnected"; @@ -791,7 +814,10 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ void *event_data) { ip_event_got_ip6_t *event = (ip_event_got_ip6_t *) event_data; ESP_LOGV(TAG, "[Ethernet event] ETH Got IPv6: " IPV6STR, IPV62STR(event->ip6_info.ip)); - global_eth_component->ipv6_count_ += 1; + // Count the addresses on the interface, not the events: recreating the link-local + // after a link flap fires another event for the same address. + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(global_eth_component->eth_netif_, if_ip6s); #if (USE_NETWORK_MIN_IPV6_ADDR_COUNT > 0) global_eth_component->connected_ = global_eth_component->got_ipv4_address_ && (global_eth_component->ipv6_count_ >= USE_NETWORK_MIN_IPV6_ADDR_COUNT); @@ -806,6 +832,29 @@ void EthernetComponent::got_ip6_event_handler(void *arg, esp_event_base_t event_ } #endif /* USE_NETWORK_IPV6 */ +#if USE_NETWORK_IPV6 +// Create the link-local address unless the interface already has one, including one still in +// duplicate address detection: recreating it would restart DAD. esp_netif_get_ip6_linklocal() +// only reports a preferred address, so ask lwIP for the slot state instead. +esp_err_t EthernetComponent::ensure_ip6_linklocal_() { + if (auto *netif = static_cast(esp_netif_get_netif_impl(this->eth_netif_)); netif != nullptr) { + u8_t state; + { + LwIPLock lock; + state = netif_ip6_addr_state(netif, 0); + } + if (ip6_addr_istentative(state) || ip6_addr_isvalid(state)) { + return ESP_OK; + } + } + esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); + if (err == ESP_OK) { + ESP_LOGD(TAG, "IPv6 link-local address created"); + } + return err; +} +#endif /* USE_NETWORK_IPV6 */ + void EthernetComponent::finish_connect_() { #if USE_NETWORK_IPV6 // Retry IPv6 link-local setup if it failed during initial connect @@ -816,10 +865,7 @@ void EthernetComponent::finish_connect_() { // - Cable unplugged/network interruption (#10705) // We can now retry since we're in CONNECTED state and the interface is definitely up. if (!this->ipv6_setup_done_) { - esp_err_t err = esp_netif_create_ip6_linklocal(this->eth_netif_); - if (err == ESP_OK) { - ESP_LOGD(TAG, "IPv6 link-local address created (retry succeeded)"); - } + this->ensure_ip6_linklocal_(); // Always set the flag to prevent continuous retries // If IPv6 setup fails here with the interface up and stable, it's // likely a persistent issue (IPv6 disabled at router, hardware @@ -833,7 +879,9 @@ void EthernetComponent::finish_connect_() { void EthernetComponent::start_connect_() { global_eth_component->got_ipv4_address_ = false; #if USE_NETWORK_IPV6 - global_eth_component->ipv6_count_ = 0; + // Recount rather than zero: addresses that survive a reconnect are not announced again. + struct esp_ip6_addr if_ip6s[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + global_eth_component->ipv6_count_ = esp_netif_get_all_ip6(this->eth_netif_, if_ip6s); this->ipv6_setup_done_ = false; #endif /* USE_NETWORK_IPV6 */ this->connect_begin_ = millis(); @@ -912,7 +960,7 @@ void EthernetComponent::start_connect_() { // - At bootup when link isn't ready (#10281) // - After disconnection/cable unplugged (#10705) // We'll retry in finish_connect_() if it fails here. - err = esp_netif_create_ip6_linklocal(this->eth_netif_); + err = this->ensure_ip6_linklocal_(); if (err != ESP_OK) { if (err == ESP_ERR_ESP_NETIF_INVALID_PARAMS) { // This is a programming error, not a transient failure diff --git a/tests/components/ethernet/test-ipv6.esp32-idf.yaml b/tests/components/ethernet/test-ipv6.esp32-idf.yaml new file mode 100644 index 0000000000..607e82dfa9 --- /dev/null +++ b/tests/components/ethernet/test-ipv6.esp32-idf.yaml @@ -0,0 +1,12 @@ +network: + enable_ipv6: true + +ethernet: + type: IP101 + mdc_pin: 23 + mdio_pin: 18 + clk: + pin: 0 + mode: CLK_EXT_IN + phy_addr: 1 + power_pin: 5