[sendspin] Update to sendspin-cpp v0.9.3 (#20347)

This commit is contained in:
Kevin Ahrendt
2026-10-08 06:33:46 -10:00
committed by GitHub
parent 3055343b6d
commit b1c145dd5f
21 changed files with 303 additions and 125 deletions
+12 -4
View File
@@ -76,7 +76,6 @@ OPUS_SAMPLE_RATE = 48000
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
@@ -211,6 +210,8 @@ CONFIG_SCHEMA = cv.All(
}
),
cv.only_on_esp32,
# sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0 cannot use.
cv.only_with_framework("esp-idf"),
_request_high_performance_networking,
)
@@ -265,7 +266,9 @@ async def to_code(config: ConfigType) -> None:
cg.add(setter(value))
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.8.0")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.9.3")
# esp_websocket_client links esp_tls even for ws:// connections.
esp32.request_tls()
cg.add_define("USE_SENDSPIN", True) # for MDNS
@@ -276,8 +279,9 @@ async def to_code(config: ConfigType) -> None:
data = _get_data()
# The color role is not yet wired up in ESPHome; disable it in the library for now.
# The color and source roles are not yet wired up in ESPHome; disable them in the library for now.
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_SOURCE", False)
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
@@ -351,7 +355,8 @@ async def to_code(config: ConfigType) -> None:
("audio_formats", audio_format_structs),
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
# The released YAML key name is kept for compatibility.
("initial_output_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
("psram_stack", psram_stack),
]
if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None:
@@ -366,6 +371,9 @@ async def to_code(config: ConfigType) -> None:
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
if not data.player_support or CODEC_OPUS not in data.player_config[CONF_CODECS]:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_OPUS", False)
if data.visualizer_support:
cg.add_define("USE_SENDSPIN_VISUALIZER", True)
else:
+13 -3
View File
@@ -1,5 +1,7 @@
"""Sendspin image platform."""
from typing import Any
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
@@ -21,7 +23,6 @@ from esphome.types import ConfigType
from .. import (
CONF_DISPLAY_OFFSET,
CONF_SENDSPIN_ID,
IMAGE_FORMAT_BMP,
IMAGE_FORMAT_JPEG,
IMAGE_FORMAT_PNG,
IMAGE_SOURCE_ALBUM,
@@ -57,9 +58,16 @@ _FORMAT_TO_SENDSPIN_ENUM = {
"JPEG": IMAGE_FORMAT_JPEG,
"JPG": IMAGE_FORMAT_JPEG,
"PNG": IMAGE_FORMAT_PNG,
"BMP": IMAGE_FORMAT_BMP,
}
# Remove before 2027.4.0
def _reject_bmp(value: Any) -> Any:
if isinstance(value, str) and value.upper() == "BMP":
raise cv.Invalid("BMP artwork is no longer supported, use JPEG or PNG instead.")
return value
# The library's SendspinImageSource::NONE is its internal "unset" sentinel; a slot advertising it
# would never receive artwork while still paying for two frame buffers, so it is not offered here.
IMAGE_SOURCES = {
@@ -121,7 +129,9 @@ CONFIG_SCHEMA = cv.All(
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
# Narrow runtime_image's format list to what the library can request, so the
# accepted set and the enum map below cannot drift apart.
cv.Required(CONF_FORMAT): cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True),
cv.Required(CONF_FORMAT): cv.All(
_reject_bmp, cv.one_of(*_FORMAT_TO_SENDSPIN_ENUM, upper=True)
),
cv.Required(CONF_RESIZE): cv.dimensions,
cv.Required(CONF_CURRENT_IMAGE): _IMAGE_SCHEMA,
cv.Optional(CONF_TRANSITION_IMAGE): _IMAGE_SCHEMA,
@@ -1,6 +1,6 @@
#include "sendspin_image.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/core/log.h"
@@ -125,7 +125,7 @@ bool SendspinImageSlot::decode_frame_(const uint8_t *data, size_t length, const
}
// A decode that asked for other dimensions had the buffer taken away from it, so it painted
// nothing (or stopped partway). JPEG and BMP report that as an error above; PNG carries on
// nothing (or stopped partway). JPEG reports that as an error above; PNG carries on
// regardless, so the frame is dropped here.
return this->decode_sink_.decoded_into(target);
}
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/components/image/image.h"
#include "esphome/components/runtime_image/runtime_image.h"
@@ -29,7 +29,7 @@ class ArtworkDecodeSink : public runtime_image::RuntimeImage {
/// @brief True when the decode ended with the given buffer still in place.
///
/// An external buffer is dropped rather than resized, so a decode that wanted other dimensions
/// leaves the sink holding nothing. The JPEG and BMP decoders report that as a decode error, but
/// leaves the sink holding nothing. The JPEG decoder reports that as a decode error, but
/// the PNG decoder ignores it and reports success, so the outcome is checked here as well.
bool decoded_into(const uint8_t *buffer) const { return this->buffer_ == buffer; }
};
@@ -1,6 +1,6 @@
#include "sendspin_media_player.h"
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/core/application.h"
#include "esphome/core/log.h"
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#if defined(USE_ESP_IDF) && defined(USE_MEDIA_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/components/media_player/media_player.h"
#include "esphome/components/sendspin/sendspin_hub.h"
@@ -11,7 +11,9 @@ from esphome.const import (
from esphome.types import ConfigType
from .. import (
CODEC_FLAC,
CODEC_OPUS,
CODEC_PCM,
CODECS,
CONF_CODECS,
CONF_DECODE_MEMORY,
@@ -58,6 +60,11 @@ def _resolve_codecs(config: ConfigType) -> ConfigType:
f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}",
path=[CONF_CODECS],
)
if CODEC_FLAC not in codecs and CODEC_PCM not in codecs:
raise cv.Invalid(
f"Codecs must include '{CODEC_FLAC}' or '{CODEC_PCM}'",
path=[CONF_CODECS],
)
return config
@@ -1,6 +1,6 @@
#include "sendspin_media_source.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/audio/audio.h"
#include "esphome/core/log.h"
@@ -24,7 +24,7 @@ void SendspinMediaSource::setup() {
// Push cached states to player role. They may have been set before setup() ran.
this->player_role_->update_volume(std::roundf(this->cached_volume_ * 100.0f));
this->player_role_->update_muted(this->cached_muted_);
this->player_role_->set_static_delay_adjustable(this->static_delay_adjustable_);
this->player_role_->set_output_delay_adjustable(this->static_delay_adjustable_);
}
void SendspinMediaSource::dump_config() {
@@ -35,7 +35,7 @@ void SendspinMediaSource::dump_config() {
void SendspinMediaSource::set_static_delay_adjustable(bool adjustable) {
this->static_delay_adjustable_ = adjustable;
if (this->player_role_) {
this->player_role_->set_static_delay_adjustable(adjustable);
this->player_role_->set_output_delay_adjustable(adjustable);
}
}
@@ -102,8 +102,8 @@ void SendspinMediaSource::handle_command(media_source::MediaSourceCommand comman
if (!this->pending_start_) {
// Ignore stop commands if we have a pending start, since the orchestrator may send a stop command before
// play_uri
ESP_LOGD(TAG, "Received STOP command, updating Sendspin state to EXTERNAL_SOURCE");
this->parent_->update_state(sendspin::SendspinClientState::EXTERNAL_SOURCE);
ESP_LOGD(TAG, "Received STOP command, leaving the Sendspin group");
this->parent_->leave_group();
}
break;
}
@@ -185,8 +185,6 @@ size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_start() {
this->parent_->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
if (!this->pending_start_) {
// Dedup rapid on_stream_start() calls
this->pending_start_ = true;
@@ -211,4 +209,4 @@ void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(mute
} // namespace esphome::sendspin_
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
#endif // USE_ESP_IDF && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/sendspin/sendspin_hub.h"
+167 -65
View File
@@ -1,6 +1,6 @@
#include "sendspin_hub.h"
#ifdef USE_ESP32
#ifdef USE_ESP_IDF
#include "esphome/components/network/util.h"
#ifdef USE_ETHERNET
@@ -17,26 +17,98 @@
#include <esp_log.h>
#include <cstring>
#include <memory>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.hub";
namespace {
// 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);
for (size_t i = 0; i < len; ++i) {
p[i] = 0;
}
}
// Call once per key, from setup().
ESPPreferenceObject make_blob_pref(const std::string &name, size_t size) {
return global_preferences->make_preference(size, fnv1a_hash("sendspin_" + name));
}
// Loads straight into the returned buffer, which the library wipes after use. A failed load can
// leave part of a stored private key or PSK behind, so that path wipes it here. This only covers
// this buffer: the preference backend keeps its own copy of a pending write until it syncs.
std::optional<std::vector<uint8_t>> read_blob(ESPPreferenceObject &pref, size_t size) {
std::vector<uint8_t> out(size);
if (!pref.load(out.data(), out.size())) {
secure_wipe(out.data(), out.size());
return std::nullopt;
}
return out;
}
// The library always writes a key's fixed size; any other length is misuse and is not stored.
bool write_blob(ESPPreferenceObject &pref, const char *key, size_t size, const uint8_t *data, size_t len) {
if (len != size) {
ESP_LOGW(TAG, "\"%s\" blob of %zu bytes does not match its size of %zu; rejecting write", key, len, size);
return false;
}
if (!pref.save(data, len)) {
ESP_LOGW(TAG, "Failed to persist \"%s\" blob (%zu bytes)", key, len);
return false;
}
return true;
}
// "rec_<n>" to n; nullopt for any other key or a slot with no storage here.
std::optional<size_t> parse_record_slot(const std::string &key) {
const char *const prefix = sendspin::persistence_keys::RECORD_SLOT_PREFIX;
const size_t prefix_len = std::strlen(prefix);
if (key.size() <= prefix_len || key.compare(0, prefix_len, prefix) != 0) {
return std::nullopt;
}
size_t slot = 0;
for (size_t i = prefix_len; i < key.size(); i++) {
const char c = key[i];
if (c < '0' || c > '9') {
return std::nullopt;
}
slot = slot * 10 + static_cast<size_t>(c - '0');
if (slot >= SENDSPIN_RECORD_SLOTS) {
return std::nullopt;
}
}
return slot;
}
} // namespace
#ifdef USE_SENDSPIN_ARTWORK
// Indexed by the library enums, which start at zero and are contiguous.
static const char *const IMAGE_SOURCE_NAMES[] = {"ALBUM", "ARTIST", "NONE"};
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG", "BMP"};
static const char *const IMAGE_FORMAT_NAMES[] = {"JPEG", "PNG"};
#endif
void SendspinHub::setup() {
auto config = this->build_client_config_();
this->client_ = std::make_unique<sendspin::SendspinClient>(std::move(config));
// Set up persistence (preferences must be initialized before providers are added to the client)
this->last_played_server_pref_ =
global_preferences->make_preference<LastPlayedServerPref>(fnv1a_hash("sendspin_last_played"));
#ifdef USE_SENDSPIN_PLAYER
this->static_delay_pref_ = global_preferences->make_preference<StaticDelayPref>(fnv1a_hash("sendspin_static_delay"));
#endif
// Set up persistence (preferences must be initialized before providers are added to the client). These key names
// are frozen; see PREFERENCE KEYS in sendspin_hub.h.
namespace keys = sendspin::persistence_keys;
this->keypair_pref_ = make_blob_pref("keypair", keys::KEYPAIR_SIZE);
this->pairing_psk_pref_ = make_blob_pref("pair_psk", keys::PAIRING_PSK_SIZE);
this->last_played_pref_ = make_blob_pref("last_played", keys::LAST_PLAYED_SIZE);
// Released firmware's name, so a calibrated delay survives.
this->output_delay_pref_ = make_blob_pref("static_delay", keys::OUTPUT_DELAY_SIZE);
for (size_t slot = 0; slot < SENDSPIN_RECORD_SLOTS; slot++) {
this->record_slot_prefs_[slot] = make_blob_pref(keys::record_slot_key(slot), keys::RECORD_SLOT_SIZE);
}
this->record_order_pref_ = make_blob_pref(keys::RECORD_ORDER, SENDSPIN_RECORD_SLOTS);
// Wire providers and client listener
this->client_->set_listener(this);
@@ -62,6 +134,9 @@ 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);
#ifndef USE_SENDSPIN_SWITCH
this->enabled_ = true;
#endif
@@ -83,17 +158,35 @@ void SendspinHub::loop() {
#endif
}
// Sends each server a goodbye and writes what the client still owed its provider. Wi-Fi sets up
// before this hub, so it shuts down after it and the goodbyes can still go out.
void SendspinHub::on_shutdown() {
// Keeps loop() from starting the client again.
this->enabled_ = false;
if (this->is_client_running()) {
this->client_->stop();
}
}
void SendspinHub::dump_config() {
// client_id exists only once start() has run.
const char *client_id = "(unavailable)";
if (this->client_ != nullptr && !this->client_->client_id().empty()) {
client_id = this->client_->client_id().c_str();
}
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Sendspin Hub:\n"
" Client ID: %s\n"
" MAC address: %s\n"
" Manufacturer: %s\n"
" Model: %s\n"
" Firmware version: %s\n"
" Task stack in PSRAM: %s",
get_client_id_into_buffer(mac_buf), this->manufacturer_, this->get_product_name_(),
this->firmware_version_, YESNO(this->task_stack_in_psram_));
" Task stack in PSRAM: %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->client_ != nullptr && this->client_->is_unpaired_access_enabled()));
#ifdef USE_SENDSPIN_ARTWORK
// Slot indices come from the order the image platform entries were declared, so the log is the
@@ -154,22 +247,33 @@ void SendspinHub::disconnect_from_server(sendspin::SendspinGoodbyeReason reason)
}
// THREAD CONTEXT: Main loop (invoked from Sendspin components)
void SendspinHub::update_state(sendspin::SendspinClientState state) {
if (this->is_client_running()) {
this->client_->update_state(state);
void SendspinHub::leave_group() {
if (this->is_client_running() &&
this->client_->get_group_state().playback_state == sendspin::SendspinPlaybackState::PLAYING) {
this->client_->leave();
}
}
const char *SendspinHub::get_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) {
// The server matches client_id against the L2 source MAC of the device's multicast traffic.
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
// eFuse base MAC when ethernet is the active interface.
#ifdef USE_ETHERNET
if (ethernet::global_eth_component != nullptr) {
return ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
ethernet::global_eth_component->get_eth_mac_address_pretty_into_buffer(buf);
} else {
get_mac_address_pretty_into_buffer(buf);
}
#else
get_mac_address_pretty_into_buffer(buf);
#endif
return get_mac_address_pretty_into_buffer(buf);
// The pretty format is uppercase, but SendspinClientConfig::mac_address must be lowercase.
for (char &c : buf) {
if (c >= 'A' && c <= 'F') {
c += 'a' - 'A';
}
}
return buf.data();
}
const char *SendspinHub::get_product_name_() const {
@@ -180,12 +284,14 @@ sendspin::SendspinClientConfig SendspinHub::build_client_config_() {
sendspin::SendspinClientConfig config;
char mac_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
config.client_id = SendspinHub::get_client_id_into_buffer(mac_buf);
config.mac_address = SendspinHub::get_mac_address_into_buffer(mac_buf);
config.name = App.get_friendly_name();
config.product_name = this->get_product_name_();
config.manufacturer = this->manufacturer_;
config.software_version = this->firmware_version_;
config.httpd_psram_stack = this->task_stack_in_psram_;
config.protocol_task_psram_stack = this->task_stack_in_psram_;
config.max_pairing_records = SENDSPIN_RECORD_SLOTS;
return config;
}
@@ -217,33 +323,56 @@ void SendspinHub::on_release_high_performance() {
// --- SendspinNetworkProvider override ---
// THREAD CONTEXT: Main loop (polled by client_->loop())
// THREAD CONTEXT: Main loop (polled by start() and client_->loop())
bool SendspinHub::is_network_ready() { return network::is_connected(); }
// --- SendspinPersistenceProvider overrides ---
// THREAD CONTEXT: Main loop (the library makes every provider call there, including from start() and stop())
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
bool SendspinHub::save_last_server_hash(uint32_t hash) {
LastPlayedServerPref pref{.server_id_hash = hash};
bool ok = this->last_played_server_pref_.save(&pref);
if (ok) {
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash);
} else {
ESP_LOGW(TAG, "Failed to persist last played server hash");
std::pair<ESPPreferenceObject *, size_t> SendspinHub::pref_for_key_(const std::string &key) {
namespace keys = sendspin::persistence_keys;
if (auto slot = parse_record_slot(key); slot.has_value()) {
return {&this->record_slot_prefs_[*slot], keys::RECORD_SLOT_SIZE};
}
return ok;
if (key == keys::RECORD_ORDER) {
return {&this->record_order_pref_, SENDSPIN_RECORD_SLOTS};
}
if (key == keys::KEYPAIR) {
return {&this->keypair_pref_, keys::KEYPAIR_SIZE};
}
if (key == keys::PAIRING_PSK) {
return {&this->pairing_psk_pref_, keys::PAIRING_PSK_SIZE};
}
if (key == keys::LAST_PLAYED) {
return {&this->last_played_pref_, keys::LAST_PLAYED_SIZE};
}
if (key == keys::OUTPUT_DELAY) {
return {&this->output_delay_pref_, keys::OUTPUT_DELAY_SIZE};
}
return {nullptr, 0};
}
// THREAD CONTEXT: Main loop (invoked by client_->loop() during lifecycle events)
std::optional<uint32_t> SendspinHub::load_last_server_hash() {
LastPlayedServerPref pref{};
if (this->last_played_server_pref_.load(&pref)) {
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash);
return pref.server_id_hash;
std::optional<std::vector<uint8_t>> SendspinHub::load_blob(const std::string &key) {
auto [pref, size] = this->pref_for_key_(key);
if (pref == nullptr) {
ESP_LOGW(TAG, "load_blob: unknown key \"%s\"", key.c_str());
return std::nullopt;
}
return std::nullopt;
return read_blob(*pref, size);
}
bool SendspinHub::save_blob(const std::string &key, const uint8_t *data, size_t len) {
auto [pref, size] = this->pref_for_key_(key);
if (pref == nullptr) {
ESP_LOGW(TAG, "save_blob: unknown key \"%s\"", key.c_str());
return false;
}
return write_blob(*pref, key.c_str(), size, data, len);
}
// Writes every component's queued preferences, so a failure may belong to another component.
bool SendspinHub::commit() { return global_preferences->sync(); }
// --- Sendspin role specific methods/overrides ---
#ifdef USE_SENDSPIN_ARTWORK
@@ -285,11 +414,7 @@ void SendspinHub::send_client_command(sendspin::SendspinControllerCommand comman
}
// THREAD CONTEXT: Main loop (invoked from the sendspin.switch action)
void SendspinHub::switch_client() {
// Clear any EXTERNAL_SOURCE state so the switch command is followed
this->update_state(sendspin::SendspinClientState::SYNCHRONIZED);
this->send_client_command(sendspin::SendspinControllerCommand::SWITCH);
}
void SendspinHub::switch_client() { this->send_client_command(sendspin::SendspinControllerCommand::SWITCH); }
// THREAD CONTEXT: Main loop (ControllerRoleListener override, fired from client_->loop())
void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObject &state) {
@@ -332,31 +457,8 @@ sendspin::PlayerRole *SendspinHub::get_player_role() {
}
return nullptr;
}
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
bool SendspinHub::save_static_delay(uint16_t delay_ms) {
StaticDelayPref pref{.delay_ms = delay_ms};
bool ok = this->static_delay_pref_.save(&pref);
if (ok) {
ESP_LOGD(TAG, "Persisted static delay: %u ms", delay_ms);
} else {
ESP_LOGW(TAG, "Failed to persist static delay");
}
return ok;
}
// THREAD CONTEXT: Main loop (SendspinPersistenceProvider override)
std::optional<uint16_t> SendspinHub::load_static_delay() {
StaticDelayPref pref{};
if (this->static_delay_pref_.load(&pref)) {
ESP_LOGI(TAG, "Loaded static delay: %u ms", pref.delay_ms);
return pref.delay_ms;
}
return std::nullopt;
}
#endif
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif // USE_ESP_IDF
+34 -29
View File
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ESP_IDF
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
@@ -31,9 +31,15 @@
#include <sendspin/player_role.h>
#endif
#include <array>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace esphome::sendspin_ {
@@ -44,22 +50,16 @@ namespace esphome::sendspin_ {
/// one step later than hub so they can assume hub's setup() has already completed.
namespace sendspin_priority {
// AFTER_WIFI so the hub runs after the wifi/ethernet drivers are up and we can read the active
// interface's MAC for client_id.
// interface's MAC address for the device info.
inline constexpr float HUB = esphome::setup_priority::AFTER_WIFI;
inline constexpr float CHILD = HUB - 1.0f;
} // namespace sendspin_priority
/// @brief Persistent storage structure for last played server hash.
struct LastPlayedServerPref {
uint32_t server_id_hash;
};
// PREFERENCE KEYS: each library persistence key has its own preference, hashed from "sendspin_" + name. Renaming
// one erases it.
#ifdef USE_SENDSPIN_PLAYER
/// @brief Persistent storage structure for player static delay.
struct StaticDelayPref {
uint16_t delay_ms;
};
#endif
/// Pairing-record slots, handed to the client as max_pairing_records.
inline constexpr size_t SENDSPIN_RECORD_SLOTS = sendspin::SendspinClientConfig::DEFAULT_MAX_PAIRING_RECORDS;
/// @brief Thin adapter over sendspin::SendspinClient.
///
@@ -93,6 +93,7 @@ class SendspinHub final : public Component,
float get_setup_priority() const override { return sendspin_priority::HUB; }
void setup() override;
void loop() override;
void on_shutdown() override;
void dump_config() override;
/// @brief Connects the underlying client to the given Sendspin server.
@@ -113,14 +114,11 @@ class SendspinHub final : public Component,
/// - `USER_REQUEST`: user explicitly requested disconnect.
void disconnect_from_server(sendspin::SendspinGoodbyeReason reason);
/// @brief Updates the client's reported playback state on the server.
/// @brief Leaves the current group so another source can use the speaker.
///
/// Sends `client/leave` while the group is playing. A stopped group is left alone, so the device stays grouped.
/// No-op if the hub's client is not running. Must be called from the main loop thread.
/// @param state New client state:
/// - `SYNCHRONIZED`: client is synchronized and playing from the server.
/// - `ERROR`: client encountered a playback error.
/// - `EXTERNAL_SOURCE`: client is playing from a non-Sendspin source.
void update_state(sendspin::SendspinClientState state);
void leave_group();
// --- Configuration setters (called from codegen) ---
@@ -219,12 +217,16 @@ class SendspinHub final : public Component,
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
/// The preference a library key is stored in and its blob size, or {nullptr, 0} for a key with
/// no storage here.
std::pair<ESPPreferenceObject *, size_t> pref_for_key_(const std::string &key);
/// @brief Returns the product name reported to the server: the configured model, or the device name.
const char *get_product_name_() const;
/// @brief Writes the active network interface's MAC into @p buf and returns its data pointer.
/// @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_client_id_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
static const char *get_mac_address_into_buffer(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf);
#ifdef USE_MDNS_SUPPORTS_ENABLE_DISABLE
/// @brief Keeps the `_sendspin` mDNS service advertised while the client is running.
@@ -242,8 +244,10 @@ class SendspinHub final : public Component,
bool is_network_ready() override;
// --- SendspinPersistenceProvider overrides ---
bool save_last_server_hash(uint32_t hash) override;
std::optional<uint32_t> load_last_server_hash() override;
// The library calls commit() after writing pairing secrets, so they reach flash right away.
std::optional<std::vector<uint8_t>> load_blob(const std::string &key) override;
bool save_blob(const std::string &key, const uint8_t *data, size_t len) override;
bool commit() override;
// --- Sendspin role specific methods/overrides/member variables ---
@@ -291,16 +295,17 @@ class SendspinHub final : public Component,
#ifdef USE_SENDSPIN_PLAYER
sendspin::PlayerRoleListener *player_listener_{nullptr};
sendspin::PlayerRoleConfig player_config_{};
// Part of SendspinPersistenceProvider overrides
ESPPreferenceObject static_delay_pref_;
std::optional<uint16_t> load_static_delay() override;
bool save_static_delay(uint16_t delay_ms) override;
#endif
// --- Core member variables ---
ESPPreferenceObject last_played_server_pref_;
// Built once in setup(): make_preference() allocates a backend that is never freed.
ESPPreferenceObject keypair_pref_;
ESPPreferenceObject pairing_psk_pref_;
ESPPreferenceObject last_played_pref_;
ESPPreferenceObject output_delay_pref_;
std::array<ESPPreferenceObject, SENDSPIN_RECORD_SLOTS> record_slot_prefs_;
ESPPreferenceObject record_order_pref_;
std::unique_ptr<sendspin::SendspinClient> client_;
@@ -349,4 +354,4 @@ class SendspinPollingChild : public PollingComponent, public Parented<SendspinHu
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif // USE_ESP_IDF
@@ -1,6 +1,6 @@
#include "sendspin_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include <sendspin/metadata_role.h>
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/sensor/sensor.h"
@@ -1,6 +1,6 @@
#include "sendspin_switch.h"
#ifdef USE_ESP32
#ifdef USE_ESP_IDF
#include "esphome/core/log.h"
@@ -23,4 +23,4 @@ void SendspinSwitch::write_state(bool state) {
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif // USE_ESP_IDF
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#ifdef USE_ESP_IDF
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/switch/switch.h"
@@ -21,4 +21,4 @@ class SendspinSwitch final : public switch_::Switch, public SendspinChild {
} // namespace esphome::sendspin_
#endif // USE_ESP32
#endif // USE_ESP_IDF
@@ -1,6 +1,6 @@
#include "sendspin_text_sensor.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#include <sendspin/metadata_role.h>
@@ -2,7 +2,7 @@
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#if defined(USE_ESP_IDF) && defined(USE_SENDSPIN_METADATA) && defined(USE_TEXT_SENSOR)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/text_sensor/text_sensor.h"
+4 -1
View File
@@ -101,8 +101,11 @@ dependencies:
- if: "idf_version >=6.0.0 && target in [esp32s2, esp32s3, esp32s31, esp32p4, esp32h4]"
esp32async/asynctcp:
version: 3.4.91
# sendspin is ESP-IDF only (it needs noise-c as an ESP-IDF component, see below)
sendspin/sendspin-cpp:
version: 0.8.0
version: 0.9.3
rules:
- if: "$ESPHOME_ARDUINO_COMPONENT == 0"
lvgl/lvgl:
version: 9.5.0
fastled/FastLED:
@@ -0,0 +1,27 @@
"""Validation tests for the frameworks the sendspin hub builds on.
sendspin-cpp needs noise-c as an ESP-IDF component, which Arduino below IDF 6.0
cannot use, so the hub is ESP-IDF only.
"""
import pytest
from esphome import config_validation as cv
from esphome.components.sendspin import CONFIG_SCHEMA
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
HUB_CONFIG = {"id": "sendspin_hub_id"}
def test_arduino_rejected(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_ARDUINO)
with pytest.raises(cv.Invalid, match="esp-idf"):
CONFIG_SCHEMA(HUB_CONFIG)
def test_esp_idf_accepted(set_core_config: SetCoreConfigCallable) -> None:
set_core_config(PlatformFramework.ESP32_IDF)
assert CONFIG_SCHEMA(HUB_CONFIG)["id"].id == "sendspin_hub_id"
@@ -53,6 +53,14 @@ def test_jpeg_alias_maps_to_one_enum(
assert _get_data().artwork_preferences[0]["format"] == IMAGE_FORMAT_JPEG
def test_bmp_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""The Sendspin protocol only allows JPEG and PNG artwork."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="BMP artwork is no longer supported"):
CONFIG_SCHEMA(_slot_config(format="BMP"))
def test_too_many_slots_rejected(set_core_config: SetCoreConfigCallable) -> None:
"""Slot numbers run out after MAX_ARTWORK_SLOTS entries."""
set_core_config(PlatformFramework.ESP32_IDF)
@@ -88,3 +88,13 @@ def test_opus_at_wrong_sample_rate_rejected(
with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100))
def test_opus_only_codec_list_rejected(
set_core_config: SetCoreConfigCallable,
) -> None:
"""Every server supports FLAC or PCM, so sendspin-cpp requires one of them."""
set_core_config(PlatformFramework.ESP32_IDF)
with pytest.raises(cv.Invalid, match="must include 'flac' or 'pcm'"):
CONFIG_SCHEMA(_media_source_config(codecs=["opus"]))