[sendspin] Add the static pairing code, pairing window and unpaired access option (#20348)

This commit is contained in:
Kevin Ahrendt
2026-10-09 13:45:34 +13:00
committed by Jesse Hills
parent 1d86cc39e9
commit eea11834f8
8 changed files with 407 additions and 10 deletions
+111 -8
View File
@@ -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.
+54 -2
View File
@@ -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<volatile uint8_t *>(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<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> 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())
@@ -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<typename F> void add_group_update_callback(F &&callback) {
this->group_update_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_open_pairing_window_callback(F &&callback) {
this->open_pairing_window_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_close_pairing_window_callback(F &&callback) {
this->close_pairing_window_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_pairing_succeeded_callback(F &&callback) {
this->pairing_succeeded_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_pairing_failed_callback(F &&callback) {
this->pairing_failed_callbacks_.add(std::forward<F>(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<void(const sendspin::GroupUpdateObject &)> group_update_callbacks_{};
// Lazy: each pairing callback is fed by an optional YAML surface.
LazyCallbackManager<void()> open_pairing_window_callbacks_{};
LazyCallbackManager<void()> close_pairing_window_callbacks_{};
LazyCallbackManager<void(const std::string &)> pairing_succeeded_callbacks_{};
LazyCallbackManager<void(const std::string &, StringRef)> 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
@@ -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))
@@ -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
+24
View File
@@ -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"
@@ -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
@@ -0,0 +1,2 @@
packages:
sendspin: !include common-pairing.yaml