From ec1d8fa9535ec51a69ac18592bd2e6b66bbf7f06 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Tue, 15 Sep 2026 21:06:40 -0400 Subject: [PATCH] [mdns] Add runtime service enable/disable API (ESP32 only) (#19325) --- esphome/components/mdns/__init__.py | 24 +++++++ esphome/components/mdns/mdns_component.h | 16 +++++ esphome/components/mdns/mdns_esp32.cpp | 69 ++++++++++++++---- esphome/core/defines.h | 3 + tests/component_tests/mdns/__init__.py | 0 .../mdns/test_service_enable_disable.py | 70 +++++++++++++++++++ 6 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tests/component_tests/mdns/__init__.py create mode 100644 tests/component_tests/mdns/test_service_enable_disable.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index c8020104b37..0fb24fdf1df 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -5,6 +5,8 @@ import esphome.config_validation as cv from esphome.const import ( CONF_DISABLED, CONF_ID, + CONF_MDNS, + CONF_OPENTHREAD, CONF_PORT, CONF_PROTOCOL, CONF_SERVICE, @@ -184,6 +186,28 @@ def enable_mdns_storage() -> None: cg.add_define("USE_MDNS_STORE_SERVICES") +def request_service_enable_disable() -> bool: + """Request MDNSComponent::set_service_enabled() support. + + ESP32 only, not with OpenThread. Returns True when the + USE_MDNS_SUPPORTS_ENABLE_DISABLE define was added; guard C++ usage with it. + + Public API for external components. Do not remove. + """ + mdns_config = CORE.config.get(CONF_MDNS) + if ( + mdns_config is None + or mdns_config[CONF_DISABLED] + or not CORE.is_esp32 + or CONF_OPENTHREAD in CORE.config + ): + return False + cg.add_define("USE_MDNS_SUPPORTS_ENABLE_DISABLE") + # Services must stay stored so a disabled service can be re-registered + enable_mdns_storage() + return True + + @coroutine_with_priority(CoroPriority.NETWORK_SERVICES) async def to_code(config: ConfigType) -> None: if config[CONF_DISABLED] is True: diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 4f97e8cb996..ed06b8e1330 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -63,6 +63,9 @@ struct MDNSService { const MDNSString *proto; TemplatableFn port; FixedVector txt_records; +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + bool enabled{true}; +#endif }; class MDNSComponent final : public Component @@ -112,6 +115,19 @@ class MDNSComponent final : public Component const StaticVector &get_services() const { return this->services_; } #endif +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifndef USE_MDNS_STORE_SERVICES +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE requires USE_MDNS_STORE_SERVICES" +#endif +#ifdef USE_OPENTHREAD +#error "USE_MDNS_SUPPORTS_ENABLE_DISABLE is not supported with OpenThread" +#endif + /// Enable or disable a compiled-in service, matched by type and proto (e.g. "_sendspin", "_tcp"). + /// Only valid once this component is ready. Re-enabling re-reads the port but keeps the boot-time TXT values. + /// Returns true if the service is in the requested state afterwards. Blocks briefly on the mDNS task. + bool set_service_enabled(const char *service_type, const char *proto, bool enabled); +#endif + void on_shutdown() override; #ifdef USE_MDNS_DYNAMIC_TXT diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 17000a2bd76..48df61326e2 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32) && defined(USE_MDNS) #include +#include #include "esphome/core/application.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" @@ -11,6 +12,23 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; +#ifndef USE_OPENTHREAD +static esp_err_t add_service(const MDNSService &service) { + // Stack buffer for up to 16 txt records, heap fallback for more + SmallBufferWithHeapFallback<16, mdns_txt_item_t> txt_records(service.txt_records.size()); + for (size_t i = 0; i < service.txt_records.size(); i++) { + const auto &record = service.txt_records[i]; + // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ + // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies + txt_records.get()[i].key = MDNS_STR_ARG(record.key); + txt_records.get()[i].value = MDNS_STR_ARG(record.value); + } + uint16_t port = service.port.value(); + return mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, + txt_records.get(), service.txt_records.size()); +} +#endif + static void register_esp32(MDNSComponent *comp, StaticVector &services) { #ifdef USE_OPENTHREAD // OpenThread handles service registration via SRP client @@ -27,27 +45,50 @@ static void register_esp32(MDNSComponent *comp, StaticVector txt_records(service.txt_records.size()); - for (size_t i = 0; i < service.txt_records.size(); i++) { - const auto &record = service.txt_records[i]; - // key and value are either compile-time string literals in flash or pointers to dynamic_txt_values_ - // Both remain valid for the lifetime of this function, and ESP-IDF makes internal copies - txt_records.get()[i].key = MDNS_STR_ARG(record.key); - txt_records.get()[i].value = MDNS_STR_ARG(record.value); - } - uint16_t port = service.port.value(); - err = mdns_service_add(nullptr, MDNS_STR_ARG(service.service_type), MDNS_STR_ARG(service.proto), port, - txt_records.get(), service.txt_records.size()); - + for (auto &service : services) { +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + if (!service.enabled) + continue; +#endif + err = add_service(service); if (err != ESP_OK) { ESP_LOGW(TAG, "Failed to register service %s: %s", MDNS_STR_ARG(service.service_type), esp_err_to_name(err)); +#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE + // Let a later enable call retry + service.enabled = false; +#endif } } #endif } +#if defined(USE_MDNS_SUPPORTS_ENABLE_DISABLE) && !defined(USE_OPENTHREAD) +bool MDNSComponent::set_service_enabled(const char *service_type, const char *proto, bool enabled) { + // services_ is compiled in setup() + if (!this->is_ready()) { + ESP_LOGW(TAG, "Cannot %s service %s before setup", enabled ? "enable" : "disable", service_type); + return false; + } + for (auto &service : this->services_) { + if (strcmp(MDNS_STR_ARG(service.service_type), service_type) != 0 || + strcmp(MDNS_STR_ARG(service.proto), proto) != 0) { + continue; + } + if (service.enabled == enabled) + return true; + esp_err_t err = enabled ? add_service(service) : mdns_service_remove(service_type, proto); + if (err != ESP_OK) { + ESP_LOGW(TAG, "Failed to %s service %s: %s", enabled ? "enable" : "disable", service_type, esp_err_to_name(err)); + return false; + } + service.enabled = enabled; + return true; + } + ESP_LOGW(TAG, "Service %s not found", service_type); + return false; +} +#endif // USE_MDNS_SUPPORTS_ENABLE_DISABLE && !USE_OPENTHREAD + void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } void MDNSComponent::on_shutdown() { diff --git a/esphome/core/defines.h b/esphome/core/defines.h index f6010fd7fa0..b36d39bbefa 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -479,6 +479,9 @@ #define USE_OPENTHREAD #define USE_ZIGBEE #endif +#ifndef USE_OPENTHREAD +#define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#endif #endif #if defined(USE_ESP32_VARIANT_ESP32S2) diff --git a/tests/component_tests/mdns/__init__.py b/tests/component_tests/mdns/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/component_tests/mdns/test_service_enable_disable.py b/tests/component_tests/mdns/test_service_enable_disable.py new file mode 100644 index 00000000000..eacf722f177 --- /dev/null +++ b/tests/component_tests/mdns/test_service_enable_disable.py @@ -0,0 +1,70 @@ +"""request_service_enable_disable() only opts in on platforms whose mDNS stack +can add and remove services after setup, and tells the caller so.""" + +import pytest + +from esphome.components import mdns +from esphome.const import CONF_DISABLED, PlatformFramework +from esphome.core import CORE +from tests.component_tests.types import SetCoreConfigCallable + +DEFINE = "USE_MDNS_SUPPORTS_ENABLE_DISABLE" + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def _set_config( + set_core_config: SetCoreConfigCallable, + platform_framework: PlatformFramework, + config: dict, +) -> None: + set_core_config(platform_framework) + CORE.config = config + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_ARDUINO], +) +def test_esp32_adds_define_and_keeps_services_stored( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is True + # Disabled services must stay stored so they can be re-registered later. + assert {DEFINE, "USE_MDNS_STORE_SERVICES"} <= _defines() + + +@pytest.mark.parametrize( + "platform_framework", + [PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2_ARDUINO], +) +def test_other_platforms_return_false( + set_core_config: SetCoreConfigCallable, platform_framework: PlatformFramework +) -> None: + _set_config(set_core_config, platform_framework, {"mdns": {CONF_DISABLED: False}}) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines() + + +@pytest.mark.parametrize( + "config", + [ + pytest.param({}, id="no_mdns"), + pytest.param({"mdns": {CONF_DISABLED: True}}, id="mdns_disabled"), + pytest.param( + {"mdns": {CONF_DISABLED: False}, "openthread": {}}, id="openthread" + ), + ], +) +def test_esp32_returns_false_when_services_cannot_be_toggled( + set_core_config: SetCoreConfigCallable, config: dict +) -> None: + _set_config(set_core_config, PlatformFramework.ESP32_IDF, config) + + assert mdns.request_service_enable_disable() is False + assert DEFINE not in _defines()