[sendspin] Add an unpaired access switch (#20350)

This commit is contained in:
Kevin Ahrendt
2026-10-09 13:45:34 +13:00
committed by Jesse Hills
parent baf1682635
commit ab66b20944
9 changed files with 274 additions and 43 deletions
+16 -5
View File
@@ -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))
+16 -8
View File
@@ -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_() {
+7 -6
View File
@@ -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<bool> enabled_;
std::optional<bool> unpaired_access_;
// Device information sent in the `client/hello` message. Defaults apply when neither the
// sendspin configuration nor the project information supplies a value.
+77 -11
View File
@@ -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)
@@ -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
@@ -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
@@ -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}
@@ -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
@@ -5,3 +5,6 @@ switch:
- platform: sendspin
name: "Sendspin Enabled"
type: enabled
- platform: sendspin
name: "Sendspin Unpaired Access"
type: unpaired_access