From ab66b2094459c840488e554d22b34216a24b1abf Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 14:13:31 -0400 Subject: [PATCH] [sendspin] Add an unpaired access switch (#20350) --- esphome/components/sendspin/__init__.py | 21 +++- esphome/components/sendspin/sendspin_hub.cpp | 24 ++-- esphome/components/sendspin/sendspin_hub.h | 13 +- .../components/sendspin/switch/__init__.py | 88 ++++++++++++-- .../sendspin/switch/sendspin_switch.cpp | 33 ++++- .../sendspin/switch/sendspin_switch.h | 18 ++- .../sendspin/test_unpaired_access.py | 115 +++++++++++++++++- tests/components/sendspin/common-pairing.yaml | 2 +- tests/components/sendspin/common-switch.yaml | 3 + 9 files changed, 274 insertions(+), 43 deletions(-) diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 5e104fe58a..ad9b22e367 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -5,7 +5,7 @@ from typing import Any from esphome import automation import esphome.codegen as cg from esphome.components import esp32, mdns, network, psram, socket, wifi -from esphome.components.const import CONF_MANUFACTURER +from esphome.components.const import CONF_ENABLED, CONF_MANUFACTURER import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -175,6 +175,7 @@ class SendspinConfiguration: player_support: bool = False visualizer_support: bool = False pairing_code_display_support: bool = False + switch_types: set[str] = field(default_factory=set) artwork_preferences: list[ConfigType] = field(default_factory=list) player_config: ConfigType | None = None @@ -216,6 +217,11 @@ def request_pairing_code_display_support() -> None: _get_data().pairing_code_display_support = True +def request_switch(switch_type: str) -> None: + """Mark that a sendspin switch of this type drives the matching hub setting.""" + _get_data().switch_types.add(switch_type) + + def register_artwork_preference(config: ConfigType) -> int: """Register an artwork slot preference and return the slot it was given. @@ -275,6 +281,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( _validate_static_pairing_code ), + # No default: the unpaired access switch rejects this key when it is set. cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( {} @@ -322,8 +329,8 @@ def _final_validate(config: ConfigType) -> ConfigType: ) if not config.get(CONF_UNPAIRED_ACCESS, True) and not _has_pairing_method(config): _LOGGER.warning( - "'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no " - "server can play on this device", + "'%s' is off but there is no pairing method (%s or a dynamic pairing code), so no " + "new server can pair with this device", CONF_UNPAIRED_ACCESS, CONF_STATIC_PAIRING_CODE, ) @@ -400,8 +407,12 @@ async def to_code(config: ConfigType) -> None: if (code := config.get(CONF_STATIC_PAIRING_CODE)) is not None: cg.add(var.set_static_pairing_code(code)) - if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None: - cg.add(var.set_default_unpaired_access(unpaired_access)) + # The client starts once both are set: here, or by the switch that drives the setting. + switch_types = _get_data().switch_types + if CONF_ENABLED not in switch_types: + cg.add(var.set_enabled(True)) + if CONF_UNPAIRED_ACCESS not in switch_types: + cg.add(var.set_unpaired_access_enabled(config.get(CONF_UNPAIRED_ACCESS, True))) if _offers_dynamic_pairing_code(config): cg.add(var.set_pairing_code_display_supported(True)) diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 3f8fcd7af4..cfbd91dfcf 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -154,16 +154,15 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); - -#ifndef USE_SENDSPIN_SWITCH - this->enabled_ = true; -#endif + // Set before setup() by codegen; an unpaired access switch sets it later, from its own setup(). + if (this->unpaired_access_.has_value()) { + this->client_->set_unpaired_access_enabled(*this->unpaired_access_); + } } void SendspinHub::loop() { - if (this->enabled_.has_value() && this->enabled_.value() != this->client_->is_started() && - !this->status_has_error()) { + if (this->enabled_.has_value() && this->unpaired_access_.has_value() && + this->enabled_.value() != this->client_->is_started() && !this->status_has_error()) { if (!this->enabled_.value()) { this->client_->stop(); } else if (!this->client_->start()) { @@ -222,7 +221,7 @@ void SendspinHub::dump_config() { #endif } -// THREAD CONTEXT: Main loop (invoked from Sendspin components) +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) void SendspinHub::set_enabled(bool enabled) { if (this->status_has_error()) { ESP_LOGE(TAG, "Cannot %s: Sendspin failed to start, reboot to retry", @@ -232,6 +231,15 @@ void SendspinHub::set_enabled(bool enabled) { this->enabled_ = enabled; } +// THREAD CONTEXT: Main loop (invoked from codegen before setup(), or from Sendspin components) +void SendspinHub::set_unpaired_access_enabled(bool enabled) { + this->unpaired_access_ = enabled; + // Before setup() there is no client yet; setup() applies the stored value. + if (this->client_ != nullptr) { + this->client_->set_unpaired_access_enabled(enabled); + } +} + #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE // THREAD CONTEXT: Main loop void SendspinHub::update_mdns_service_() { diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index d33269048e..33356f21f6 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -168,12 +168,13 @@ class SendspinHub final : public Component, /// stop. /// /// Applied from the hub's loop(). Stopping blocks until the client is fully stopped; the roles' clear callbacks - /// fire from inside that call. With a sendspin switch configured the client stays stopped until the switch has - /// called this once. Must be called from the main loop thread. + /// fire from inside that call. The client stays stopped until this has been called once, by codegen or by the + /// enabled switch. Must be called from the main loop thread. void set_enabled(bool enabled); - /// Turns unpaired (Sentinel) access on or off from setup(); see SendspinClient::set_unpaired_access_enabled(). - void set_default_unpaired_access(bool enabled) { this->default_unpaired_access_ = enabled; } + /// Turns unpaired (Sentinel) access on or off. The client waits for this call, by codegen or by the unpaired + /// access switch, before its first start. May be called before setup(). Main loop only. + void set_unpaired_access_enabled(bool enabled); /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -382,14 +383,14 @@ class SendspinHub final : public Component, const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured bool pairing_code_display_supported_{false}; - bool default_unpaired_access_{true}; bool task_stack_in_psram_{false}; #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE bool mdns_advertised_{false}; // Last state requested from mdns #endif - // Requested client state, applied from loop(). Empty until the switch restores its state. + // Requested client state, applied from loop(). The client does not start until both are set. std::optional enabled_; + std::optional unpaired_access_; // Device information sent in the `client/hello` message. Defaults apply when neither the // sendspin configuration nor the project information supplies a value. diff --git a/esphome/components/sendspin/switch/__init__.py b/esphome/components/sendspin/switch/__init__.py index 1f896640bf..ae640e70a4 100644 --- a/esphome/components/sendspin/switch/__init__.py +++ b/esphome/components/sendspin/switch/__init__.py @@ -1,35 +1,101 @@ +import logging + import esphome.codegen as cg from esphome.components import switch from esphome.components.const import CONF_ENABLED import esphome.config_validation as cv -from esphome.const import CONF_TYPE, ENTITY_CATEGORY_CONFIG +from esphome.const import CONF_PLATFORM, CONF_SWITCH, CONF_TYPE, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv from esphome.types import ConfigType -from .. import CONF_SENDSPIN_ID, SendspinHub, sendspin_ns +from .. import ( + CONF_SENDSPIN_ID, + CONF_STATIC_PAIRING_CODE, + CONF_UNPAIRED_ACCESS, + DOMAIN, + SendspinHub, + _has_pairing_method, + request_switch, + sendspin_ns, +) + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@kahrendt"] DEPENDENCIES = ["sendspin"] -SendspinSwitch = sendspin_ns.class_("SendspinSwitch", switch.Switch, cg.Component) +SendspinEnabledSwitch = sendspin_ns.class_( + "SendspinEnabledSwitch", switch.Switch, cg.Component +) +SendspinUnpairedAccessSwitch = sendspin_ns.class_( + "SendspinUnpairedAccessSwitch", switch.Switch, cg.Component +) + + +def _switch_schema(class_: cg.MockObjClass) -> cv.Schema: + return ( + switch.switch_schema( + class_, + block_inverted=True, + default_restore_mode="RESTORE_DEFAULT_ON", + entity_category=ENTITY_CATEGORY_CONFIG, + ) + .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) + .extend(cv.COMPONENT_SCHEMA) + ) + + +def _request_switch(config: ConfigType) -> ConfigType: + """Tell the hub to wait for this switch before the client's first start.""" + request_switch(config[CONF_TYPE]) + return config + CONFIG_SCHEMA = cv.All( cv.typed_schema( { - CONF_ENABLED: switch.switch_schema( - SendspinSwitch, - block_inverted=True, - default_restore_mode="RESTORE_DEFAULT_ON", - entity_category=ENTITY_CATEGORY_CONFIG, - ) - .extend({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)}) - .extend(cv.COMPONENT_SCHEMA), + CONF_ENABLED: _switch_schema(SendspinEnabledSwitch), + CONF_UNPAIRED_ACCESS: _switch_schema(SendspinUnpairedAccessSwitch), }, key=CONF_TYPE, ), cv.only_on_esp32, + _request_switch, ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + switch_type = config[CONF_TYPE] + same_type = [ + conf + for conf in full_config.get(CONF_SWITCH, []) + if conf.get(CONF_PLATFORM) == DOMAIN and conf.get(CONF_TYPE) == switch_type + ] + # Two switches of one type would each drive the same hub setting. + if len(same_type) > 1: + raise cv.Invalid(f"Only one sendspin '{switch_type}' switch is allowed") + if switch_type != CONF_UNPAIRED_ACCESS: + return config + hub_config = full_config.get(DOMAIN, {}) + if CONF_UNPAIRED_ACCESS in hub_config: + raise cv.Invalid( + f"'{DOMAIN}: {CONF_UNPAIRED_ACCESS}' cannot be set together with an " + f"{CONF_UNPAIRED_ACCESS} switch; set the switch's restore_mode instead" + ) + if not _has_pairing_method(hub_config): + _LOGGER.warning( + "The %s switch has no pairing method (%s or a dynamic pairing code), so " + "while it is off no new server can pair with this device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + async def to_code(config: ConfigType) -> None: var = await switch.new_switch(config) await cg.register_component(var, config) diff --git a/esphome/components/sendspin/switch/sendspin_switch.cpp b/esphome/components/sendspin/switch/sendspin_switch.cpp index af4d053854..70703ea3c5 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.cpp +++ b/esphome/components/sendspin/switch/sendspin_switch.cpp @@ -1,26 +1,49 @@ #include "sendspin_switch.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/core/log.h" +#include "esphome/core/preferences.h" namespace esphome::sendspin_ { static const char *const TAG = "sendspin.switch"; -void SendspinSwitch::setup() { +// --- SendspinEnabledSwitch --- + +void SendspinEnabledSwitch::setup() { // The hub waits for this request, so a restore mode without a state still has to answer. this->control(this->get_initial_state_with_restore_mode().value_or(true)); } -void SendspinSwitch::dump_config() { LOG_SWITCH("", "Sendspin Switch", this); } +void SendspinEnabledSwitch::dump_config() { LOG_SWITCH("", "Sendspin Enabled Switch", this); } // THREAD CONTEXT: Main loop -void SendspinSwitch::write_state(bool state) { +void SendspinEnabledSwitch::write_state(bool state) { this->parent_->set_enabled(state); this->publish_state(state); } +// --- SendspinUnpairedAccessSwitch --- + +void SendspinUnpairedAccessSwitch::setup() { + // The hub holds the client's first start for this, so a restore mode without a state still has to answer. + this->control(this->get_initial_state_with_restore_mode().value_or(true)); +} + +void SendspinUnpairedAccessSwitch::dump_config() { LOG_SWITCH("", "Sendspin Unpaired Access Switch", this); } + +// THREAD CONTEXT: Main loop +void SendspinUnpairedAccessSwitch::write_state(bool state) { + const bool turning_off = !state && this->has_state() && this->state; + this->parent_->set_unpaired_access_enabled(state); + this->publish_state(state); + // Write the change to flash now: losing it in a power cut would reopen unpaired access. + if (turning_off) { + global_preferences->sync(); + } +} + } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/esphome/components/sendspin/switch/sendspin_switch.h b/esphome/components/sendspin/switch/sendspin_switch.h index f9173c362d..4b04c95857 100644 --- a/esphome/components/sendspin/switch/sendspin_switch.h +++ b/esphome/components/sendspin/switch/sendspin_switch.h @@ -2,7 +2,7 @@ #include "esphome/core/defines.h" -#ifdef USE_ESP_IDF +#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_SWITCH) #include "esphome/components/sendspin/sendspin_hub.h" #include "esphome/components/switch/switch.h" @@ -10,7 +10,19 @@ namespace esphome::sendspin_ { /// @brief Switch that starts and stops the Sendspin client through the hub (see SendspinHub::set_enabled()). -class SendspinSwitch final : public switch_::Switch, public SendspinChild { +class SendspinEnabledSwitch final : public switch_::Switch, public SendspinChild { + public: + void setup() override; + void dump_config() override; + + protected: + void write_state(bool state) override; +}; + +/// @brief Switch that turns unpaired (Sentinel) access on and off through the hub (see +/// SendspinHub::set_unpaired_access_enabled()). The library does not persist the setting, so the switch's restore +/// mode does. +class SendspinUnpairedAccessSwitch final : public switch_::Switch, public SendspinChild { public: void setup() override; void dump_config() override; @@ -21,4 +33,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild { } // namespace esphome::sendspin_ -#endif // USE_ESP_IDF +#endif diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py index 80bf5a2417..8660ba9f7c 100644 --- a/tests/component_tests/sendspin/test_unpaired_access.py +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -1,24 +1,42 @@ -"""Validation tests for the hub's unpaired access policy.""" +"""Validation tests for the unpaired access policy. + +The hub's `unpaired_access` key and an `unpaired_access` switch both set the +policy, so the switch platform rejects a config that has both. +""" import logging import pytest +from esphome import config_validation as cv from esphome.components.sendspin import ( CONF_UNPAIRED_ACCESS, CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + DOMAIN, FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, + _get_data, request_pairing_code_display_support, ) +from esphome.components.sendspin.switch import ( + CONFIG_SCHEMA as SWITCH_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as SWITCH_FINAL_VALIDATE_SCHEMA, +) from esphome.const import PlatformFramework +from esphome.types import ConfigType from tests.component_tests.types import SetCoreConfigCallable HUB_ID = "sendspin_hub_id" -NO_PAIRING_WARNING = "nothing lets a server pair" +NO_PAIRING_WARNING = "no new server can pair with this device" + + +def _switch_config(switch_type: str) -> ConfigType: + return SWITCH_CONFIG_SCHEMA( + {"name": "Sendspin Switch", "type": switch_type, "sendspin_id": HUB_ID} + ) def test_hub_key_absent_by_default(set_core_config: SetCoreConfigCallable) -> None: - """Codegen leaves the hub's built-in default of on in place when the key is not set.""" + """With no default in the schema, the switch can tell whether the key was set.""" set_core_config(PlatformFramework.ESP32_IDF) assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) @@ -36,7 +54,7 @@ def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) - def test_off_without_pairing_method_warns( set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture ) -> None: - """With unpaired access off and no way to pair, no server could ever play.""" + """With unpaired access off and no way to pair, no new server could pair.""" set_core_config(PlatformFramework.ESP32_IDF) config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) @@ -76,3 +94,92 @@ def test_on_does_not_warn( HUB_FINAL_VALIDATE_SCHEMA(config) assert NO_PAIRING_WARNING not in caplog.text + + +def test_switch_with_hub_key_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Both would set the policy, and the hub's value would be silently ignored.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with pytest.raises(cv.Invalid, match="set the switch's restore_mode instead"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +def test_switch_without_hub_key_accepted( + set_core_config: SetCoreConfigCallable, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +def test_switch_without_pairing_method_warns( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + """Turning the switch off would leave no way for a new server to pair.""" + set_core_config(PlatformFramework.ESP32_IDF, full_config={DOMAIN: {"id": HUB_ID}}) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +def test_switch_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, "static_pairing_code": "01234567"}}, + ) + config = _switch_config(CONF_UNPAIRED_ACCESS) + + with caplog.at_level(logging.WARNING): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_enabled_switch_ignores_hub_key(set_core_config: SetCoreConfigCallable) -> None: + """Only the unpaired access switch competes with the hub key.""" + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}}, + ) + config = _switch_config("enabled") + + assert SWITCH_FINAL_VALIDATE_SCHEMA(config) is config + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_duplicate_switch_type_rejected( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """Two switches of one type would fight over the same hub setting.""" + entry = {"platform": DOMAIN, "type": switch_type} + set_core_config( + PlatformFramework.ESP32_IDF, + full_config={DOMAIN: {"id": HUB_ID}, "switch": [entry, dict(entry)]}, + ) + config = _switch_config(switch_type) + + with pytest.raises(cv.Invalid, match="Only one sendspin"): + SWITCH_FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("switch_type", ["enabled", CONF_UNPAIRED_ACCESS]) +def test_switch_requests_its_type( + set_core_config: SetCoreConfigCallable, switch_type: str +) -> None: + """The hub skips the codegen value for a setting a switch drives, so the client + waits for that switch to restore before its first start.""" + set_core_config(PlatformFramework.ESP32_IDF) + + _switch_config(switch_type) + + assert _get_data().switch_types == {switch_type} diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml index cd9cd82bec..b201a5b850 100644 --- a/tests/components/sendspin/common-pairing.yaml +++ b/tests/components/sendspin/common-pairing.yaml @@ -10,5 +10,5 @@ wifi: sendspin: id: sendspin_hub_id static_pairing_code: "01234567" - # Covers the hub's unpaired access option. + # Covers the hub's unpaired access option, which is only valid without an unpaired access switch. unpaired_access: false diff --git a/tests/components/sendspin/common-switch.yaml b/tests/components/sendspin/common-switch.yaml index 01e2f0b02b..dbf260286b 100644 --- a/tests/components/sendspin/common-switch.yaml +++ b/tests/components/sendspin/common-switch.yaml @@ -5,3 +5,6 @@ switch: - platform: sendspin name: "Sendspin Enabled" type: enabled + - platform: sendspin + name: "Sendspin Unpaired Access" + type: unpaired_access