mirror of
https://github.com/esphome/esphome.git
synced 2026-05-22 01:42:49 +08:00
[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
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:
@@ -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
|
||||
|
||||
@@ -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 ¶ms = 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
|
||||
Reference in New Issue
Block a user