From bf7083c50100b2b1ba83d980e7be6d34fb549b8c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Apr 2026 19:19:29 -0500 Subject: [PATCH] [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);