From bf7083c50100b2b1ba83d980e7be6d34fb549b8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:19:29 -0500 Subject: [PATCH 01/13] [mdns] Drive MDNS.update() polling from WiFi IP state events on ESP8266/RP2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Arduino LEAmDNS library only has meaningful timer-driven work during the ~9 s probe+announce phase following MDNS.begin() or _restart(): 3 probes at 250 ms + 8 announcements at 1000 ms, then all internal timeouts are set to resetToNeverExpires(). Incoming packets are handled via the lwIP UDP RX callback independently of update(). ESPHome does not issue service queries, so the query cache path is always a no-op. The previous implementation ran set_interval(50) forever — ~20 dispatches/sec, 1200+ scheduler calls per minute of pure overhead once probing completed. This PR arms a bounded MDNS_POLL_WINDOW_MS (12 s) polling window driven by WiFiIPStateListener events. A fresh window covers each probe/announce cycle (boot, wifi reconnect, or internal _restart() triggered by netif changes); outside the window there are zero scheduler dispatches and the scheduler heap contains no mDNS items. ESP8266 is WiFi-only in the Arduino build so the path is unconditional. RP2040 supports W5500 ethernet without WiFi, so the listener is requested only when WiFi is in the config; ethernet-only RP2040 builds keep the legacy polling loop. Scheduler IDs use uint32_t (MDNS_POLL_ID / MDNS_POLL_STOP_ID) to avoid the name-hash/strcmp cost of string-named timers on the cancel + re-arm paths. --- esphome/components/mdns/__init__.py | 14 ++++ esphome/components/mdns/mdns_component.cpp | 14 ++++ esphome/components/mdns/mdns_component.h | 82 ++++++++++++++++------ esphome/components/mdns/mdns_esp8266.cpp | 28 ++++++-- esphome/components/mdns/mdns_rp2040.cpp | 44 +++++++++++- 5 files changed, 153 insertions(+), 29 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 7c36295e8d9..3e07c91d53d 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -169,6 +169,20 @@ 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. + # + # ESP8266 has no ethernet driver in the Arduino build, so it's always a WiFi + # device — the listener is unconditional. RP2040 supports the W5500 ethernet + # shield without WiFi, so the listener is requested only when WiFi is present; + # ethernet-only RP2040 builds fall back to the legacy polling loop. + if CORE.is_esp8266 or (CORE.is_rp2040 and "wifi" in CORE.config): + from esphome.components import wifi + + wifi.request_wifi_ip_state_listener() + if CORE.is_esp32: add_idf_component(name="espressif/mdns", ref="1.11.0") diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index e05373ac5d3..88d0b1be31a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -190,6 +190,20 @@ void MDNSComponent::compile_records_(StaticVectorcancel_polling_window_(); + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} + +void MDNSComponent::cancel_polling_window_() { + this->cancel_interval(MDNS_POLL_ID); + this->cancel_timeout(MDNS_POLL_STOP_ID); +} +#endif + void MDNSComponent::dump_config() { ESP_LOGCONFIG(TAG, "mDNS:\n" diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index adf88a9cf16..6f6e22ee625 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,9 +5,27 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" +// Event-driven polling is used whenever the scheduler-backed MDNS.update() interval +// needs to be gated on network state (ESP8266, or RP2040 with WiFi). ESP8266 mDNS +// always runs over WiFi — there is no ethernet driver for ESP8266 in the Arduino +// build — so this path is unconditional on ESP8266. RP2040 can run mDNS over the +// W5500 ethernet shield without WiFi, so it falls back to the legacy polling loop +// when WiFi is absent. +#if defined(USE_ESP8266) || (defined(USE_RP2040) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) +#include "esphome/components/network/ip_address.h" +#include "esphome/components/wifi/wifi_component.h" +#define USE_MDNS_EVENT_DRIVEN_POLLING +#endif namespace esphome::mdns { +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +/// Call MDNS.update() on the target platform. Defined in the per-platform cpp file so +/// the shared component code can drive the polling window without pulling in +/// platform-specific mDNS headers. +void mdns_pump_update(); +#endif + // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) struct MDNSString; @@ -40,33 +58,40 @@ struct MDNSService { FixedVector txt_records; }; -class MDNSComponent final : public Component { +class MDNSComponent final : public Component +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + , + public wifi::WiFiIPStateListener +#endif +{ public: void setup() override; void dump_config() override; - // Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040). +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + // On ESP8266 and RP2040, MDNS.update() calls _process(true) which only manages + // timer-driven state machines (probe/announce timeouts and service query cache TTLs). + // Incoming mDNS packets are handled independently via the lwIP onRx UDP callback and + // are NOT affected by how often update() is called. // - // On these platforms, MDNS.update() calls _process(true) which only manages timer-driven - // state machines (probe/announce timeouts and service query cache TTLs). Incoming mDNS - // packets are handled independently via the lwIP onRx UDP callback and are NOT affected - // by how often update() is called. + // The work has a bounded lifetime: after MDNS.begin() (or _restart() triggered by a + // network interface change) the library sends 3 probes 250ms apart followed by 8 + // announcements 1000ms apart, after which all internal timeouts are set to + // resetToNeverExpires(). ESPHome does not issue mDNS service queries, so the service + // query cache is always empty. Every subsequent update() call is pure overhead. // - // The shortest internal timer is the 250ms probe interval (RFC 6762 Section 8.1). - // Announcement intervals are 1000ms and cache TTL checks are on the order of seconds - // to minutes. A 50ms polling interval provides sufficient resolution for all timers - // while completely removing mDNS from the per-iteration loop list. - // - // In steady state (after the ~8 second boot probe/announce phase completes), update() - // checks timers that are set to never expire, making every call pure overhead. - // - // Tasmota uses a 50ms main loop cycle with mDNS working correctly, confirming this - // interval is safe in production. - // - // By using set_interval() instead of overriding loop(), the component is excluded from - // the main loop list via has_overridden_loop(), eliminating all per-iteration overhead - // including virtual dispatch. + // Instead of polling forever, we arm a bounded polling window driven by + // WiFiIPStateListener events. A fresh window covers each probe/announce cycle that + // follows initial connect or reconnect; outside the window no update() calls occur. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; + // Boot probe+announce phase is ~9.0s (3*250ms probes + 8*1000ms announces). Window + // includes margin for the initial `rand() % MDNS_PROBE_DELAY` jitter and for the + // debounced internal restart triggered by netif status changes on ESP8266. + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; + // Scheduler IDs (uint32_t variants avoid name hashing/strcmp on cancel paths) + static constexpr uint32_t MDNS_POLL_ID = 0; + static constexpr uint32_t MDNS_POLL_STOP_ID = 1; +#endif float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } #ifdef USE_MDNS_EXTRA_SERVICES @@ -87,7 +112,20 @@ class MDNSComponent final : public Component { } #endif +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; +#endif + protected: +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING + /// Arm a bounded polling window so MDNS.update() runs at MDNS_UPDATE_INTERVAL_MS + /// for MDNS_POLL_WINDOW_MS. A subsequent call replaces the previous window. + void start_polling_window_(); + /// Cancel any active polling window. + void cancel_polling_window_(); + bool ip_was_up_{false}; +#endif /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); @@ -131,8 +169,10 @@ class MDNSComponent final : public Component { StaticVector services_{}; #endif #ifdef USE_RP2040 - bool was_connected_{false}; bool initialized_{false}; +#if !defined(USE_MDNS_EVENT_DRIVEN_POLLING) + bool was_connected_{false}; +#endif #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 70c614f8d34..aa8558dac1b 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -7,6 +7,7 @@ #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#include "esphome/components/wifi/wifi_component.h" #include "mdns_component.h" namespace esphome::mdns { @@ -36,13 +37,30 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); - // Schedule MDNS.update() via set_interval() instead of overriding loop(). - // This removes the component from the per-iteration loop list entirely, - // eliminating virtual dispatch overhead on every main loop cycle. - // See MDNS_UPDATE_INTERVAL_MS comment in mdns_component.h for safety analysis. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + // Arduino LEAmDNS registers its own LwipIntf::statusChangeCB that calls _restart() + // 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. + wifi::global_wifi_component->add_ip_state_listener(this); + this->start_polling_window_(); +} + +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + const bool has_ip = ips[0].is_set(); + if (has_ip && !this->ip_was_up_) { + // IP came up. LEAmDNS's internal lwIP callback will call _restart() shortly after + // (if it hasn't already) — arm the polling window so the probe/announce phase is + // serviced regardless of our relative timing vs the library's callback. + this->start_polling_window_(); + } else if (!has_ip && this->ip_was_up_) { + this->cancel_polling_window_(); + } + this->ip_was_up_ = has_ip; } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 64b603030c8..44db320dbb7 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,6 +6,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +#include "esphome/components/wifi/wifi_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. @@ -40,6 +43,10 @@ static void register_rp2040(MDNSComponent *, StaticVectoradd_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)); + } +#else + // Fallback (non-WiFi build): 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_) { @@ -67,8 +85,28 @@ void MDNSComponent::setup() { MDNS.update(); } }); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING +void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, + const network::IPAddress &) { + const bool has_ip = ips[0].is_set(); + if (has_ip && !this->ip_was_up_) { + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + this->start_polling_window_(); + } else if (!has_ip && this->ip_was_up_) { + this->cancel_polling_window_(); + } + this->ip_was_up_ = has_ip; +} +#endif + void MDNSComponent::on_shutdown() { MDNS.close(); delay(40); From 25601434d9739f8eb8101b5ad6775d789105914c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:26:01 -0500 Subject: [PATCH 02/13] [mdns] Simplify listener logic: always re-arm on IP notify, drop transition tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events), not on IP loss — on disconnect, only the WiFiConnectStateListener's disconnect path fires (see wifi_component_esp8266.cpp:952-962 and wifi_component_pico_w.cpp:340). The previous commit's `ip_was_up_` transition tracking was broken: after the first IP-up event, `ip_was_up_` latched to true and never reset, so subsequent disconnect+reconnect cycles would see has_ip=true && ip_was_up_=true and skip re-arming the polling window. Fix: always re-arm on any IP notification. The scheduler's set_interval/set_timeout with a uint32_t ID already performs atomic cancel-and-add for matching IDs (Scheduler::set_timer_common_ line 232-234), so start_polling_window_ is idempotent and needs no explicit cancel. Drop the ip_was_up_ field and cancel_polling_window_ helper entirely. The !has_ip branch (cancel on disconnect) was dead code: it would never fire because the listener doesn't receive disconnect events. Removing it; the polling window will naturally expire on its own (at most 12s of harmless MDNS.update() calls during a disconnect that isn't followed by reconnect within the window). --- esphome/components/mdns/mdns_component.cpp | 10 +++------ esphome/components/mdns/mdns_component.h | 3 --- esphome/components/mdns/mdns_esp8266.cpp | 14 +++++------- esphome/components/mdns/mdns_rp2040.cpp | 26 ++++++++++++---------- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 88d0b1be31a..c80b9224f4e 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -192,16 +192,12 @@ void MDNSComponent::compile_records_(StaticVectorcancel_polling_window_(); + // Re-arming replaces the previous window. The scheduler's set_interval/set_timeout + // with a uint32_t ID already does atomic cancel-and-add for items sharing that ID + // (see Scheduler::set_timer_common_), so no explicit cancel is needed. this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } - -void MDNSComponent::cancel_polling_window_() { - this->cancel_interval(MDNS_POLL_ID); - this->cancel_timeout(MDNS_POLL_STOP_ID); -} #endif void MDNSComponent::dump_config() { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 6f6e22ee625..7be2f4665ec 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -122,9 +122,6 @@ class MDNSComponent final : public Component /// Arm a bounded polling window so MDNS.update() runs at MDNS_UPDATE_INTERVAL_MS /// for MDNS_POLL_WINDOW_MS. A subsequent call replaces the previous window. void start_polling_window_(); - /// Cancel any active polling window. - void cancel_polling_window_(); - bool ip_was_up_{false}; #endif /// Helper to set up services and MAC buffers, then call platform-specific registration using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index aa8558dac1b..d6254de5681 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -51,16 +51,14 @@ void MDNSComponent::setup() { void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - const bool has_ip = ips[0].is_set(); - if (has_ip && !this->ip_was_up_) { - // IP came up. LEAmDNS's internal lwIP callback will call _restart() shortly after - // (if it hasn't already) — arm the polling window so the probe/announce phase is - // serviced regardless of our relative timing vs the library's callback. + // ESPHome's WiFiIPStateListener only notifies on IP acquisition (GOT_IP events on + // ESP8266 — see wifi_component_esp8266.cpp), not on IP loss, so every notification + // represents a fresh IP that the LEAmDNS library's lwIP callback will trigger a + // _restart() for. Always re-arm the polling window — start_polling_window_() is + // idempotent (scheduler does atomic cancel-and-add on matching IDs). + if (ips[0].is_set()) { this->start_polling_window_(); - } else if (!has_ip && this->ip_was_up_) { - this->cancel_polling_window_(); } - this->ip_was_up_ = has_ip; } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 44db320dbb7..cc2d81fe24a 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -91,19 +91,21 @@ void MDNSComponent::setup() { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - const bool has_ip = ips[0].is_set(); - if (has_ip && !this->ip_was_up_) { - if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); - this->initialized_ = true; - } else { - MDNS.notifyAPChange(); - } - this->start_polling_window_(); - } else if (!has_ip && this->ip_was_up_) { - this->cancel_polling_window_(); + // 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. + if (!ips[0].is_set()) { + return; } - this->ip_was_up_ = has_ip; + if (!this->initialized_) { + this->setup_buffers_and_register_(register_rp2040); + this->initialized_ = true; + } else { + MDNS.notifyAPChange(); + } + this->start_polling_window_(); } #endif From 5cb258034bc8cc9b683954e1cbb4fe67435272d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:36:11 -0500 Subject: [PATCH 03/13] [mdns] Fall back to legacy polling when WiFi IP state listener isn't available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clang-tidy CI compiles the source with the esp8266-arduino-tidy env's raw build flags (-DUSE_ESP8266 only) without running the Python codegen that adds USE_WIFI_IP_STATE_LISTENERS. The previous guard assumed USE_WIFI_IP_STATE_LISTENERS would always be defined on ESP8266, so clang-tidy failed with 'no member named add_ip_state_listener in wifi::WiFiComponent'. Gate USE_MDNS_EVENT_DRIVEN_POLLING on USE_WIFI + USE_WIFI_IP_STATE_LISTENERS for both ESP8266 and RP2040. When either is absent, fall back to the pre-PR behaviour: set_interval(MDNS_UPDATE_INTERVAL_MS, MDNS.update) running forever. Python side already only requests the listener slot when WiFi is in the config, so real production builds on ESP8266 (which always have WiFi) continue to use the event-driven path — only the clang-tidy static-analysis build takes the fallback. --- esphome/components/mdns/__init__.py | 11 ++++------- esphome/components/mdns/mdns_component.h | 22 +++++++++++----------- esphome/components/mdns/mdns_esp8266.cpp | 12 ++++++++++++ 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 3e07c91d53d..dc86a314da9 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -172,13 +172,10 @@ async def to_code(config): # 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. - # - # ESP8266 has no ethernet driver in the Arduino build, so it's always a WiFi - # device — the listener is unconditional. RP2040 supports the W5500 ethernet - # shield without WiFi, so the listener is requested only when WiFi is present; - # ethernet-only RP2040 builds fall back to the legacy polling loop. - if CORE.is_esp8266 or (CORE.is_rp2040 and "wifi" in CORE.config): + # 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 wifi.request_wifi_ip_state_listener() diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 7be2f4665ec..32a365bb625 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,13 +5,12 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" -// Event-driven polling is used whenever the scheduler-backed MDNS.update() interval -// needs to be gated on network state (ESP8266, or RP2040 with WiFi). ESP8266 mDNS -// always runs over WiFi — there is no ethernet driver for ESP8266 in the Arduino -// build — so this path is unconditional on ESP8266. RP2040 can run mDNS over the -// W5500 ethernet shield without WiFi, so it falls back to the legacy polling loop -// when WiFi is absent. -#if defined(USE_ESP8266) || (defined(USE_RP2040) && defined(USE_WIFI) && defined(USE_WIFI_IP_STATE_LISTENERS)) +// 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) #include "esphome/components/network/ip_address.h" #include "esphome/components/wifi/wifi_component.h" #define USE_MDNS_EVENT_DRIVEN_POLLING @@ -68,7 +67,6 @@ class MDNSComponent final : public Component void setup() override; void dump_config() override; -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // On ESP8266 and RP2040, MDNS.update() calls _process(true) which only manages // timer-driven state machines (probe/announce timeouts and service query cache TTLs). // Incoming mDNS packets are handled independently via the lwIP onRx UDP callback and @@ -80,10 +78,12 @@ class MDNSComponent final : public Component // resetToNeverExpires(). ESPHome does not issue mDNS service queries, so the service // query cache is always empty. Every subsequent update() call is pure overhead. // - // Instead of polling forever, we arm a bounded polling window driven by - // WiFiIPStateListener events. A fresh window covers each probe/announce cycle that - // follows initial connect or reconnect; outside the window no update() calls occur. + // When USE_MDNS_EVENT_DRIVEN_POLLING is defined we arm a bounded polling window from + // WiFiIPStateListener events so update() only runs during the probe+announce phase; + // outside that window no update() calls occur. Otherwise (fallback), we poll at + // MDNS_UPDATE_INTERVAL_MS forever. static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // Boot probe+announce phase is ~9.0s (3*250ms probes + 8*1000ms announces). Window // includes margin for the initial `rand() % MDNS_PROBE_DELAY` jitter and for the // debounced internal restart triggered by netif status changes on ESP8266. diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index d6254de5681..072be497dbe 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -7,7 +7,9 @@ #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING #include "esphome/components/wifi/wifi_component.h" +#endif #include "mdns_component.h" namespace esphome::mdns { @@ -37,18 +39,27 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // Arduino LEAmDNS registers its own LwipIntf::statusChangeCB that calls _restart() // 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. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); +#else + // Fallback for builds without a WiFi IP state listener (e.g. clang-tidy without + // codegen defines). Matches the pre-PR behaviour: poll forever at 50ms. + this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); +#endif } +#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 (GOT_IP events on @@ -60,6 +71,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); From ceada8632553398018656a273a8a8f7a3e598b2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:41:23 -0500 Subject: [PATCH 04/13] [mdns] Drive event-driven polling from Ethernet IP state events on RP2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- esphome/components/mdns/__init__.py | 26 +++++++---- esphome/components/mdns/mdns_component.h | 28 ++++++++--- esphome/components/mdns/mdns_esp8266.cpp | 6 ++- esphome/components/mdns/mdns_rp2040.cpp | 46 +++++++++++++------ .../mdns/common-enabled-ethernet.yaml | 23 ++++++++++ .../test-enabled-ethernet.rp2040-ard.yaml | 1 + 6 files changed, 99 insertions(+), 31 deletions(-) create mode 100644 tests/components/mdns/common-enabled-ethernet.yaml create mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index dc86a314da9..db915e5f895 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -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") diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 32a365bb625..ea74e228186 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -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; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 072be497dbe..c7737bcb141 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -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 diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index cc2d81fe24a..8a6a36096f0 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -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; } diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-enabled-ethernet.yaml new file mode 100644 index 00000000000..bfa9321d436 --- /dev/null +++ b/tests/components/mdns/common-enabled-ethernet.yaml @@ -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 diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml new file mode 100644 index 00000000000..f84a0bc276b --- /dev/null +++ b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common-enabled-ethernet.yaml From 09fe59c5ccdb724838273ce2242d27b220bceea2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:57:48 -0500 Subject: [PATCH 05/13] [mdns] Drop fallback paths, collapse everything under USE_MDNS_EVENT_DRIVEN_POLLING MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mDNS on ESP8266/RP2040 always runs over a network interface (WiFi on ESP8266; WiFi or W5500/etc. ethernet on RP2040), and every such interface already publishes an IP state listener in tree. USE_MDNS_EVENT_DRIVEN_POLLING is therefore always defined in production, so the fallback set_interval() paths in both platform files and the was_connected_ bookkeeping are dead code. Also drop the ethernet-specific test — the existing wifi and ethernet+mdns combos are already covered by the mdns test fixtures paired with their network component tests. Trim redundant comments throughout: the header now documents the ~9s probe/announce window and why update() can stop afterward in a few lines instead of a full essay; platform files keep only the non-obvious bits (why RP2040 needs to drive begin()/notifyAPChange() itself, why re-arming on any listener notification is correct). --- esphome/components/mdns/__init__.py | 12 +--- esphome/components/mdns/mdns_component.cpp | 5 +- esphome/components/mdns/mdns_component.h | 47 ++++----------- esphome/components/mdns/mdns_esp8266.cpp | 27 ++------- esphome/components/mdns/mdns_rp2040.cpp | 58 +++---------------- .../mdns/common-enabled-ethernet.yaml | 23 -------- .../test-enabled-ethernet.rp2040-ard.yaml | 1 - 7 files changed, 29 insertions(+), 144 deletions(-) delete mode 100644 tests/components/mdns/common-enabled-ethernet.yaml delete mode 100644 tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index db915e5f895..b15a778b59f 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -169,15 +169,9 @@ async def to_code(config): elif CORE.is_rp2040: cg.add_library("LEAmDNS", None) - # 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. + # Subscribe to the network IP state listener(s) so MDNS.update() is only + # scheduled during the probe+announce phase. Same on_ip_state() override + # serves both WiFi and Ethernet (signatures match). if CORE.is_esp8266 or CORE.is_rp2040: if "wifi" in CORE.config: from esphome.components import wifi diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index c80b9224f4e..80e12ade717 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -192,9 +192,8 @@ void MDNSComponent::compile_records_(StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index ea74e228186..0e848ba975d 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -5,12 +5,8 @@ #include "esphome/core/automation.h" #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 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. +// On ESP8266 and RP2040 the scheduler-backed MDNS.update() polling window is armed by +// IP state listener events on whichever network interface is configured. #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))) @@ -29,9 +25,8 @@ namespace esphome::mdns { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING -/// Call MDNS.update() on the target platform. Defined in the per-platform cpp file so -/// the shared component code can drive the polling window without pulling in -/// platform-specific mDNS headers. +/// Platform-specific MDNS.update() trampoline. Defined in mdns_.cpp so the +/// shared code can schedule it without including the platform's mDNS header. void mdns_pump_update(); #endif @@ -81,28 +76,13 @@ class MDNSComponent final : public Component void setup() override; void dump_config() override; - // On ESP8266 and RP2040, MDNS.update() calls _process(true) which only manages - // timer-driven state machines (probe/announce timeouts and service query cache TTLs). - // Incoming mDNS packets are handled independently via the lwIP onRx UDP callback and - // are NOT affected by how often update() is called. - // - // The work has a bounded lifetime: after MDNS.begin() (or _restart() triggered by a - // network interface change) the library sends 3 probes 250ms apart followed by 8 - // announcements 1000ms apart, after which all internal timeouts are set to - // resetToNeverExpires(). ESPHome does not issue mDNS service queries, so the service - // query cache is always empty. Every subsequent update() call is pure overhead. - // - // When USE_MDNS_EVENT_DRIVEN_POLLING is defined we arm a bounded polling window from - // WiFiIPStateListener events so update() only runs during the probe+announce phase; - // outside that window no update() calls occur. Otherwise (fallback), we poll at - // MDNS_UPDATE_INTERVAL_MS forever. - static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; #ifdef USE_MDNS_EVENT_DRIVEN_POLLING - // Boot probe+announce phase is ~9.0s (3*250ms probes + 8*1000ms announces). Window - // includes margin for the initial `rand() % MDNS_PROBE_DELAY` jitter and for the - // debounced internal restart triggered by netif status changes on ESP8266. - static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; - // Scheduler IDs (uint32_t variants avoid name hashing/strcmp on cancel paths) + // LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes + + // 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires() + // and update() becomes pure overhead. We arm a bounded polling window from IP state + // listener events so update() runs only during that phase. + static constexpr uint32_t MDNS_UPDATE_INTERVAL_MS = 50; + static constexpr uint32_t MDNS_POLL_WINDOW_MS = 12000; // ~9s phase + jitter/restart margin static constexpr uint32_t MDNS_POLL_ID = 0; static constexpr uint32_t MDNS_POLL_STOP_ID = 1; #endif @@ -133,8 +113,8 @@ class MDNSComponent final : public Component protected: #ifdef USE_MDNS_EVENT_DRIVEN_POLLING - /// Arm a bounded polling window so MDNS.update() runs at MDNS_UPDATE_INTERVAL_MS - /// for MDNS_POLL_WINDOW_MS. A subsequent call replaces the previous window. + /// Arm a fresh MDNS_POLL_WINDOW_MS polling window. Idempotent — re-arming replaces + /// the previous window via the scheduler's atomic cancel-and-add on matching IDs. void start_polling_window_(); #endif /// Helper to set up services and MAC buffers, then call platform-specific registration @@ -181,9 +161,6 @@ class MDNSComponent final : public Component #endif #ifdef USE_RP2040 bool initialized_{false}; -#if !defined(USE_MDNS_EVENT_DRIVEN_POLLING) - bool was_connected_{false}; -#endif #endif void compile_records_(StaticVector &services, char *mac_address_buf); }; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index c7737bcb141..d1ffee6e61a 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,9 +8,7 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" -#ifdef USE_MDNS_WIFI_LISTENER #include "esphome/components/wifi/wifi_component.h" -#endif namespace esphome::mdns { @@ -39,41 +37,24 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING - // Arduino LEAmDNS registers its own LwipIntf::statusChangeCB that calls _restart() - // 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. + // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we only + // need to arm the polling window around the initial probe/announce and each reconnect. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); -#else - // Fallback for builds without a WiFi IP state listener (e.g. clang-tidy without - // codegen defines). Matches the pre-PR behaviour: poll forever at 50ms. - this->set_interval(MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); -#endif } -#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 (GOT_IP events on - // ESP8266 — see wifi_component_esp8266.cpp), not on IP loss, so every notification - // represents a fresh IP that the LEAmDNS library's lwIP callback will trigger a - // _restart() for. Always re-arm the polling window — start_polling_window_() is - // idempotent (scheduler does atomic cancel-and-add on matching IDs). + // IP listener only fires on acquisition (not loss), so any notification is a fresh + // IP worth re-arming for. start_polling_window_() is idempotent. if (ips[0].is_set()) { this->start_polling_window_(); } } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 8a6a36096f0..1ecadea36e6 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -14,7 +14,6 @@ #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. #pragma push_macro("IRAM_ATTR") #undef IRAM_ATTR #include @@ -26,10 +25,7 @@ static void register_rp2040(MDNSComponent *, StaticVectoradd_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. + // AFTER_CONNECTION priority means the network may already be up; the listener only + // fires on subsequent changes, so seed the current state. { const auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); if (ips[0].is_set()) { @@ -78,8 +63,6 @@ void MDNSComponent::setup() { #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()) { @@ -87,35 +70,11 @@ void MDNSComponent::setup() { } } #endif -#else - // 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_) { - if (!this->initialized_) { - this->setup_buffers_and_register_(register_rp2040); - this->initialized_ = true; - } else { - MDNS.notifyAPChange(); - } - } - this->was_connected_ = connected; - if (this->initialized_) { - MDNS.update(); - } - }); -#endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { - // 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. + // Listener only fires on IP acquisition (not loss); every event is a fresh IP. if (!ips[0].is_set()) { return; } @@ -127,7 +86,6 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: } this->start_polling_window_(); } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/tests/components/mdns/common-enabled-ethernet.yaml b/tests/components/mdns/common-enabled-ethernet.yaml deleted file mode 100644 index bfa9321d436..00000000000 --- a/tests/components/mdns/common-enabled-ethernet.yaml +++ /dev/null @@ -1,23 +0,0 @@ -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 diff --git a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml b/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml deleted file mode 100644 index f84a0bc276b..00000000000 --- a/tests/components/mdns/test-enabled-ethernet.rp2040-ard.yaml +++ /dev/null @@ -1 +0,0 @@ -<<: !include common-enabled-ethernet.yaml From 6938f2b40008c27328b3e4369ebb6d4f28bd3885 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:06:14 -0500 Subject: [PATCH 06/13] [mdns] Gate listener code under USE_MDNS_EVENT_DRIVEN_POLLING for clang-tidy clang-tidy compiles mdns_esp8266.cpp / mdns_rp2040.cpp with only the tidy env's raw build flags, without the Python codegen defines (USE_WIFI_IP_STATE_LISTENERS, USE_ETHERNET_IP_STATE_LISTENERS, USE_MDNS_EVENT_DRIVEN_POLLING). Wrap all listener-specific code paths under USE_MDNS_EVENT_DRIVEN_POLLING so tidy sees a compilable translation unit with an empty setup() instead of unresolved members on WiFiComponent / MDNSComponent. Production builds always have the listener defines via the mdns Python to_code()'s wifi.request_wifi_ip_state_listener() / ethernet.request_ethernet_ip_state_listener() calls, so this is tidy-only dead code at runtime. --- esphome/components/mdns/mdns_esp8266.cpp | 8 ++++++++ esphome/components/mdns/mdns_rp2040.cpp | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index d1ffee6e61a..bc121542583 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,7 +8,9 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING #include "esphome/components/wifi/wifi_component.h" +#endif namespace esphome::mdns { @@ -37,16 +39,21 @@ static void register_esp8266(MDNSComponent *, StaticVectorsetup_buffers_and_register_(register_esp8266); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we only // need to arm the polling window around the initial probe/announce and each reconnect. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh @@ -55,6 +62,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 1ecadea36e6..1b3c13178d7 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -42,7 +42,9 @@ static void register_rp2040(MDNSComponent *, StaticVectorstart_polling_window_(); } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); From ae414275237ec29f6783900954ff44a430c5cf51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:11:48 -0500 Subject: [PATCH 07/13] [mdns] Enforce network-interface availability at config time on ESP8266/RP2040 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review feedback: if someone enables mdns on ESP8266 or RP2040 without wifi (or ethernet on RP2040), the listener-based setup() is a no-op and the user sees a silent failure rather than a helpful error. FINAL_VALIDATE_SCHEMA rejects mdns on these platforms when no compatible network component is present, naming the specific options that would satisfy the requirement. The existing DEPENDENCIES = ["network"] covers most misconfigurations indirectly, but an explicit network: alone (without wifi or ethernet) slips past that check — now it fails with a clear message. --- esphome/components/mdns/__init__.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index b15a778b59f..e7e1323f402 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -14,6 +14,7 @@ from esphome.const import ( from esphome.core import CORE, Lambda, coroutine_with_priority from esphome.coroutine import CoroPriority from esphome.cpp_generator import LambdaExpression +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@esphome/core"] @@ -61,6 +62,29 @@ def _consume_mdns_sockets(config: ConfigType) -> ConfigType: return config +def _require_network_interface(config: ConfigType) -> ConfigType: + """Require a network interface for mDNS on Arduino/LEAmDNS platforms. + + On ESP8266 and RP2040 the C++ implementation needs at least one IP state + listener (WiFi on ESP8266; WiFi or Ethernet on RP2040) to arm its polling + window. Reject at config time rather than silently producing a component + that never initializes. + """ + if config.get(CONF_DISABLED): + return config + if not CORE.using_arduino or not (CORE.is_esp8266 or CORE.is_rp2040): + return config + full_config = fv.full_config.get() + has_wifi = "wifi" in full_config + has_ethernet = CORE.is_rp2040 and "ethernet" in full_config + if not (has_wifi or has_ethernet): + options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" + raise cv.Invalid( + f"mdns on this platform requires a network interface — add a {options} component to your configuration." + ) + return config + + CONFIG_SCHEMA = cv.All( cv.Schema( { @@ -74,6 +98,9 @@ CONFIG_SCHEMA = cv.All( ) +FINAL_VALIDATE_SCHEMA = _require_network_interface + + def mdns_txt_record(key: str, value: str) -> cg.RawExpression: """Create a mDNS TXT record. From 4f8cfff4accdf8724693728932ede3170c9e6b84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:34:29 -0500 Subject: [PATCH 08/13] [mdns] Address review feedback: tighten guards, comments, validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop redundant wifi_component.h / ethernet_component.h includes from the platform .cpp files — mdns_component.h already pulls them in transitively under their listener defines. - Guard initialized_ with USE_RP2040 && USE_MDNS_EVENT_DRIVEN_POLLING instead of USE_RP2040 alone, making the coupling with the listener-driven path explicit. - Short comment on mdns_pump_update noting ODR is preserved by FILTER_SOURCE_FILES compiling exactly one platform cpp per build. - Inline comment on ESP8266 setup() noting AFTER_CONNECTION priority is why the unconditional start_polling_window_() is safe. - Collapse the disabled/platform early-return in _require_network_interface; drop the redundant CORE.using_arduino check (ESP8266/RP2040 are always Arduino). - Add USE_MDNS_EVENT_DRIVEN_POLLING and USE_MDNS_WIFI_LISTENER to defines.h for static-analysis discoverability (ethernet listener is mutually exclusive with wifi on these platforms, so one representative is enough). --- esphome/components/mdns/__init__.py | 4 +--- esphome/components/mdns/mdns_component.h | 7 ++++--- esphome/components/mdns/mdns_esp8266.cpp | 10 +++++----- esphome/components/mdns/mdns_rp2040.cpp | 8 ++------ esphome/core/defines.h | 2 ++ 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e7e1323f402..741669280c8 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -70,9 +70,7 @@ def _require_network_interface(config: ConfigType) -> ConfigType: window. Reject at config time rather than silently producing a component that never initializes. """ - if config.get(CONF_DISABLED): - return config - if not CORE.using_arduino or not (CORE.is_esp8266 or CORE.is_rp2040): + if config.get(CONF_DISABLED) or not (CORE.is_esp8266 or CORE.is_rp2040): return config full_config = fv.full_config.get() has_wifi = "wifi" in full_config diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 0e848ba975d..95bf2bf27fa 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -25,8 +25,8 @@ namespace esphome::mdns { #ifdef USE_MDNS_EVENT_DRIVEN_POLLING -/// Platform-specific MDNS.update() trampoline. Defined in mdns_.cpp so the -/// shared code can schedule it without including the platform's mDNS header. +/// MDNS.update() trampoline. Defined in exactly one mdns_.cpp per build +/// (FILTER_SOURCE_FILES in __init__.py enforces this), so ODR is preserved. void mdns_pump_update(); #endif @@ -159,7 +159,8 @@ class MDNSComponent final : public Component #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif -#ifdef USE_RP2040 +#if defined(USE_RP2040) && defined(USE_MDNS_EVENT_DRIVEN_POLLING) + // RP2040 defers MDNS.begin() until the first IP-up event; this tracks that. bool initialized_{false}; #endif void compile_records_(StaticVector &services, char *mac_address_buf); diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index bc121542583..d77d9685dd8 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -8,9 +8,8 @@ #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "mdns_component.h" -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING -#include "esphome/components/wifi/wifi_component.h" -#endif +// wifi_component.h is pulled in transitively by mdns_component.h when +// USE_MDNS_WIFI_LISTENER is defined. namespace esphome::mdns { @@ -46,8 +45,9 @@ void mdns_pump_update() { MDNS.update(); } void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); #ifdef USE_MDNS_EVENT_DRIVEN_POLLING - // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we only - // need to arm the polling window around the initial probe/announce and each reconnect. + // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just + // arm the window around the initial probe/announce and each reconnect. Unconditional + // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index 1b3c13178d7..a8b0af4ad62 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -6,12 +6,8 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "mdns_component.h" -#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 +// wifi_component.h / ethernet_component.h are pulled in transitively by +// mdns_component.h when their respective listener defines are active. // Arduino-Pico's PolledTimeout.h (pulled in by ESP8266mDNS.h) redefines IRAM_ATTR to empty. #pragma push_macro("IRAM_ATTR") diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 80247f69da1..53b78a97df5 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -111,6 +111,8 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 +#define USE_MDNS_EVENT_DRIVEN_POLLING +#define USE_MDNS_WIFI_LISTENER #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define SERIAL_PROXY_COUNT 2 From d32db7904a9f53dd697d5e8504dee1d2da968cef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:40:38 -0500 Subject: [PATCH 09/13] [mdns] Inline MDNS.update() as a lambda in each platform's start_polling_window_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the mdns_pump_update() trampoline. It existed because start_polling_window_ lived in mdns_component.cpp which can't see the platform's MDNS global. Moving start_polling_window_ into each platform cpp lets the set_interval lambda call MDNS.update() directly — no forward decl, no ODR comment, no indirection. --- esphome/components/mdns/mdns_component.cpp | 9 --------- esphome/components/mdns/mdns_component.h | 6 ------ esphome/components/mdns/mdns_esp8266.cpp | 6 +++++- esphome/components/mdns/mdns_rp2040.cpp | 6 +++++- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 80e12ade717..e05373ac5d3 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -190,15 +190,6 @@ void MDNSComponent::compile_records_(StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, mdns_pump_update); - this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); -} -#endif - void MDNSComponent::dump_config() { ESP_LOGCONFIG(TAG, "mDNS:\n" diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 95bf2bf27fa..4f651f7d84f 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -24,12 +24,6 @@ namespace esphome::mdns { -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING -/// MDNS.update() trampoline. Defined in exactly one mdns_.cpp per build -/// (FILTER_SOURCE_FILES in __init__.py enforces this), so ODR is preserved. -void mdns_pump_update(); -#endif - // Helper struct that identifies strings that may be stored in flash storage (similar to LogString) struct MDNSString; diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index d77d9685dd8..95137ea1164 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -39,7 +39,11 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} #endif void MDNSComponent::setup() { diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a8b0af4ad62..f5848893a34 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -39,7 +39,11 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); + this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); +} #endif void MDNSComponent::setup() { From b9a9067d98f9141149641fc1396abb1b904280a4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:41:15 -0500 Subject: [PATCH 10/13] [mdns] Wrap validator error string at 80 cols --- esphome/components/mdns/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 741669280c8..2b25cf243d7 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -78,7 +78,8 @@ def _require_network_interface(config: ConfigType) -> ConfigType: if not (has_wifi or has_ethernet): options = "'wifi'" if CORE.is_esp8266 else "'wifi' or 'ethernet'" raise cv.Invalid( - f"mdns on this platform requires a network interface — add a {options} component to your configuration." + "mdns on this platform requires a network interface — " + f"add a {options} component to your configuration." ) return config From fad9210cb981172d1aa1f34f2c352446f8471a7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:43:04 -0500 Subject: [PATCH 11/13] [mdns] Drop redundant USE_MDNS_EVENT_DRIVEN_POLLING guards in platform .cpp files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python validator enforces that wifi or ethernet is configured on ESP8266/ RP2040, so USE_WIFI_IP_STATE_LISTENERS (or the ethernet equivalent) is always requested in production, which always defines USE_MDNS_EVENT_DRIVEN_POLLING via mdns_component.h's derivation. defines.h also declares it unconditionally for static analysis. The #ifdef guards around start_polling_window_, setup()'s listener subscription, and on_ip_state were dead code — a misconfiguration should surface as a compile error, not silently strip out the method bodies. --- esphome/components/mdns/mdns_esp8266.cpp | 6 ------ esphome/components/mdns/mdns_rp2040.cpp | 4 ---- 2 files changed, 10 deletions(-) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 95137ea1164..3b9cfc65ffd 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -38,26 +38,21 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } -#endif void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just // arm the window around the initial probe/announce and each reconnect. Unconditional // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); -#endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh @@ -66,7 +61,6 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index f5848893a34..ece2bc5ace3 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -38,13 +38,11 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } -#endif void MDNSComponent::setup() { // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses @@ -74,7 +72,6 @@ void MDNSComponent::setup() { #endif } -#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // Listener only fires on IP acquisition (not loss); every event is a fresh IP. @@ -89,7 +86,6 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: } this->start_polling_window_(); } -#endif void MDNSComponent::on_shutdown() { MDNS.close(); From c74931aecb6d4bd9b2bfade15c619c67622eb27d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 20:55:22 -0500 Subject: [PATCH 12/13] [mdns] Revert defines.h USE_MDNS_EVENT_DRIVEN_POLLING / USE_MDNS_WIFI_LISTENER adds Copilot flagged that defining these unconditionally in defines.h breaks static analysis on platforms that don't meet the derivation's platform+listener guard: USE_MDNS_WIFI_LISTENER enables 'public wifi::WiFiIPStateListener' inheritance in the class declaration, but wifi_component.h is only #include'd inside the mdns_component.h derivation block. On analysis envs where the derivation guard is false (non-ESP8266/RP2040, or missing listener feature flags), the inherit fires without the header in scope, causing 'use of undeclared identifier' and related errors. Letting the derivation in mdns_component.h be the sole source of truth keeps the define and its corresponding include atomically linked. --- esphome/core/defines.h | 2 -- 1 file changed, 2 deletions(-) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 53b78a97df5..80247f69da1 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -111,8 +111,6 @@ #define MDNS_SERVICE_COUNT 3 #define USE_MDNS_DYNAMIC_TXT #define MDNS_DYNAMIC_TXT_COUNT 2 -#define USE_MDNS_EVENT_DRIVEN_POLLING -#define USE_MDNS_WIFI_LISTENER #define MICRONOVA_LISTENER_COUNT 1 #define USE_MICRONOVA_WRITER #define SERIAL_PROXY_COUNT 2 From fad39129975c70329d9f0cb18238d6df5c7061b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 21:20:03 -0500 Subject: [PATCH 13/13] [mdns] Restore USE_MDNS_EVENT_DRIVEN_POLLING guards around listener definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clang-tidy CI compiles with the raw esp8266-arduino-tidy env's flags — no Python codegen, and it doesn't pick up the feature defines from defines.h in the same way a real IDE does. USE_WIFI_IP_STATE_LISTENERS ends up undefined, the derivation in mdns_component.h doesn't fire, and the class doesn't declare start_polling_window_ / on_ip_state / the MDNS_POLL_* constants. Removing the guards in the platform cpps made those definitions dangle. Keep the listener-specific definitions under #ifdef USE_MDNS_EVENT_DRIVEN_POLLING. The Python validator still enforces the runtime invariant (wifi or ethernet must be present on ESP8266/RP2040), so production builds always fire through the listener path; this is purely about what the tidy compiler sees. --- esphome/components/mdns/mdns_esp8266.cpp | 6 ++++++ esphome/components/mdns/mdns_rp2040.cpp | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 3b9cfc65ffd..95137ea1164 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -38,21 +38,26 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } +#endif void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING // LEAmDNS's own LwipIntf::statusChangeCB drives _restart() on netif changes; we just // arm the window around the initial probe/announce and each reconnect. Unconditional // here is safe: setup_priority::AFTER_CONNECTION guarantees the network is up. wifi::global_wifi_component->add_ip_state_listener(this); this->start_polling_window_(); +#endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // IP listener only fires on acquisition (not loss), so any notification is a fresh @@ -61,6 +66,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: this->start_polling_window_(); } } +#endif void MDNSComponent::on_shutdown() { MDNS.close(); diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index ece2bc5ace3..f5848893a34 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -38,11 +38,13 @@ static void register_rp2040(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { MDNS.update(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } +#endif void MDNSComponent::setup() { // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses @@ -72,6 +74,7 @@ void MDNSComponent::setup() { #endif } +#ifdef USE_MDNS_EVENT_DRIVEN_POLLING void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &, const network::IPAddress &) { // Listener only fires on IP acquisition (not loss); every event is a fresh IP. @@ -86,6 +89,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: } this->start_polling_window_(); } +#endif void MDNSComponent::on_shutdown() { MDNS.close();