From eea11834f85e8f776ba1a1aa6999b1e14de00eb1 Mon Sep 17 00:00:00 2001 From: Kevin Ahrendt Date: Thu, 8 Oct 2026 13:19:14 -0400 Subject: [PATCH] [sendspin] Add the static pairing code, pairing window and unpaired access option (#20348) --- esphome/components/sendspin/__init__.py | 119 ++++++++++++++++-- esphome/components/sendspin/sendspin_hub.cpp | 56 ++++++++- esphome/components/sendspin/sendspin_hub.h | 50 ++++++++ .../component_tests/sendspin/test_pairing.py | 77 ++++++++++++ .../sendspin/test_unpaired_access.py | 75 +++++++++++ tests/components/sendspin/common-hub.yaml | 24 ++++ tests/components/sendspin/common-pairing.yaml | 14 +++ .../sendspin/test-pairing.esp32-idf.yaml | 2 + 8 files changed, 407 insertions(+), 10 deletions(-) create mode 100644 tests/component_tests/sendspin/test_pairing.py create mode 100644 tests/component_tests/sendspin/test_unpaired_access.py create mode 100644 tests/components/sendspin/common-pairing.yaml create mode 100644 tests/components/sendspin/test-pairing.esp32-idf.yaml diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 2fb8d4a17e..fcc53dde4b 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -1,4 +1,6 @@ from dataclasses import dataclass, field +import logging +from typing import Any from esphome import automation import esphome.codegen as cg @@ -25,6 +27,8 @@ from esphome.core import CORE from esphome.cpp_generator import MockObj from esphome.types import ConfigType +_LOGGER = logging.getLogger(__name__) + # mdns for autodiscovery AUTO_LOAD = ["mdns"] CODEOWNERS = ["@kahrendt"] @@ -46,6 +50,31 @@ CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" CONF_CODECS = "codecs" +CONF_STATIC_PAIRING_CODE = "static_pairing_code" +CONF_UNPAIRED_ACCESS = "unpaired_access" +CONF_ON_OPEN_PAIRING_WINDOW = "on_open_pairing_window" +CONF_ON_CLOSE_PAIRING_WINDOW = "on_close_pairing_window" +CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded" +CONF_ON_PAIRING_FAILED = "on_pairing_failed" + +# A static pairing code is exactly 8 decimal digits. +STATIC_PAIRING_CODE_DIGITS = 8 + + +def _validate_static_pairing_code(value: Any) -> str: + # string_strict so leading zeros survive and `!secret` works. + value = cv.string_strict(value) + if len(value) != STATIC_PAIRING_CODE_DIGITS or not ( + value.isascii() and value.isdigit() + ): + raise cv.Invalid( + f"{CONF_STATIC_PAIRING_CODE} must be exactly " + f"{STATIC_PAIRING_CODE_DIGITS} decimal digits " + '(quote the value so leading zeros are preserved, e.g. "01234567")' + ) + return value + + # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -108,6 +137,26 @@ SendspinHub = sendspin_ns.class_( ) +_CALLBACK_AUTOMATIONS = ( + automation.CallbackAutomation( + CONF_ON_OPEN_PAIRING_WINDOW, "add_on_open_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback" + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_SUCCEEDED, + "add_on_pairing_succeeded_callback", + [(cg.std_string, "server_id")], + ), + automation.CallbackAutomation( + CONF_ON_PAIRING_FAILED, + "add_on_pairing_failed_callback", + [(cg.std_string, "server_id"), (cg.StringRef, "reason")], + ), +) + + @dataclass class SendspinConfiguration: artwork_support: bool = False @@ -207,6 +256,18 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_MANUFACTURER): DEVICE_INFO_STRING, cv.Optional(CONF_MODEL): DEVICE_INFO_STRING, cv.Optional(CONF_FIRMWARE_VERSION): DEVICE_INFO_STRING, + cv.Optional(CONF_STATIC_PAIRING_CODE): cv.sensitive( + _validate_static_pairing_code + ), + cv.Optional(CONF_UNPAIRED_ACCESS): cv.boolean, + cv.Optional(CONF_ON_OPEN_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation( + {} + ), + cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}), + cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}), } ), cv.only_on_esp32, @@ -216,21 +277,42 @@ CONFIG_SCHEMA = cv.All( ) +def _has_pairing_method(config: ConfigType) -> bool: + """Whether the config gives a server any way to pair with the device.""" + return CONF_STATIC_PAIRING_CODE in config + + +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), so no server can play on this " + "device", + CONF_UNPAIRED_ACCESS, + CONF_STATIC_PAIRING_CODE, + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def _request_controller_role(config: ConfigType) -> ConfigType: """Request the controller role for the sendspin.switch action.""" request_controller_support() return config +# Selects the hub. sendspin.switch adds the controller role it needs; the pairing window actions need no role. +SENDSPIN_HUB_ACTION_SCHEMA = automation.maybe_simple_id( + cv.Schema( + { + cv.GenerateID(): cv.use_id(SendspinHub), + } + ) +) + SENDSPIN_SIMPLE_ACTION_SCHEMA = cv.All( - automation.maybe_simple_id( - cv.Schema( - { - cv.GenerateID(): cv.use_id(SendspinHub), - } - ) - ), - _request_controller_role, + SENDSPIN_HUB_ACTION_SCHEMA, _request_controller_role ) @@ -241,6 +323,19 @@ automation.register_apply_action( ) +automation.register_apply_action( + "sendspin.confirm_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("confirm_pairing_window()"), +) + +automation.register_apply_action( + "sendspin.cancel_pairing_window", + SENDSPIN_HUB_ACTION_SCHEMA, + automation.ApplyCall("cancel_pairing_window()"), +) + + async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -265,6 +360,14 @@ async def to_code(config: ConfigType) -> None: if value: cg.add(setter(value)) + 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)) + + await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS) + # sendspin-cpp library esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3") # esp_websocket_client links esp_tls even for ws:// connections. diff --git a/esphome/components/sendspin/sendspin_hub.cpp b/esphome/components/sendspin/sendspin_hub.cpp index 0348850225..927e0e49ad 100644 --- a/esphome/components/sendspin/sendspin_hub.cpp +++ b/esphome/components/sendspin/sendspin_hub.cpp @@ -26,6 +26,26 @@ static const char *const TAG = "sendspin.hub"; namespace { +// The reason passed to on_pairing_failed automations, in the protocol's own spelling. +StringRef pair_abort_reason_to_string(sendspin::SendspinPairAbortReason reason) { + using sendspin::SendspinPairAbortReason; + switch (reason) { + case SendspinPairAbortReason::ATTEMPT_TIMEOUT: + return StringRef::from_lit("attempt_timeout"); + case SendspinPairAbortReason::CONCURRENT_ATTEMPT: + return StringRef::from_lit("concurrent_attempt"); + case SendspinPairAbortReason::METHOD_NOT_SUPPORTED: + return StringRef::from_lit("method_not_supported"); + case SendspinPairAbortReason::PAIRING_CODE_MISMATCH: + return StringRef::from_lit("pairing_code_mismatch"); + case SendspinPairAbortReason::USER_CANCELLED: + return StringRef::from_lit("user_cancelled"); + case SendspinPairAbortReason::UNKNOWN: + break; + } + return StringRef::from_lit("unknown"); +} + // Zeroes through a volatile pointer so dead-store elimination cannot drop it. void secure_wipe(void *data, size_t len) { volatile auto *p = static_cast(data); @@ -134,8 +154,7 @@ void SendspinHub::setup() { this->client_->add_player(this->player_config_).set_listener(this->player_listener_); #endif - // Any server may play without pairing, as before rc1. - this->client_->set_unpaired_access_enabled(true); + this->client_->set_unpaired_access_enabled(this->default_unpaired_access_); #ifndef USE_SENDSPIN_SWITCH this->enabled_ = true; @@ -183,9 +202,11 @@ void SendspinHub::dump_config() { " Model: %s\n" " Firmware version: %s\n" " Task stack in PSRAM: %s\n" + " Static pairing code: %s\n" " Unpaired access: %s", client_id, get_mac_address_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(), this->firmware_version_, YESNO(this->task_stack_in_psram_), + YESNO(this->static_pairing_code_ != nullptr), YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled())); #ifdef USE_SENDSPIN_ARTWORK @@ -254,6 +275,20 @@ void SendspinHub::leave_group() { } } +// THREAD CONTEXT: Main loop (invoked from the sendspin.confirm_pairing_window action) +void SendspinHub::confirm_pairing_window() { + if (this->is_client_running()) { + this->client_->confirm_pairing_window(); + } +} + +// THREAD CONTEXT: Main loop (invoked from the sendspin.cancel_pairing_window action) +void SendspinHub::cancel_pairing_window() { + if (this->is_client_running()) { + this->client_->cancel_pairing_window(); + } +} + const char *SendspinHub::get_mac_address_into_buffer(std::span buf) { // The server matches this MAC against the L2 source MAC of the device's multicast traffic. // ESP-IDF derives the ethernet MAC as base+3 by default on ESP32-S3, so we cannot use the @@ -293,6 +328,11 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() { config.protocol_task_psram_stack = this->task_stack_in_psram_; config.max_pairing_records = SENDSPIN_RECORD_SLOTS; + if (this->static_pairing_code_ != nullptr) { + config.static_pairing_code_locations = {"operator"}; + config.static_pairing_code = this->static_pairing_code_; + } + return config; } @@ -321,6 +361,18 @@ void SendspinHub::on_release_high_performance() { #endif } +void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks_.call(); } + +void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); } + +void SendspinHub::on_pairing_succeeded(const std::string &server_id) { + this->pairing_succeeded_callbacks_.call(server_id); +} + +void SendspinHub::on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) { + this->pairing_failed_callbacks_.call(server_id, pair_abort_reason_to_string(reason)); +} + // --- SendspinNetworkProvider override --- // THREAD CONTEXT: Main loop (polled by start() and client_->loop()) diff --git a/esphome/components/sendspin/sendspin_hub.h b/esphome/components/sendspin/sendspin_hub.h index 43c56feca7..7c3708c374 100644 --- a/esphome/components/sendspin/sendspin_hub.h +++ b/esphome/components/sendspin/sendspin_hub.h @@ -8,6 +8,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/preferences.h" +#include "esphome/core/string_ref.h" #include "esphome/core/version.h" #ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE @@ -120,12 +121,39 @@ class SendspinHub final : public Component, /// No-op if the hub's client is not running. Must be called from the main loop thread. void leave_group(); + /// @brief Confirms a pairing attempt on the device. With no attempt waiting, it opens the pairing window for the + /// next one. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void confirm_pairing_window(); + + /// @brief Closes an open pairing window, so a waiting pairing attempt is not confirmed. + /// + /// No-op if the hub's client is not running. Must be called from the main loop thread. + void cancel_pairing_window(); + // --- Configuration setters (called from codegen) --- template void add_group_update_callback(F &&callback) { this->group_update_callbacks_.add(std::forward(callback)); } + template void add_on_open_pairing_window_callback(F &&callback) { + this->open_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_close_pairing_window_callback(F &&callback) { + this->close_pairing_window_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_succeeded_callback(F &&callback) { + this->pairing_succeeded_callbacks_.add(std::forward(callback)); + } + + template void add_on_pairing_failed_callback(F &&callback) { + this->pairing_failed_callbacks_.add(std::forward(callback)); + } + void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; } /// @brief Requests the Sendspin client, including the server, the roles and the mDNS advertisement, to start or @@ -136,6 +164,9 @@ class SendspinHub final : public Component, /// called this once. 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; } + /// @brief Returns whether the Sendspin client is running. bool is_client_running() const { return this->client_ != nullptr && this->client_->is_started(); } @@ -152,6 +183,9 @@ class SendspinHub final : public Component, void set_mdns(mdns::MDNSComponent *mdns) { this->mdns_ = mdns; } #endif + /// The static pairing code, used on every boot. + void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; } + // --- Sendspin role specific methods --- #ifdef USE_SENDSPIN_ARTWORK @@ -240,6 +274,14 @@ class SendspinHub final : public Component, void on_release_high_performance() override; + void on_open_pairing_window() override; + + void on_close_pairing_window() override; + + void on_pairing_succeeded(const std::string &server_id) override; + + void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override; + // --- SendspinNetworkProvider override --- bool is_network_ready() override; @@ -312,6 +354,14 @@ class SendspinHub final : public Component, // Callback fan-out to child components CallbackManager group_update_callbacks_{}; + // Lazy: each pairing callback is fed by an optional YAML surface. + LazyCallbackManager open_pairing_window_callbacks_{}; + LazyCallbackManager close_pairing_window_callbacks_{}; + LazyCallbackManager pairing_succeeded_callbacks_{}; + LazyCallbackManager pairing_failed_callbacks_{}; + + const char *static_pairing_code_{nullptr}; // Codegen string literal, or nullptr when not configured + 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 diff --git a/tests/component_tests/sendspin/test_pairing.py b/tests/component_tests/sendspin/test_pairing.py new file mode 100644 index 0000000000..dcfce8cece --- /dev/null +++ b/tests/component_tests/sendspin/test_pairing.py @@ -0,0 +1,77 @@ +"""Validation tests for the sendspin hub's pairing options. + +These cover the rejection branches, which a compile test cannot reach: a +`test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_STATIC_PAIRING_CODE, CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _hub_config(**overrides: Any) -> ConfigType: + """Build a minimal valid hub config, allowing field overrides.""" + config: ConfigType = {"id": "sendspin_hub_id"} + config.update(overrides) + return config + + +def test_minimal_config_is_accepted(set_core_config: SetCoreConfigCallable) -> None: + """The baseline the rejection tests vary is itself valid.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_hub_config()) + + assert CONF_STATIC_PAIRING_CODE not in config + + +@pytest.mark.parametrize("code", ["01234567", "00000000", "99999999"]) +def test_static_pairing_code_accepted( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Eight decimal digits, leading zeros included, pass through unchanged.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert ( + CONFIG_SCHEMA(_hub_config(static_pairing_code=code))[CONF_STATIC_PAIRING_CODE] + == code + ) + + +@pytest.mark.parametrize( + "code", + [ + "0123456", # too short + "012345678", # too long + "0123456a", # not all decimal digits + "0123 567", # whitespace is not a digit + "01234567", # full-width digits are not ASCII + "", + ], +) +def test_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, code: str +) -> None: + """Anything that is not exactly eight decimal digits is refused here, since the + library would otherwise refuse to start the client.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="exactly 8 decimal digits"): + CONFIG_SCHEMA(_hub_config(static_pairing_code=code)) + + +def test_unquoted_static_pairing_code_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """An unquoted YAML code arrives as an int, having already lost its leading + zeros, so it is refused rather than silently pairing with the wrong value.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid): + CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567)) diff --git a/tests/component_tests/sendspin/test_unpaired_access.py b/tests/component_tests/sendspin/test_unpaired_access.py new file mode 100644 index 0000000000..a66de471ab --- /dev/null +++ b/tests/component_tests/sendspin/test_unpaired_access.py @@ -0,0 +1,75 @@ +"""Validation tests for the hub's unpaired access policy.""" + +import logging + +import pytest + +from esphome.components.sendspin import ( + CONF_UNPAIRED_ACCESS, + CONFIG_SCHEMA as HUB_CONFIG_SCHEMA, + FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA, +) +from esphome.const import PlatformFramework +from tests.component_tests.types import SetCoreConfigCallable + +HUB_ID = "sendspin_hub_id" +NO_PAIRING_WARNING = "nothing lets a server pair" + + +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.""" + set_core_config(PlatformFramework.ESP32_IDF) + + assert CONF_UNPAIRED_ACCESS not in HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + +@pytest.mark.parametrize("value", [True, False]) +def test_hub_key_accepted(set_core_config: SetCoreConfigCallable, value: bool) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: value}) + + assert config[CONF_UNPAIRED_ACCESS] is value + + +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.""" + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING in caplog.text + + +@pytest.mark.parametrize("method", ["static_pairing_code"]) +def test_off_with_pairing_method_does_not_warn( + set_core_config: SetCoreConfigCallable, + caplog: pytest.LogCaptureFixture, + method: str, +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False} + if method == "static_pairing_code": + hub_config["static_pairing_code"] = "01234567" + config = HUB_CONFIG_SCHEMA(hub_config) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text + + +def test_on_does_not_warn( + set_core_config: SetCoreConfigCallable, caplog: pytest.LogCaptureFixture +) -> None: + set_core_config(PlatformFramework.ESP32_IDF) + config = HUB_CONFIG_SCHEMA({"id": HUB_ID}) + + with caplog.at_level(logging.WARNING): + HUB_FINAL_VALIDATE_SCHEMA(config) + + assert NO_PAIRING_WARNING not in caplog.text diff --git a/tests/components/sendspin/common-hub.yaml b/tests/components/sendspin/common-hub.yaml index bd6747ee07..147227b2f1 100644 --- a/tests/components/sendspin/common-hub.yaml +++ b/tests/components/sendspin/common-hub.yaml @@ -7,3 +7,27 @@ sendspin: manufacturer: Test Manufacturer model: Test Model firmware_version: 1.2.3 + on_open_pairing_window: + then: + - logger.log: "Pairing window requested" + - sendspin.confirm_pairing_window: + # Calling cancel_pairing_window here only compiles the action; a real config cancels from a user input. + on_close_pairing_window: + then: + - logger.log: "Pairing window prompt dismissed" + - sendspin.cancel_pairing_window: + on_pairing_succeeded: + then: + - logger.log: + format: "Paired with server %s" + args: ["server_id.c_str()"] + on_pairing_failed: + then: + - logger.log: + format: "Pairing with server %s failed: %s" + args: ["server_id.c_str()", "reason.c_str()"] + - if: + condition: + lambda: 'return reason == "user_cancelled";' + then: + - logger.log: "Pairing was cancelled" diff --git a/tests/components/sendspin/common-pairing.yaml b/tests/components/sendspin/common-pairing.yaml new file mode 100644 index 0000000000..cd9cd82bec --- /dev/null +++ b/tests/components/sendspin/common-pairing.yaml @@ -0,0 +1,14 @@ +# The hub on its own, with no other sendspin platforms, to build the pairing options. +# +# Deliberately does not include common.yaml: that file pulls in the shared hub package, and +# the point here is a separate hub configuration. sendspin depends on network, so the interface +# has to be declared locally instead. +wifi: + ap: + +# Also the static-pairing-code build. +sendspin: + id: sendspin_hub_id + static_pairing_code: "01234567" + # Covers the hub's unpaired access option. + unpaired_access: false diff --git a/tests/components/sendspin/test-pairing.esp32-idf.yaml b/tests/components/sendspin/test-pairing.esp32-idf.yaml new file mode 100644 index 0000000000..5862e841df --- /dev/null +++ b/tests/components/sendspin/test-pairing.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + sendspin: !include common-pairing.yaml