mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 14:55:05 +00:00
[mdns] Drive MDNS.update() polling from IP state events on ESP8266/RP2040 (#15961)
This commit is contained in:
@@ -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,28 @@ 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) 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(
|
||||
"mdns on this platform requires a network interface — "
|
||||
f"add a {options} component to your configuration."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -74,6 +97,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.
|
||||
|
||||
@@ -169,6 +195,19 @@ async def to_code(config):
|
||||
elif CORE.is_rp2040:
|
||||
cg.add_library("LEAmDNS", None)
|
||||
|
||||
# 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
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -5,6 +5,22 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
// 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)))
|
||||
#include "esphome/components/network/ip_address.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 {
|
||||
|
||||
@@ -40,33 +56,40 @@ struct MDNSService {
|
||||
FixedVector<MDNSTXTRecord> txt_records;
|
||||
};
|
||||
|
||||
class MDNSComponent final : public Component {
|
||||
class MDNSComponent final : public Component
|
||||
#ifdef USE_MDNS_WIFI_LISTENER
|
||||
,
|
||||
public wifi::WiFiIPStateListener
|
||||
#endif
|
||||
#ifdef USE_MDNS_ETHERNET_LISTENER
|
||||
,
|
||||
public ethernet::EthernetIPStateListener
|
||||
#endif
|
||||
{
|
||||
public:
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
// Polling interval for MDNS.update() on platforms that require it (ESP8266, RP2040).
|
||||
//
|
||||
// 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 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.
|
||||
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
|
||||
// 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;
|
||||
// Must exceed LEAmDNS's longest restart-to-announce-complete path:
|
||||
// MDNS_PROBE_DELAY (250ms) × MDNS_PROBE_COUNT (3) = 750ms probing
|
||||
// + MDNS_ANNOUNCE_DELAY (1000ms) × MDNS_ANNOUNCE_COUNT (8) = 8000ms announcing
|
||||
// + rand() % MDNS_PROBE_DELAY jitter on first probe (0–250ms)
|
||||
// + debounced schedule_function() hop when statusChangeCB fires on ESP8266
|
||||
// ≈ 9s nominal. 15s gives ~6s margin to absorb main-loop blocking (long
|
||||
// component setup, WiFi scan, flash writes) that could stretch the deadlines
|
||||
// between our polls. If LEAmDNS ever extends its phase (upstream library
|
||||
// update) this constant needs to grow. Constants defined in LEAmDNS_Priv.h
|
||||
// (ESP8266 core 3.1.2 / arduino-pico 5.5.1).
|
||||
static constexpr uint32_t MDNS_POLL_WINDOW_MS = 15000;
|
||||
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 +110,17 @@ 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 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
|
||||
using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &);
|
||||
|
||||
@@ -130,8 +163,8 @@ class MDNSComponent final : public Component {
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_{};
|
||||
#endif
|
||||
#ifdef USE_RP2040
|
||||
bool was_connected_{false};
|
||||
#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<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf);
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "mdns_component.h"
|
||||
// wifi_component.h is pulled in transitively by mdns_component.h when
|
||||
// USE_MDNS_WIFI_LISTENER is defined.
|
||||
|
||||
namespace esphome::mdns {
|
||||
|
||||
@@ -36,15 +38,36 @@ static void register_esp8266(MDNSComponent *, StaticVector<MDNSService, MDNS_SER
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
|
||||
void MDNSComponent::start_polling_window_() {
|
||||
// uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add.
|
||||
this->set_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);
|
||||
// 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(); });
|
||||
#ifdef USE_MDNS_WIFI_LISTENER
|
||||
// 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_WIFI_LISTENER
|
||||
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
|
||||
// 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();
|
||||
delay(10);
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "mdns_component.h"
|
||||
// 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.
|
||||
// Save and restore our definition around the include to avoid a redefinition warning.
|
||||
#pragma push_macro("IRAM_ATTR")
|
||||
#undef IRAM_ATTR
|
||||
#include <ESP8266mDNS.h>
|
||||
@@ -20,10 +21,7 @@ static void register_rp2040(MDNSComponent *, StaticVector<MDNSService, MDNS_SERV
|
||||
MDNS.begin(App.get_name().c_str());
|
||||
|
||||
for (const auto &service : services) {
|
||||
// Strip the leading underscore from the proto and service_type. While it is
|
||||
// part of the wire protocol to have an underscore, and for example ESP-IDF
|
||||
// expects the underscore to be there, the ESP8266 implementation always adds
|
||||
// the underscore itself.
|
||||
// ESP8266mDNS always adds the leading underscore itself, so strip it here.
|
||||
auto *proto = MDNS_STR_ARG(service.proto);
|
||||
while (*proto == '_') {
|
||||
proto++;
|
||||
@@ -40,34 +38,58 @@ static void register_rp2040(MDNSComponent *, StaticVector<MDNSService, MDNS_SERV
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// in arduino-pico's LwipIntfCB.cpp because the original ESP8266 implementation used
|
||||
// schedule_function() which doesn't exist in arduino-pico, and the callback can't
|
||||
// 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
|
||||
// thread-safe.
|
||||
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();
|
||||
}
|
||||
});
|
||||
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
|
||||
void MDNSComponent::start_polling_window_() {
|
||||
// uint32_t-ID set_interval/set_timeout already does atomic cancel-and-add.
|
||||
this->set_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
|
||||
// on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our
|
||||
// own IP state listener. Both WiFi and Ethernet have the same listener signature —
|
||||
// one on_ip_state() override serves both.
|
||||
#ifdef USE_MDNS_WIFI_LISTENER
|
||||
wifi::global_wifi_component->add_ip_state_listener(this);
|
||||
// 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()) {
|
||||
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);
|
||||
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
|
||||
}
|
||||
|
||||
#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.
|
||||
if (!ips[0].is_set()) {
|
||||
return;
|
||||
}
|
||||
if (!this->initialized_) {
|
||||
this->setup_buffers_and_register_(register_rp2040);
|
||||
this->initialized_ = true;
|
||||
} else {
|
||||
MDNS.notifyAPChange();
|
||||
}
|
||||
this->start_polling_window_();
|
||||
}
|
||||
#endif
|
||||
|
||||
void MDNSComponent::on_shutdown() {
|
||||
MDNS.close();
|
||||
|
||||
23
tests/components/mdns/common-enabled-ethernet.yaml
Normal file
23
tests/components/mdns/common-enabled-ethernet.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
ethernet:
|
||||
type: W5500
|
||||
clk_pin: 18
|
||||
mosi_pin: 19
|
||||
miso_pin: 16
|
||||
cs_pin: 17
|
||||
interrupt_pin: 21
|
||||
reset_pin: 20
|
||||
manual_ip:
|
||||
static_ip: 192.168.178.56
|
||||
gateway: 192.168.178.1
|
||||
subnet: 255.255.255.0
|
||||
domain: .local
|
||||
mac_address: "02:AA:BB:CC:DD:01"
|
||||
|
||||
mdns:
|
||||
disabled: false
|
||||
services:
|
||||
- service: _test_service
|
||||
protocol: _tcp
|
||||
port: 8888
|
||||
txt:
|
||||
static_string: Anything
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-enabled-ethernet.yaml
|
||||
Reference in New Issue
Block a user