[sendspin] Add the dynamic pairing code (#20349)

This commit is contained in:
Kevin Ahrendt
2026-10-08 13:36:12 -04:00
committed by GitHub
parent 4b2f8122e3
commit 9c79fa7430
13 changed files with 271 additions and 19 deletions
+43 -3
View File
@@ -54,6 +54,8 @@ 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_DISPLAY_PAIRING_CODE = "on_display_pairing_code"
CONF_ON_CLEAR_PAIRING_CODE = "on_clear_pairing_code"
CONF_ON_PAIRING_SUCCEEDED = "on_pairing_succeeded"
CONF_ON_PAIRING_FAILED = "on_pairing_failed"
@@ -144,6 +146,14 @@ _CALLBACK_AUTOMATIONS = (
automation.CallbackAutomation(
CONF_ON_CLOSE_PAIRING_WINDOW, "add_on_close_pairing_window_callback"
),
automation.CallbackAutomation(
CONF_ON_DISPLAY_PAIRING_CODE,
"add_on_display_pairing_code_callback",
[(cg.std_string, "code")],
),
automation.CallbackAutomation(
CONF_ON_CLEAR_PAIRING_CODE, "add_on_clear_pairing_code_callback"
),
automation.CallbackAutomation(
CONF_ON_PAIRING_SUCCEEDED,
"add_on_pairing_succeeded_callback",
@@ -164,6 +174,7 @@ class SendspinConfiguration:
metadata_support: bool = False
player_support: bool = False
visualizer_support: bool = False
pairing_code_display_support: bool = False
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -200,6 +211,11 @@ def request_visualizer_support() -> None:
_get_data().visualizer_support = True
def request_pairing_code_display_support() -> None:
"""Mark that the device can emit a dynamic pairing code (e.g. a pairing_code text sensor)."""
_get_data().pairing_code_display_support = True
def register_artwork_preference(config: ConfigType) -> int:
"""Register an artwork slot preference and return the slot it was given.
@@ -266,6 +282,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_ON_CLOSE_PAIRING_WINDOW): automation.validate_automation(
{}
),
cv.Optional(CONF_ON_DISPLAY_PAIRING_CODE): automation.validate_automation(
{}
),
cv.Optional(CONF_ON_CLEAR_PAIRING_CODE): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_SUCCEEDED): automation.validate_automation({}),
cv.Optional(CONF_ON_PAIRING_FAILED): automation.validate_automation({}),
}
@@ -277,16 +297,33 @@ CONFIG_SCHEMA = cv.All(
)
def _offers_dynamic_pairing_code(config: ConfigType) -> bool:
"""Whether the device can show a dynamic pairing code, so dynamic_pairing_code is advertised."""
return bool(
config.get(CONF_ON_DISPLAY_PAIRING_CODE)
or _get_data().pairing_code_display_support
)
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
return CONF_STATIC_PAIRING_CODE in config or _offers_dynamic_pairing_code(config)
def _final_validate(config: ConfigType) -> ConfigType:
dynamic_code = _offers_dynamic_pairing_code(config)
# The protocol allows only one pairing code method.
if dynamic_code and CONF_STATIC_PAIRING_CODE in config:
raise cv.Invalid(
f"'{CONF_STATIC_PAIRING_CODE}' cannot be used with a dynamic pairing code "
f"({CONF_ON_DISPLAY_PAIRING_CODE} or a pairing_code text sensor), since only "
"one pairing code method can be offered",
path=[CONF_STATIC_PAIRING_CODE],
)
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",
"'%s' is off but nothing lets a server pair (%s or a dynamic pairing code), so no "
"server can play on this device",
CONF_UNPAIRED_ACCESS,
CONF_STATIC_PAIRING_CODE,
)
@@ -366,6 +403,9 @@ async def to_code(config: ConfigType) -> None:
if (unpaired_access := config.get(CONF_UNPAIRED_ACCESS)) is not None:
cg.add(var.set_default_unpaired_access(unpaired_access))
if _offers_dynamic_pairing_code(config):
cg.add(var.set_pairing_code_display_supported(True))
await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
# sendspin-cpp library
+29 -4
View File
@@ -202,12 +202,12 @@ 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",
" Unpaired access: %s\n"
" Pairing code method: %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()));
YESNO(this->client_ != nullptr && this->client_->is_unpaired_access_enabled()),
this->pairing_code_method_());
#ifdef USE_SENDSPIN_ARTWORK
// Slot indices come from the order the image platform entries were declared, so the log is the
@@ -311,6 +311,17 @@ const char *SendspinHub::get_mac_address_into_buffer(std::span<char, MAC_ADDRESS
return buf.data();
}
// Validation rejects a static code together with the dynamic code, so at most one is set.
const char *SendspinHub::pairing_code_method_() const {
if (this->pairing_code_display_supported_) {
return LOG_STR_LITERAL("dynamic");
}
if (this->static_pairing_code_ != nullptr) {
return LOG_STR_LITERAL("static");
}
return LOG_STR_LITERAL("none");
}
const char *SendspinHub::get_product_name_() const {
return this->model_ != nullptr ? this->model_ : App.get_name().c_str();
}
@@ -328,6 +339,13 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
config.protocol_task_psram_stack = this->task_stack_in_psram_;
config.max_pairing_records = SENDSPIN_RECORD_SLOTS;
// The dynamic code needs a channel and a format. Only digits, since automations get the bare string and could not
// tell a QR code token apart.
if (this->pairing_code_display_supported_) {
config.pairing_code_out_channels = {sendspin::SendspinPairingCodeChannel::DISPLAY};
config.pairing_code_formats = {sendspin::SendspinPairingCodeFormat::DIGITS};
}
if (this->static_pairing_code_ != nullptr) {
config.static_pairing_code_locations = {"operator"};
config.static_pairing_code = this->static_pairing_code_;
@@ -365,6 +383,13 @@ void SendspinHub::on_open_pairing_window() { this->open_pairing_window_callbacks
void SendspinHub::on_close_pairing_window() { this->close_pairing_window_callbacks_.call(); }
// Only digits are offered, and the library refuses an activation in any other format.
void SendspinHub::on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat /*format*/) {
this->display_pairing_code_callbacks_.call(code);
}
void SendspinHub::on_clear_pairing_code() { this->clear_pairing_code_callbacks_.call(); }
void SendspinHub::on_pairing_succeeded(const std::string &server_id) {
this->pairing_succeeded_callbacks_.call(server_id);
}
@@ -146,6 +146,14 @@ class SendspinHub final : public Component,
this->close_pairing_window_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_display_pairing_code_callback(F &&callback) {
this->display_pairing_code_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_on_clear_pairing_code_callback(F &&callback) {
this->clear_pairing_code_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));
}
@@ -186,6 +194,10 @@ class SendspinHub final : public Component,
/// The static pairing code, used on every boot.
void set_static_pairing_code(const char *code) { this->static_pairing_code_ = code; }
/// Set when on_display_pairing_code or a pairing_code text sensor is configured, so the device offers the dynamic
/// pairing code.
void set_pairing_code_display_supported(bool supported) { this->pairing_code_display_supported_ = supported; }
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_ARTWORK
@@ -258,6 +270,8 @@ class SendspinHub final : public Component,
/// @brief Returns the product name reported to the server: the configured model, or the device name.
const char *get_product_name_() const;
const char *pairing_code_method_() const;
/// @brief Writes the active network interface's MAC, in lowercase, into @p buf and returns its data pointer.
/// Uses the ethernet MAC if ethernet is configured, otherwise the base MAC (used by wifi).
static const char *get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
@@ -278,6 +292,10 @@ class SendspinHub final : public Component,
void on_close_pairing_window() override;
void on_display_pairing_code(const std::string &code, sendspin::SendspinPairingCodeFormat format) override;
void on_clear_pairing_code() override;
void on_pairing_succeeded(const std::string &server_id) override;
void on_pairing_failed(const std::string &server_id, sendspin::SendspinPairAbortReason reason) override;
@@ -357,10 +375,13 @@ class SendspinHub final : public Component,
// 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 &)> display_pairing_code_callbacks_{};
LazyCallbackManager<void()> clear_pairing_code_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 pairing_code_display_supported_{false};
bool default_unpaired_access_{true};
bool task_stack_in_psram_{false};
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
@@ -4,16 +4,29 @@ import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TYPE
from esphome.types import ConfigType
from .. import CONF_SENDSPIN_ID, SendspinHub, request_metadata_support, sendspin_ns
from .. import (
CONF_SENDSPIN_ID,
SendspinHub,
request_metadata_support,
request_pairing_code_display_support,
sendspin_ns,
)
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
CONF_PAIRING_CODE = "pairing_code"
SendspinTextSensor = sendspin_ns.class_(
"SendspinTextSensor",
text_sensor.TextSensor,
cg.Component,
)
SendspinPairingCodeTextSensor = sendspin_ns.class_(
"SendspinPairingCodeTextSensor",
text_sensor.TextSensor,
cg.Component,
)
SendspinTextMetadataTypes = sendspin_ns.enum("SendspinTextMetadataTypes", is_class=True)
SENDSPIN_TEXT_METADATA_TYPES = {
@@ -26,18 +39,29 @@ SENDSPIN_TEXT_METADATA_TYPES = {
def _request_roles(config: ConfigType) -> ConfigType:
"""Request the necessary Sendspin roles for the text sensor."""
request_metadata_support()
if config[CONF_TYPE] == CONF_PAIRING_CODE:
# A display surface for the dynamic code, so advertise dynamic_pairing_code.
request_pairing_code_display_support()
else:
request_metadata_support()
return config
_HUB_ID_SCHEMA = cv.Schema({cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub)})
_METADATA_SCHEMA = text_sensor.text_sensor_schema(SendspinTextSensor).extend(
_HUB_ID_SCHEMA
)
CONFIG_SCHEMA = cv.All(
text_sensor.text_sensor_schema().extend(
cv.typed_schema(
{
cv.GenerateID(): cv.declare_id(SendspinTextSensor),
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Required(CONF_TYPE): cv.enum(SENDSPIN_TEXT_METADATA_TYPES),
}
**dict.fromkeys(SENDSPIN_TEXT_METADATA_TYPES, _METADATA_SCHEMA),
CONF_PAIRING_CODE: text_sensor.text_sensor_schema(
SendspinPairingCodeTextSensor
).extend(_HUB_ID_SCHEMA),
},
key=CONF_TYPE,
),
cv.only_on_esp32,
_request_roles,
@@ -50,4 +74,7 @@ async def to_code(config: ConfigType) -> None:
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
await text_sensor.register_text_sensor(var, config)
cg.add(var.set_metadata_type(config[CONF_TYPE]))
if (
metadata_type := SENDSPIN_TEXT_METADATA_TYPES.get(config[CONF_TYPE])
) is not None:
cg.add(var.set_metadata_type(metadata_type))
@@ -1,8 +1,10 @@
#include "sendspin_text_sensor.h"
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR)
#ifdef USE_SENDSPIN_METADATA
#include <sendspin/metadata_role.h>
#endif
#include <string>
@@ -10,6 +12,7 @@ namespace esphome::sendspin_ {
ESPHOME_LOG_TAG(TAG, "sendspin.text_sensor");
#ifdef USE_SENDSPIN_METADATA
void SendspinTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Sendspin", this); }
// A field is nullopt when the server has not provided it or has explicitly cleared it. Both mean there is nothing to
@@ -50,6 +53,16 @@ void SendspinTextSensor::publish_if_changed_(const char *value) {
this->publish_state(value);
}
}
#endif // USE_SENDSPIN_METADATA
void SendspinPairingCodeTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Pairing Code", this); }
// THREAD CONTEXT: Main loop. The registered callbacks also fire on the main loop (SendspinHub dispatches them from
// its loop()).
void SendspinPairingCodeTextSensor::setup() {
this->parent_->add_on_display_pairing_code_callback([this](const std::string &code) { this->publish_state(code); });
this->parent_->add_on_clear_pairing_code_callback([this]() { this->publish_state(""); });
}
} // namespace esphome::sendspin_
@@ -2,15 +2,19 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_TEXT_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/text_sensor/text_sensor.h"
#ifdef USE_SENDSPIN_METADATA
#include <sendspin/metadata_role.h>
#endif
namespace esphome::sendspin_ {
#ifdef USE_SENDSPIN_METADATA
enum class SendspinTextMetadataTypes {
TITLE,
ARTIST,
@@ -31,6 +35,13 @@ class SendspinTextSensor final : public SendspinChild, public text_sensor::TextS
SendspinTextMetadataTypes metadata_type_;
};
#endif // USE_SENDSPIN_METADATA
class SendspinPairingCodeTextSensor final : public SendspinChild, public text_sensor::TextSensor {
public:
void dump_config() override;
void setup() override;
};
} // namespace esphome::sendspin_
#endif
+19 -1
View File
@@ -9,7 +9,12 @@ 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.components.sendspin import (
CONF_STATIC_PAIRING_CODE,
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
request_pairing_code_display_support,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
@@ -75,3 +80,16 @@ def test_unquoted_static_pairing_code_rejected(
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_hub_config(static_pairing_code=1234567))
def test_static_code_with_dynamic_code_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Only one pairing code method can be offered, so a static code next to the dynamic
code would be silently ignored."""
set_core_config(PlatformFramework.ESP32_IDF)
request_pairing_code_display_support()
config = CONFIG_SCHEMA(_hub_config(static_pairing_code="01234567"))
with pytest.raises(cv.Invalid, match="cannot be used with a dynamic pairing code"):
FINAL_VALIDATE_SCHEMA(config)
@@ -0,0 +1,60 @@
"""Validation tests for the sendspin text sensor platform.
These cover behavior a compile test cannot observe: which roles a sensor type requests.
"""
from typing import Any
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import _get_data
from esphome.components.sendspin.text_sensor import (
CONF_PAIRING_CODE,
CONFIG_SCHEMA,
SENDSPIN_TEXT_METADATA_TYPES,
)
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _sensor_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid text sensor config, allowing field overrides."""
config: ConfigType = {"name": "Sendspin Text", "type": "title"}
config.update(overrides)
return config
@pytest.mark.parametrize("sensor_type", sorted(SENDSPIN_TEXT_METADATA_TYPES))
def test_metadata_types_request_metadata_role(
set_core_config: SetCoreConfigCallable, sensor_type: str
) -> None:
"""Metadata sensors need the metadata role, and say nothing about pairing codes."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_sensor_config(type=sensor_type))
assert _get_data().metadata_support is True
assert _get_data().pairing_code_display_support is False
def test_pairing_code_requests_code_display(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A pairing_code sensor is a way to show the dynamic code, so it alone makes the
hub offer it. It needs no role, so a pairing-only config builds without metadata."""
set_core_config(PlatformFramework.ESP32_IDF)
CONFIG_SCHEMA(_sensor_config(type=CONF_PAIRING_CODE))
assert _get_data().pairing_code_display_support is True
assert _get_data().metadata_support is False
def test_unknown_type_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""A misspelled type must fail rather than fall through to a default."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(_sensor_config(type="pairing_secret"))
@@ -8,6 +8,7 @@ from esphome.components.sendspin import (
CONF_UNPAIRED_ACCESS,
CONFIG_SCHEMA as HUB_CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA as HUB_FINAL_VALIDATE_SCHEMA,
request_pairing_code_display_support,
)
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@@ -45,7 +46,7 @@ def test_off_without_pairing_method_warns(
assert NO_PAIRING_WARNING in caplog.text
@pytest.mark.parametrize("method", ["static_pairing_code"])
@pytest.mark.parametrize("method", ["static_pairing_code", "pairing_code"])
def test_off_with_pairing_method_does_not_warn(
set_core_config: SetCoreConfigCallable,
caplog: pytest.LogCaptureFixture,
@@ -55,6 +56,8 @@ def test_off_with_pairing_method_does_not_warn(
hub_config = {"id": HUB_ID, CONF_UNPAIRED_ACCESS: False}
if method == "static_pairing_code":
hub_config["static_pairing_code"] = "01234567"
else:
request_pairing_code_display_support()
config = HUB_CONFIG_SCHEMA(hub_config)
with caplog.at_level(logging.WARNING):
+11
View File
@@ -7,6 +7,9 @@ sendspin:
manufacturer: Test Manufacturer
model: Test Model
firmware_version: 1.2.3
# No static_pairing_code here: the on_display_pairing_code automation below makes
# this a dynamic-pairing-code device, and validation rejects both together.
# common-pairing.yaml covers the static-code build.
on_open_pairing_window:
then:
- logger.log: "Pairing window requested"
@@ -16,6 +19,14 @@ sendspin:
then:
- logger.log: "Pairing window prompt dismissed"
- sendspin.cancel_pairing_window:
on_display_pairing_code:
then:
- logger.log:
format: "Pairing code: %s"
args: ["code.c_str()"]
on_clear_pairing_code:
then:
- logger.log: "Pairing code cleared"
on_pairing_succeeded:
then:
- logger.log:
@@ -0,0 +1,17 @@
# The pairing code text sensor on its own, with no metadata text sensor to pull in the
# metadata role. This is the build where USE_SENDSPIN_METADATA is undefined while the text
# sensor platform is still compiled, which is guarded separately in sendspin_text_sensor.{h,cpp}.
#
# Deliberately does not include common.yaml: that file pulls in the shared hub package, and
# the point here is the narrower configuration. sendspin depends on network, so the interface
# has to be declared locally instead.
wifi:
ap:
sendspin:
id: sendspin_hub_id
text_sensor:
- platform: sendspin
name: "Pairing Code"
type: pairing_code
@@ -14,3 +14,7 @@ text_sensor:
- platform: sendspin
name: "Album Artist"
type: album_artist
- platform: sendspin
name: "Pairing Code"
type: pairing_code
entity_category: diagnostic
@@ -0,0 +1,2 @@
packages:
sendspin: !include common-text_sensor-pairing.yaml