[sendspin] Add a Sendspin media source component for playing audio (PR4) (#15950)
CI / Create common environment (push) Has been cancelled
CI / Check pylint (push) Has been cancelled
CI / Run script/ci-custom (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.11) (push) Has been cancelled
CI / Run pytest (macOS-latest, 3.14) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.11) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.13) (push) Has been cancelled
CI / Run pytest (ubuntu-latest, 3.14) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.11) (push) Has been cancelled
CI / Run pytest (windows-latest, 3.14) (push) Has been cancelled
CI / Determine which jobs to run (push) Has been cancelled
CI / Run integration tests (push) Has been cancelled
CI / Run C++ unit tests (push) Has been cancelled
CI / Run CodSpeed benchmarks (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 IDF (push) Has been cancelled
CI / Run script/clang-tidy for ESP8266 (push) Has been cancelled
CI / Run script/clang-tidy for ZEPHYR (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 1/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 2/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 3/4 (push) Has been cancelled
CI / Run script/clang-tidy for ESP32 Arduino 4/4 (push) Has been cancelled
CI / Test components batch (${{ matrix.components }}) (push) Has been cancelled
CI / pre-commit.ci lite (push) Has been cancelled
CI / Build target branch for memory impact (push) Has been cancelled
CI / Build PR branch for memory impact (push) Has been cancelled
CI / Comment memory impact (push) Has been cancelled
CI / CI Status (push) Has been cancelled
Stale / stale (push) Has been cancelled
Lock closed issues and PRs / lock (push) Has been cancelled
Publish Release / Initialize build (push) Has been cancelled
Publish Release / Build and publish to PyPi (push) Has been cancelled
Publish Release / Build ESPHome amd64 (push) Has been cancelled
Publish Release / Build ESPHome arm64 (push) Has been cancelled
Publish Release / Publish ESPHome docker to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome docker to ghcr (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to dockerhub (push) Has been cancelled
Publish Release / Publish ESPHome ha-addon to ghcr (push) Has been cancelled
Publish Release / deploy-ha-addon-repo (push) Has been cancelled
Publish Release / deploy-esphome-schema (push) Has been cancelled
Publish Release / version-notifier (push) Has been cancelled
Synchronise Device Classes from Home Assistant / Sync Device Classes (push) Has been cancelled

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Kevin Ahrendt
2026-04-24 06:00:22 -04:00
committed by GitHub
parent ae02ab3865
commit bc7f35b569
10 changed files with 595 additions and 1 deletions
+1
View File
@@ -442,6 +442,7 @@ esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
+77 -1
View File
@@ -4,7 +4,12 @@ from esphome import automation
import esphome.codegen as cg
from esphome.components import esp32, network, psram, socket, wifi
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_ID,
CONF_SAMPLE_RATE,
CONF_TASK_STACK_IN_PSRAM,
)
from esphome.core import CORE, ID
from esphome.cpp_generator import TemplateArgsType
from esphome.types import ConfigType
@@ -17,6 +22,23 @@ DOMAIN = "sendspin"
CONF_SENDSPIN_ID = "sendspin_id"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay"
# sendspin-cpp library lives in the global `sendspin` namespace.
sendspin_library_ns = cg.global_ns.namespace("sendspin")
# Library Enums
SendspinCodecFormat = sendspin_library_ns.enum("SendspinCodecFormat", is_class=True)
CODEC_FORMAT_FLAC = SendspinCodecFormat.enum("FLAC")
CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
# Library Structs
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
# Analysis tools strip the trailing underscore (same pattern as `template_`).
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
@@ -41,6 +63,8 @@ class SendspinConfiguration:
player_support: bool = False
visualizer_support: bool = False
player_config: ConfigType | None = None
def _get_data() -> SendspinConfiguration:
if DOMAIN not in CORE.data:
@@ -73,6 +97,17 @@ def request_visualizer_support() -> None:
_get_data().visualizer_support = True
def register_player_config(config: ConfigType) -> None:
"""Register the player role config from the media source subcomponent."""
data = _get_data()
request_player_support()
if data.player_config is not None:
raise cv.Invalid(
"Only one sendspin media_source player configuration is supported"
)
data.player_config = config
def _validate_task_stack_in_psram(value):
value = cv.boolean(value)
if value:
@@ -183,6 +218,47 @@ async def to_code(config: ConfigType) -> None:
if data.player_support:
cg.add_define("USE_SENDSPIN_PLAYER", True)
# Configures the player role. We always assume support for 16 bits per sample mono and stereo FLAC, Opus, and PCM at the configured sample rate
# (with Opus only supported at 48 kHz since that's the only sample rate it supports). Users can configure the specific formats via the Sendspin server
player_cfg = data.player_config
sample_rate = player_cfg[CONF_SAMPLE_RATE]
# OPUS only supports 48 kHz audio
codecs = [CODEC_FORMAT_FLAC]
if sample_rate == 48000:
codecs.append(CODEC_FORMAT_OPUS)
codecs.append(CODEC_FORMAT_PCM)
def _audio_format(codec, channels):
return cg.StructInitializer(
AudioSupportedFormatObject,
("codec", codec),
("channels", channels),
("sample_rate", sample_rate),
("bit_depth", 16),
)
audio_format_structs = [
_audio_format(codec, channels) for codec in codecs for channels in (2, 1)
]
psram_stack = player_cfg.get(CONF_TASK_STACK_IN_PSRAM, False)
if psram_stack:
esp32.add_idf_sdkconfig_option(
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
)
player_config_struct = cg.StructInitializer(
PlayerRoleConfig,
("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]),
("psram_stack", psram_stack),
("priority", 2),
)
cg.add(var.set_player_config(player_config_struct))
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_PLAYER", False)
@@ -0,0 +1,134 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import media_source
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_ID,
CONF_SAMPLE_RATE,
CONF_TASK_STACK_IN_PSRAM,
)
from esphome.core import ID
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigType
from .. import (
CONF_FIXED_DELAY,
CONF_INITIAL_STATIC_DELAY,
CONF_SENDSPIN_ID,
SendspinHub,
_validate_task_stack_in_psram,
register_player_config,
request_controller_support,
sendspin_ns,
)
AUTO_LOAD = ["audio"]
CODEOWNERS = ["@kahrendt"]
CONF_STATIC_DELAY_ADJUSTABLE = "static_delay_adjustable"
SendspinMediaSource = sendspin_ns.class_(
"SendspinMediaSource",
cg.Component,
media_source.MediaSource,
)
EnableStaticDelayAdjustmentAction = sendspin_ns.class_(
"EnableStaticDelayAdjustmentAction",
automation.Action,
cg.Parented.template(SendspinMediaSource),
)
DisableStaticDelayAdjustmentAction = sendspin_ns.class_(
"DisableStaticDelayAdjustmentAction",
automation.Action,
cg.Parented.template(SendspinMediaSource),
)
def _register(config: ConfigType) -> ConfigType:
request_controller_support()
register_player_config(
{
CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE],
CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE],
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
CONF_FIXED_DELAY: config[CONF_FIXED_DELAY],
CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False),
}
)
return config
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
SendspinMediaSource,
).extend(
{
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
cv.Optional(CONF_BUFFER_SIZE, default=1000000): cv.int_range(min=25000),
cv.Optional(CONF_INITIAL_STATIC_DELAY, default="0ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=5000)),
),
cv.Optional(CONF_STATIC_DELAY_ADJUSTABLE, default=False): cv.boolean,
cv.Optional(CONF_FIXED_DELAY, default="0us"): cv.All(
cv.positive_time_period_microseconds,
cv.Range(max=cv.TimePeriod(microseconds=10000)),
),
cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range(
min=16000, max=96000
),
}
),
cv.only_on_esp32,
_register,
)
async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await media_source.register_media_source(var, config)
sendspin_hub = await cg.get_variable(config[CONF_SENDSPIN_ID])
await cg.register_parented(var, sendspin_hub)
cg.add(sendspin_hub.set_listener(var))
cg.add(var.set_static_delay_adjustable(config[CONF_STATIC_DELAY_ADJUSTABLE]))
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA = automation.maybe_simple_id(
cv.Schema(
{
cv.GenerateID(): cv.use_id(SendspinMediaSource),
}
)
)
@automation.register_action(
"sendspin.media_source.enable_static_delay_adjustment",
EnableStaticDelayAdjustmentAction,
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA,
synchronous=True,
)
@automation.register_action(
"sendspin.media_source.disable_static_delay_adjustment",
DisableStaticDelayAdjustmentAction,
SENDSPIN_MEDIA_SOURCE_ACTION_SCHEMA,
synchronous=True,
)
async def sendspin_static_delay_adjustment_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -0,0 +1,26 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_PLAYER) && defined(USE_SENDSPIN_CONTROLLER)
#include "esphome/core/automation.h"
#include "sendspin_media_source.h"
namespace esphome::sendspin_ {
template<typename... Ts>
class EnableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
public:
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(true); }
};
template<typename... Ts>
class DisableStaticDelayAdjustmentAction : public Action<Ts...>, public Parented<SendspinMediaSource> {
public:
void play(const Ts &...x) override { this->parent_->set_static_delay_adjustable(false); }
};
} // namespace esphome::sendspin_
#endif
@@ -0,0 +1,207 @@
#include "sendspin_media_source.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/audio/audio.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.media_source";
static constexpr char URI_PREFIX[] = "sendspin://";
void SendspinMediaSource::setup() {
this->player_role_ = this->parent_->get_player_role();
if (!this->player_role_) {
ESP_LOGE(TAG, "Failed to get player role from hub");
this->mark_failed();
return;
}
// 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_);
}
void SendspinMediaSource::dump_config() {
ESP_LOGCONFIG(TAG, "Sendspin Media Source: static_delay_adjustable=%s", YESNO(this->static_delay_adjustable_));
}
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / 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);
}
}
// --- MediaSource interface ---
bool SendspinMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
// THREAD CONTEXT: Main loop (media_source.h documents play_uri as main-loop only)
bool SendspinMediaSource::play_uri(const std::string &uri) {
if (!this->is_ready() || this->is_failed() || !this->has_listener()) {
return false;
}
if (this->get_state() != media_source::MediaSourceState::IDLE) {
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
return false;
}
if (!uri.starts_with(URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
std::string sendspin_id = uri.substr(sizeof(URI_PREFIX) - 1);
if (sendspin_id.empty()) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
ESP_LOGD(TAG, "sendspin_id: %s", sendspin_id.c_str());
if (sendspin_id != "current") {
// Connect to a new server as a websocket client
this->parent_->connect_to_server("ws://" + sendspin_id);
}
// Tell the orchestrator we're now playing so it routes audio output from us
this->pending_start_ = false;
this->set_state_(media_source::MediaSourceState::PLAYING);
return true;
}
// THREAD CONTEXT: Main loop (media_source.h documents handle_command as main-loop only)
void SendspinMediaSource::handle_command(media_source::MediaSourceCommand command) {
switch (command) {
case media_source::MediaSourceCommand::STOP: {
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);
}
break;
}
case media_source::MediaSourceCommand::PLAY: // NOLINT(bugprone-branch-clone)
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PLAY, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::PAUSE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PAUSE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::NEXT:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::NEXT, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::PREVIOUS:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::PREVIOUS, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_ALL:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ALL, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_ONE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_ONE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::REPEAT_OFF:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::REPEAT_OFF, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::SHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::SHUFFLE, std::nullopt, std::nullopt);
break;
case media_source::MediaSourceCommand::UNSHUFFLE:
this->parent_->send_client_command(sendspin::SendspinControllerCommand::UNSHUFFLE, std::nullopt, std::nullopt);
break;
default:
break;
}
}
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
void SendspinMediaSource::notify_volume_changed(float volume) {
this->cached_volume_ = volume;
if (this->player_role_) {
this->player_role_->update_volume(std::roundf(volume * 100.0f));
}
}
// THREAD CONTEXT: Main loop (orchestrator -> source notification)
void SendspinMediaSource::notify_mute_changed(bool is_muted) {
this->cached_muted_ = is_muted;
if (this->player_role_) {
this->player_role_->update_muted(is_muted);
}
}
// THREAD CONTEXT: Speaker playback callback thread (forwarded from the speaker).
// PlayerRole::notify_audio_played() is documented as thread-safe for this use.
void SendspinMediaSource::notify_audio_played(uint32_t frames, int64_t timestamp) {
if (this->player_role_) {
this->player_role_->notify_audio_played(frames, timestamp);
}
}
// --- Sendspin PlayerRoleListener overrides ---
// THREAD CONTEXT: Sendspin sync task background thread. May block up to timeout_ms.
size_t SendspinMediaSource::on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) {
if (!this->has_listener() || (this->get_state() != media_source::MediaSourceState::PLAYING)) {
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
return 0;
}
// PlayerRole::get_current_stream_params() is safe to call from the sync task.
auto &params = this->player_role_->get_current_stream_params();
if (!params.bit_depth.has_value() || !params.channels.has_value() || !params.sample_rate.has_value()) {
vTaskDelay(pdMS_TO_TICKS(timeout_ms));
return 0;
}
audio::AudioStreamInfo stream_info(*params.bit_depth, *params.channels, *params.sample_rate);
return this->write_output(data, length, timeout_ms, stream_info);
}
// 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;
// Request the orchestrator to start this source
this->request_play_uri_("sendspin://current");
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_end() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
void SendspinMediaSource::on_stream_clear() {
if (this->get_state() != media_source::MediaSourceState::IDLE) {
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
this->set_state_(media_source::MediaSourceState::IDLE);
}
}
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
void SendspinMediaSource::on_mute_changed(bool muted) { this->request_mute_(muted); }
} // namespace esphome::sendspin_
#endif // USE_ESP32 && USE_SENDSPIN_PLAYER && USE_SENDSPIN_CONTROLLER
@@ -0,0 +1,72 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_CONTROLLER) && defined(USE_SENDSPIN_PLAYER)
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/components/media_source/media_source.h"
#include <sendspin/player_role.h>
namespace esphome::sendspin_ {
/// @brief Thin adapter media source for Sendspin.
///
/// Implements PlayerRoleListener to receive audio data from the sendspin-cpp library's
/// SyncTask and bridges it to ESPHome's MediaSource output pipeline. Also forwards
/// transport commands to the hub's controller role.
class SendspinMediaSource : public SendspinChild,
public media_source::MediaSource,
public sendspin::PlayerRoleListener {
public:
void setup() override;
void dump_config() override;
void set_static_delay_adjustable(bool adjustable);
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
void handle_command(media_source::MediaSourceCommand command) override;
bool can_handle(const std::string &uri) const override;
bool has_internal_playlist() const override { return true; }
void notify_volume_changed(float volume) override;
void notify_mute_changed(bool is_muted) override;
void notify_audio_played(uint32_t frames, int64_t timestamp) override;
protected:
// --- Sendspin PlayerRoleListener overrides ---
/// @brief Writes decoded PCM audio to ESPHome's media source output pipeline.
/// Called from the sync task's background thread.
size_t on_audio_write(uint8_t *data, size_t length, uint32_t timeout_ms) override;
/// @brief Called when a new audio stream starts (main loop thread).
void on_stream_start() override;
/// @brief Called when the audio stream ends (main loop thread).
void on_stream_end() override;
/// @brief Called when the audio stream is cleared (main loop thread).
void on_stream_clear() override;
/// @brief Called when volume changes (main loop thread).
void on_volume_changed(uint8_t volume) override;
/// @brief Called when mute state changes (main loop thread).
void on_mute_changed(bool muted) override;
sendspin::PlayerRole *player_role_{nullptr};
float cached_volume_{0.0f};
bool cached_muted_{false};
bool pending_start_{false};
bool static_delay_adjustable_{false};
};
} // namespace esphome::sendspin_
#endif
@@ -25,6 +25,9 @@ void SendspinHub::setup() {
// 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
// Wire providers and client listener
this->client_->set_listener(this);
@@ -36,6 +39,10 @@ void SendspinHub::setup() {
this->controller_role_->set_listener(this);
#endif
#ifdef USE_SENDSPIN_PLAYER
this->client_->add_player(this->player_config_).set_listener(this->player_listener_);
#endif
if (!this->client_->start_server()) {
ESP_LOGE(TAG, "Failed to start Sendspin server");
this->mark_failed();
@@ -160,6 +167,39 @@ void SendspinHub::on_controller_state(const sendspin::ServerStateControllerObjec
}
#endif
#ifdef USE_SENDSPIN_PLAYER
// THREAD CONTEXT: Main loop, called from child component setup() after player role is created and configured
sendspin::PlayerRole *SendspinHub::get_player_role() {
if (this->is_ready()) {
return this->client_->player();
}
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
@@ -16,6 +16,9 @@
#ifdef USE_SENDSPIN_CONTROLLER
#include <sendspin/controller_role.h>
#endif
#ifdef USE_SENDSPIN_PLAYER
#include <sendspin/player_role.h>
#endif
#include <functional>
#include <memory>
@@ -38,6 +41,13 @@ struct LastPlayedServerPref {
uint32_t server_id_hash;
};
#ifdef USE_SENDSPIN_PLAYER
/// @brief Persistent storage structure for player static delay.
struct StaticDelayPref {
uint16_t delay_ms;
};
#endif
/// @brief Thin adapter over sendspin::SendspinClient.
///
/// The hub owns a SendspinClient instance and bridges its listener/provider interfaces to ESPHome's CallbackManager for
@@ -112,6 +122,14 @@ class SendspinHub final : public Component,
}
#endif
#ifdef USE_SENDSPIN_PLAYER
void set_listener(sendspin::PlayerRoleListener *listener) { this->player_listener_ = listener; }
void set_player_config(const sendspin::PlayerRoleConfig &config) { this->player_config_ = config; }
/// @brief Child components call this to get the PlayerRole instance after setup, so they can push updates to it.
sendspin::PlayerRole *get_player_role();
#endif
protected:
/// @brief Builds the SendspinClientConfig from ESPHome configuration and platform info.
sendspin::SendspinClientConfig build_client_config_();
@@ -141,6 +159,16 @@ class SendspinHub final : public Component,
CallbackManager<void(const sendspin::ServerStateControllerObject &)> controller_state_callbacks_{};
#endif
#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_;
@@ -0,0 +1,9 @@
<<: !include common.yaml
media_source:
- platform: sendspin
id: media_source_id
buffer_size: 500000
initial_static_delay: 5ms
static_delay_adjustable: true
fixed_delay: 480us
@@ -0,0 +1 @@
<<: !include common-media_source.yaml