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);