[mdns] Drive MDNS.update() polling from WiFi IP state events on ESP8266/RP2040

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.
This commit is contained in:
J. Nick Koston
2026-04-23 19:19:29 -05:00
parent ddf1426f86
commit bf7083c501
5 changed files with 153 additions and 29 deletions
+14
View File
@@ -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")
@@ -190,6 +190,20 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
#endif
}
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
void MDNSComponent::start_polling_window_() {
// Re-arming replaces the previous window; cancel any active schedulers first.
this->cancel_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"
+61 -21
View File
@@ -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<MDNSTXTRecord> 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<MDNSService, MDNS_SERVICE_COUNT> &);
@@ -131,8 +169,10 @@ class MDNSComponent final : public Component {
StaticVector<MDNSService, MDNS_SERVICE_COUNT> 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<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf);
};
+23 -5
View File
@@ -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 *, StaticVector<MDNSService, MDNS_SER
}
}
void mdns_pump_update() { MDNS.update(); }
void MDNSComponent::setup() {
this->setup_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() {
+41 -3
View File
@@ -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 *, StaticVector<MDNSService, MDNS_SERV
}
}
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
void mdns_pump_update() { MDNS.update(); }
#endif
void MDNSComponent::setup() {
// RP2040's LEAmDNS library registers a LwipIntf::stateUpCB() callback to restart
// mDNS when the network interface reconnects. However, stateUpCB() is stubbed out
@@ -48,10 +55,21 @@ void MDNSComponent::setup() {
// safely run directly since netif status callbacks fire from IRQ context
// (PICO_CYW43_ARCH_THREADSAFE_BACKGROUND) while _restart() allocates UDP sockets.
//
// Workaround: defer MDNS.begin() and service registration until the network is
// connected (has an IP), then call notifyAPChange() on subsequent reconnects to
// restart mDNS probing and announcing — all from main loop context so it's
// 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.
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
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));
}
#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);