[mdns] Drive event-driven polling from Ethernet IP state events on RP2040

Extends the WiFi-only listener pattern from the previous commit to also subscribe
to EthernetIPStateListener when Ethernet is configured. RP2040 can run mDNS over a
W5500 ethernet shield without WiFi, and mDNS and WiFi are mutually exclusive on
RP2040 (the framework doesn't support both simultaneously on the CYW43/PIO paths),
so this adds the ethernet-only path without touching the WiFi path.

ESPHome's wifi and ethernet components already publish compatible IP state listener
APIs (`WiFiIPStateListener::on_ip_state` and `EthernetIPStateListener::on_ip_state`
with identical signatures). MDNSComponent multiply-inherits both when available; a
single on_ip_state() override satisfies both vtable entries.

- New `USE_MDNS_WIFI_LISTENER` / `USE_MDNS_ETHERNET_LISTENER` gates control per-
  interface subscription. `USE_MDNS_EVENT_DRIVEN_POLLING` fires if either is
  available.
- Python side now calls `ethernet.request_ethernet_ip_state_listener()` when
  ethernet is in the config (RP2040 only — ESP8266 has no ethernet driver).
- setup() seeds current state for each registered listener so an already-up
  interface still triggers MDNS.begin() + polling window under AFTER_CONNECTION
  priority.

Tests: adds `test-enabled-ethernet.rp2040-ard.yaml` covering the ethernet-only
path. Existing `test-enabled.rp2040-ard.yaml` (WiFi-only) and ESP8266 tests
continue to pass.
This commit is contained in:
J. Nick Koston
2026-04-23 19:41:23 -05:00
parent 5cb258034b
commit ceada86325
6 changed files with 99 additions and 31 deletions
+17 -9
View File
@@ -169,16 +169,24 @@ async def to_code(config):
elif CORE.is_rp2040:
cg.add_library("LEAmDNS", None)
# ESP8266 and RP2040 use a WiFi IP state listener to arm a bounded MDNS.update()
# polling window only while the library is in its probe+announce phase. This
# eliminates the steady-state 50ms interval that ran forever (1200+ dispatches
# per minute) and its scheduler overhead. When WiFi is absent (e.g. an
# ethernet-only RP2040 build) the component falls back to the legacy polling
# loop at MDNS_UPDATE_INTERVAL_MS.
if (CORE.is_esp8266 or CORE.is_rp2040) and "wifi" in CORE.config:
from esphome.components import wifi
# ESP8266 and RP2040 use a network IP state listener to arm a bounded
# MDNS.update() polling window only while the library is in its probe+announce
# phase. This eliminates the steady-state 50ms interval that ran forever
# (1200+ dispatches per minute) and its scheduler overhead.
#
# We subscribe to any listener interface that's configured (WiFi and/or
# Ethernet); the same on_ip_state() override satisfies both because the
# listener signatures match. If neither is available the component falls
# back to the legacy polling loop at MDNS_UPDATE_INTERVAL_MS.
if CORE.is_esp8266 or CORE.is_rp2040:
if "wifi" in CORE.config:
from esphome.components import wifi
wifi.request_wifi_ip_state_listener()
wifi.request_wifi_ip_state_listener()
if CORE.is_rp2040 and "ethernet" in CORE.config:
from esphome.components import ethernet
ethernet.request_ethernet_ip_state_listener()
if CORE.is_esp32:
add_idf_component(name="espressif/mdns", ref="1.11.0")
+21 -7
View File
@@ -6,14 +6,24 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
// Event-driven polling replaces the legacy set_interval() loop on platforms that need
// scheduler-backed MDNS.update() (ESP8266, RP2040). It's enabled when a WiFi IP state
// listener slot is available — the mdns Python to_code() requests one when WiFi is in
// the config. If it's not (e.g. clang-tidy running without full codegen, or an
// ethernet-only RP2040 build), the component falls back to the legacy polling loop.
#if (defined(USE_ESP8266) || defined(USE_RP2040)) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)
// scheduler-backed MDNS.update() (ESP8266, RP2040). It's enabled when at least one
// compatible network IP state listener slot is available — the mdns Python to_code()
// requests a WiFi slot when WiFi is in the config and an Ethernet slot when Ethernet
// is in the config. If neither is available (e.g. clang-tidy running without full
// codegen), the component falls back to the legacy polling loop.
#if (defined(USE_ESP8266) || defined(USE_RP2040)) && \
((defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) || \
(defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS)))
#include "esphome/components/network/ip_address.h"
#include "esphome/components/wifi/wifi_component.h"
#define USE_MDNS_EVENT_DRIVEN_POLLING
#if defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)
#include "esphome/components/wifi/wifi_component.h"
#define USE_MDNS_WIFI_LISTENER
#endif
#if defined(USE_ETHERNET) && defined(USE_ETHERNET_IP_STATE_LISTENERS)
#include "esphome/components/ethernet/ethernet_component.h"
#define USE_MDNS_ETHERNET_LISTENER
#endif
#endif
namespace esphome::mdns {
@@ -58,10 +68,14 @@ struct MDNSService {
};
class MDNSComponent final : public Component
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
#ifdef USE_MDNS_WIFI_LISTENER
,
public wifi::WiFiIPStateListener
#endif
#ifdef USE_MDNS_ETHERNET_LISTENER
,
public ethernet::EthernetIPStateListener
#endif
{
public:
void setup() override;
+4 -2
View File
@@ -7,10 +7,10 @@
#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
#include "mdns_component.h"
#ifdef USE_MDNS_WIFI_LISTENER
#include "esphome/components/wifi/wifi_component.h"
#endif
#include "mdns_component.h"
namespace esphome::mdns {
@@ -50,6 +50,8 @@ void MDNSComponent::setup() {
// on every netif status change (link up, IP up, etc.), so we don't trigger begin()
// or restart here — we just cover the probe+announce window with a bounded polling
// schedule. The listener catches subsequent reconnects and re-arms the window.
// ESP8266 has no ethernet driver in the Arduino build so only the WiFi listener
// branch is reachable here.
wifi::global_wifi_component->add_ip_state_listener(this);
this->start_polling_window_();
#else
+33 -13
View File
@@ -6,9 +6,12 @@
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include "mdns_component.h"
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
#ifdef USE_MDNS_WIFI_LISTENER
#include "esphome/components/wifi/wifi_component.h"
#endif
#ifdef USE_MDNS_ETHERNET_LISTENER
#include "esphome/components/ethernet/ethernet_component.h"
#endif
// Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty.
// Save and restore our definition around the include to avoid a redefinition warning.
@@ -57,19 +60,36 @@ void MDNSComponent::setup() {
//
// Workaround: defer MDNS.begin() and service registration until the network has an IP,
// then call notifyAPChange() on subsequent reconnects to restart mDNS probing and
// announcing — all from main loop context via the WiFiIPStateListener callback so it's
// thread-safe.
// announcing — all from main loop context via the IP state listener callback(s) so
// it's thread-safe. We subscribe to any listener interface that's active (WiFi and/or
// Ethernet); the same on_ip_state() override serves both because the signatures match.
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
#ifdef USE_MDNS_WIFI_LISTENER
wifi::global_wifi_component->add_ip_state_listener(this);
// AFTER_CONNECTION priority means the network may already be up when setup() runs;
// the listener only fires on subsequent state changes, so seed the current state.
const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses();
if (ips[0].is_set()) {
this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0),
wifi::global_wifi_component->get_dns_address(1));
{
const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses();
if (ips[0].is_set()) {
this->on_ip_state(ips, wifi::global_wifi_component->get_dns_address(0),
wifi::global_wifi_component->get_dns_address(1));
}
}
#endif
#ifdef USE_MDNS_ETHERNET_LISTENER
ethernet::global_eth_component->add_ip_state_listener(this);
// Seed current Ethernet state for the same reason — if the interface is already up
// when mdns setup() runs, we need to kick off begin() + polling here.
if (ethernet::global_eth_component->is_connected()) {
const auto ips = ethernet::global_eth_component->get_ip_addresses();
if (ips[0].is_set()) {
this->on_ip_state(ips, network::IPAddress{}, network::IPAddress{});
}
}
#endif
#else
// Fallback (non-WiFi build): poll forever, checking connection state each tick.
// Fallback (no IP state listener available): poll forever, checking connection state
// each tick.
this->set_interval(MDNS_UPDATE_INTERVAL_MS, [this]() {
bool connected = network::is_connected();
if (connected && !this->was_connected_) {
@@ -91,11 +111,11 @@ void MDNSComponent::setup() {
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &,
const network::IPAddress &) {
// ESPHome's WiFiIPStateListener only notifies on IP acquisition (see
// wifi_component_pico_w.cpp), not on IP loss, so every notification represents a
// fresh IP that needs a probe/announce cycle. The library's internal
// LwipIntf::stateUpCB is stubbed out on arduino-pico (see setup()), so we drive
// begin/restart ourselves from this callback.
// Both WiFi and Ethernet IP state listeners only notify on IP acquisition (see
// wifi_component_pico_w.cpp and ethernet_component.cpp), not on IP loss, so every
// notification represents a fresh IP that needs a probe/announce cycle. The library's
// internal LwipIntf::stateUpCB is stubbed out on arduino-pico (see setup()), so we
// drive begin/restart ourselves from this callback.
if (!ips[0].is_set()) {
return;
}
@@ -0,0 +1,23 @@
ethernet:
type: W5500
clk_pin: 18
mosi_pin: 19
miso_pin: 16
cs_pin: 17
interrupt_pin: 21
reset_pin: 20
manual_ip:
static_ip: 192.168.178.56
gateway: 192.168.178.1
subnet: 255.255.255.0
domain: .local
mac_address: "02:AA:BB:CC:DD:01"
mdns:
disabled: false
services:
- service: _test_service
protocol: _tcp
port: 8888
txt:
static_string: Anything
@@ -0,0 +1 @@
<<: !include common-enabled-ethernet.yaml