mirror of
https://github.com/esphome/esphome.git
synced 2026-09-17 18:18:43 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
@@ -37,7 +37,7 @@ repos:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.20.0
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py311-plus]
|
||||
|
||||
+7
-6
@@ -74,6 +74,8 @@ from esphome.util import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
|
||||
|
||||
# Maximum buffer size for serial log reading to prevent unbounded memory growth
|
||||
SERIAL_BUFFER_MAX_SIZE = 65536
|
||||
|
||||
@@ -1307,9 +1309,8 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
files = list_yaml_files(args.configuration)
|
||||
|
||||
def build_command(f):
|
||||
if CORE.dashboard:
|
||||
return ["esphome", "--dashboard", "run", f, "--no-logs", "--device", "OTA"]
|
||||
return ["esphome", "run", f, "--no-logs", "--device", "OTA"]
|
||||
dashboard = ["--dashboard"] if CORE.dashboard else []
|
||||
return [*ESPHOME_COMMAND, *dashboard, "run", f, "--no-logs", "--device", "OTA"]
|
||||
|
||||
return run_multiple_configs(files, build_command)
|
||||
|
||||
@@ -1458,7 +1459,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
new_path.write_text(new_raw, encoding="utf-8")
|
||||
|
||||
rc = run_external_process("esphome", "config", str(new_path))
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
@@ -1476,7 +1477,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
cli_args.insert(0, "--dashboard")
|
||||
|
||||
try:
|
||||
rc = run_external_process("esphome", *cli_args)
|
||||
rc = run_external_process(*ESPHOME_COMMAND, *cli_args)
|
||||
except KeyboardInterrupt:
|
||||
rc = 1
|
||||
if rc != 0:
|
||||
@@ -1873,7 +1874,7 @@ def run_esphome(argv):
|
||||
# argv[0] is the program path, skip it since we prefix with "esphome"
|
||||
def build_command(f):
|
||||
return (
|
||||
["esphome"]
|
||||
[*ESPHOME_COMMAND]
|
||||
+ [arg for arg in argv[1:] if arg not in args.configuration]
|
||||
+ [str(f)]
|
||||
)
|
||||
|
||||
@@ -12,24 +12,25 @@ static const char *const TAG = "sensirion_i2c";
|
||||
static const size_t BUFFER_STACK_SIZE = 16;
|
||||
|
||||
bool SensirionI2CDevice::read_data(uint16_t *data, const uint8_t len) {
|
||||
const size_t num_bytes = len * 3;
|
||||
uint8_t buf[num_bytes];
|
||||
const size_t required_buffer_len = len * 3;
|
||||
SmallBufferWithHeapFallback<BUFFER_STACK_SIZE> buffer(required_buffer_len);
|
||||
uint8_t *temp = buffer.get();
|
||||
|
||||
this->last_error_ = this->read(buf, num_bytes);
|
||||
this->last_error_ = this->read(temp, required_buffer_len);
|
||||
if (this->last_error_ != i2c::ERROR_OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (uint8_t i = 0; i < len; i++) {
|
||||
const uint8_t j = 3 * i;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
const size_t j = i * 3;
|
||||
// Use MSB first since Sensirion devices use CRC-8 with MSB first
|
||||
uint8_t crc = crc8(&buf[j], 2, 0xFF, CRC_POLYNOMIAL, true);
|
||||
if (crc != buf[j + 2]) {
|
||||
ESP_LOGE(TAG, "CRC invalid @ %d! 0x%02X != 0x%02X", i, buf[j + 2], crc);
|
||||
uint8_t crc = crc8(&temp[j], 2, 0xFF, CRC_POLYNOMIAL, true);
|
||||
if (crc != temp[j + 2]) {
|
||||
ESP_LOGE(TAG, "CRC invalid @ %zu! 0x%02X != 0x%02X", i, temp[j + 2], crc);
|
||||
this->last_error_ = i2c::ERROR_CRC;
|
||||
return false;
|
||||
}
|
||||
data[i] = encode_uint16(buf[j], buf[j + 1]);
|
||||
data[i] = encode_uint16(temp[j], temp[j + 1]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "speaker_source_media_player.h"
|
||||
|
||||
namespace esphome::speaker_source {
|
||||
|
||||
template<typename... Ts> class SetPlaylistDelayAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit SetPlaylistDelayAction(SpeakerSourceMediaPlayer *parent) : parent_(parent) {}
|
||||
|
||||
TEMPLATABLE_VALUE(uint8_t, pipeline)
|
||||
TEMPLATABLE_VALUE(uint32_t, delay)
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
this->parent_->set_playlist_delay_ms(this->pipeline_.value(x...), this->delay_.value(x...));
|
||||
}
|
||||
|
||||
protected:
|
||||
SpeakerSourceMediaPlayer *parent_;
|
||||
};
|
||||
|
||||
} // namespace esphome::speaker_source
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -3,13 +3,16 @@ import esphome.codegen as cg
|
||||
from esphome.components import audio, media_player, media_source, speaker
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_DELAY,
|
||||
CONF_FORMAT,
|
||||
CONF_ID,
|
||||
CONF_NUM_CHANNELS,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_SPEAKER,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.core.entity_helpers import inherit_property_from
|
||||
from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["audio"]
|
||||
@@ -17,8 +20,10 @@ DEPENDENCIES = ["media_source", "speaker"]
|
||||
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
|
||||
CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline"
|
||||
CONF_MEDIA_PIPELINE = "media_pipeline"
|
||||
CONF_ON_MUTE = "on_mute"
|
||||
CONF_PIPELINE = "pipeline"
|
||||
CONF_ON_UNMUTE = "on_unmute"
|
||||
CONF_ON_VOLUME = "on_volume"
|
||||
CONF_SOURCES = "sources"
|
||||
@@ -36,6 +41,26 @@ SpeakerSourceMediaPlayer = speaker_source_ns.class_(
|
||||
PipelineContext = speaker_source_ns.struct("PipelineContext")
|
||||
|
||||
Pipeline = speaker_source_ns.enum("Pipeline")
|
||||
PIPELINE_ENUM = {
|
||||
"media": Pipeline.MEDIA_PIPELINE,
|
||||
"announcement": Pipeline.ANNOUNCEMENT_PIPELINE,
|
||||
}
|
||||
|
||||
# Maps config key -> (C++ Pipeline enum value, format purpose)
|
||||
_PIPELINE_INFO = {
|
||||
CONF_MEDIA_PIPELINE: (
|
||||
Pipeline.MEDIA_PIPELINE,
|
||||
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
|
||||
),
|
||||
CONF_ANNOUNCEMENT_PIPELINE: (
|
||||
Pipeline.ANNOUNCEMENT_PIPELINE,
|
||||
media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
|
||||
),
|
||||
}
|
||||
|
||||
SetPlaylistDelayAction = speaker_source_ns.class_(
|
||||
"SetPlaylistDelayAction", automation.Action
|
||||
)
|
||||
|
||||
|
||||
FORMAT_MAPPING = {
|
||||
@@ -48,7 +73,7 @@ FORMAT_MAPPING = {
|
||||
|
||||
# Returns a media_player.MediaPlayerSupportedFormat struct with the configured
|
||||
# format, sample rate, number of channels, purpose, and bytes per sample
|
||||
def _get_supported_format_struct(pipeline: ConfigType):
|
||||
def _get_supported_format_struct(pipeline: ConfigType, purpose: MockObj):
|
||||
args = [
|
||||
media_player.MediaPlayerSupportedFormat,
|
||||
]
|
||||
@@ -57,7 +82,7 @@ def _get_supported_format_struct(pipeline: ConfigType):
|
||||
|
||||
args.append(("sample_rate", pipeline[CONF_SAMPLE_RATE]))
|
||||
args.append(("num_channels", pipeline[CONF_NUM_CHANNELS]))
|
||||
args.append(("purpose", media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"]))
|
||||
args.append(("purpose", purpose))
|
||||
|
||||
# Omit sample_bytes for MP3: ffmpeg transcoding in Home Assistant fails
|
||||
# if the number of bytes per sample is specified for MP3.
|
||||
@@ -104,6 +129,40 @@ PIPELINE_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
def _validate_no_shared_resources(config: ConfigType) -> ConfigType:
|
||||
announcement_config = config.get(CONF_ANNOUNCEMENT_PIPELINE)
|
||||
media_config = config.get(CONF_MEDIA_PIPELINE)
|
||||
|
||||
# Check for duplicates within each pipeline
|
||||
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
|
||||
if pipeline_config := config.get(pipeline_key):
|
||||
source_ids = [s.id for s in pipeline_config[CONF_SOURCES]]
|
||||
if len(source_ids) != len(set(source_ids)):
|
||||
raise cv.Invalid(
|
||||
f"Duplicate media sources in {pipeline_key}. "
|
||||
"Each media source can only appear once per pipeline."
|
||||
)
|
||||
|
||||
# Check for sources shared between pipelines
|
||||
if announcement_config and media_config:
|
||||
if announcement_config[CONF_SPEAKER] == media_config[CONF_SPEAKER]:
|
||||
raise cv.Invalid(
|
||||
"The announcement and media pipelines cannot use the same speaker. "
|
||||
"Use the `mixer` speaker component to create two source speakers."
|
||||
)
|
||||
|
||||
announcement_source_ids = {s.id for s in announcement_config[CONF_SOURCES]}
|
||||
media_source_ids = {s.id for s in media_config[CONF_SOURCES]}
|
||||
shared = announcement_source_ids & media_source_ids
|
||||
if shared:
|
||||
raise cv.Invalid(
|
||||
f"Media sources cannot be shared between pipelines: {', '.join(shared)}. "
|
||||
"Create separate media source instances for each pipeline."
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_volume_settings(config: ConfigType) -> ConfigType:
|
||||
# CONF_VOLUME_INITIAL is in the scaled volume domain (0.0-1.0) and doesn't need to be validated
|
||||
if config[CONF_VOLUME_MIN] > config[CONF_VOLUME_MAX]:
|
||||
@@ -120,7 +179,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_VOLUME_INITIAL, default=0.5): cv.percentage,
|
||||
cv.Optional(CONF_VOLUME_MAX, default=1.0): cv.percentage,
|
||||
cv.Optional(CONF_VOLUME_MIN, default=0.0): cv.percentage,
|
||||
cv.Required(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA,
|
||||
cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): PIPELINE_SCHEMA,
|
||||
cv.Optional(CONF_MEDIA_PIPELINE): PIPELINE_SCHEMA,
|
||||
cv.Optional(CONF_ON_MUTE): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_UNMUTE): automation.validate_automation(single=True),
|
||||
cv.Optional(CONF_ON_VOLUME): automation.validate_automation(single=True),
|
||||
@@ -129,23 +189,37 @@ CONFIG_SCHEMA = cv.All(
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(media_player.media_player_schema(SpeakerSourceMediaPlayer)),
|
||||
cv.only_on_esp32,
|
||||
cv.has_at_least_one_key(CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE),
|
||||
_validate_no_shared_resources,
|
||||
_validate_volume_settings,
|
||||
)
|
||||
|
||||
|
||||
def _final_validate_codecs(config: ConfigType) -> ConfigType:
|
||||
pipeline = config[CONF_MEDIA_PIPELINE]
|
||||
fmt = pipeline[CONF_FORMAT]
|
||||
if fmt == "NONE":
|
||||
# "NONE" means the pipeline accepts any format at runtime, so all optional codecs must be available.
|
||||
# When a specific format is set, only that codec is requested.
|
||||
needed_formats: set[str] = set()
|
||||
need_all = False
|
||||
|
||||
for pipeline_key in (CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE):
|
||||
if pipeline := config.get(pipeline_key):
|
||||
fmt = pipeline[CONF_FORMAT]
|
||||
if fmt == "NONE":
|
||||
need_all = True
|
||||
else:
|
||||
needed_formats.add(fmt)
|
||||
|
||||
if need_all:
|
||||
audio.request_flac_support()
|
||||
audio.request_mp3_support()
|
||||
audio.request_opus_support()
|
||||
elif fmt == "FLAC":
|
||||
audio.request_flac_support()
|
||||
elif fmt == "MP3":
|
||||
audio.request_mp3_support()
|
||||
elif fmt == "OPUS":
|
||||
audio.request_opus_support()
|
||||
else:
|
||||
if "FLAC" in needed_formats:
|
||||
audio.request_flac_support()
|
||||
if "MP3" in needed_formats:
|
||||
audio.request_mp3_support()
|
||||
if "OPUS" in needed_formats:
|
||||
audio.request_opus_support()
|
||||
|
||||
return config
|
||||
|
||||
@@ -153,7 +227,8 @@ def _final_validate_codecs(config: ConfigType) -> ConfigType:
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_MEDIA_PIPELINE): _validate_pipeline,
|
||||
cv.Optional(CONF_ANNOUNCEMENT_PIPELINE): _validate_pipeline,
|
||||
cv.Optional(CONF_MEDIA_PIPELINE): _validate_pipeline,
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
),
|
||||
@@ -171,26 +246,25 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add(var.set_volume_max(config[CONF_VOLUME_MAX]))
|
||||
cg.add(var.set_volume_min(config[CONF_VOLUME_MIN]))
|
||||
|
||||
pipeline_config = config[CONF_MEDIA_PIPELINE]
|
||||
pipeline_enum = Pipeline.MEDIA_PIPELINE
|
||||
for pipeline_key, (pipeline_enum, purpose) in _PIPELINE_INFO.items():
|
||||
if pipeline_config := config.get(pipeline_key):
|
||||
for source in pipeline_config[CONF_SOURCES]:
|
||||
src = await cg.get_variable(source)
|
||||
cg.add(var.add_media_source(pipeline_enum, src))
|
||||
|
||||
for source in pipeline_config[CONF_SOURCES]:
|
||||
src = await cg.get_variable(source)
|
||||
cg.add(var.add_media_source(pipeline_enum, src))
|
||||
|
||||
cg.add(
|
||||
var.set_speaker(
|
||||
pipeline_enum,
|
||||
await cg.get_variable(pipeline_config[CONF_SPEAKER]),
|
||||
)
|
||||
)
|
||||
if pipeline_config[CONF_FORMAT] != "NONE":
|
||||
cg.add(
|
||||
var.set_format(
|
||||
pipeline_enum,
|
||||
_get_supported_format_struct(pipeline_config),
|
||||
cg.add(
|
||||
var.set_speaker(
|
||||
pipeline_enum,
|
||||
await cg.get_variable(pipeline_config[CONF_SPEAKER]),
|
||||
)
|
||||
)
|
||||
)
|
||||
if pipeline_config[CONF_FORMAT] != "NONE":
|
||||
cg.add(
|
||||
var.set_format(
|
||||
pipeline_enum,
|
||||
_get_supported_format_struct(pipeline_config, purpose),
|
||||
)
|
||||
)
|
||||
|
||||
if on_mute := config.get(CONF_ON_MUTE):
|
||||
await automation.build_automation(
|
||||
@@ -210,3 +284,35 @@ async def to_code(config: ConfigType) -> None:
|
||||
[(cg.float_, "x")],
|
||||
on_volume,
|
||||
)
|
||||
|
||||
|
||||
SET_PLAYLIST_DELAY_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(SpeakerSourceMediaPlayer),
|
||||
cv.Required(CONF_PIPELINE): cv.enum(PIPELINE_ENUM, lower=True),
|
||||
cv.Required(CONF_DELAY): cv.templatable(cv.positive_time_period_milliseconds),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"speaker_source.set_playlist_delay",
|
||||
SetPlaylistDelayAction,
|
||||
SET_PLAYLIST_DELAY_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def set_playlist_delay_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
parent = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, parent)
|
||||
|
||||
cg.add(var.set_pipeline(config[CONF_PIPELINE]))
|
||||
|
||||
template_ = await cg.templatable(config[CONF_DELAY], args, cg.uint32)
|
||||
cg.add(var.set_delay(template_))
|
||||
|
||||
return var
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace esphome::speaker_source {
|
||||
|
||||
static constexpr uint32_t MEDIA_CONTROLS_QUEUE_LENGTH = 20;
|
||||
@@ -126,6 +128,7 @@ void SpeakerSourceMediaPlayer::handle_play_uri_request_(uint8_t pipeline, const
|
||||
// Smart source is requesting the player to play a different URI
|
||||
auto call = this->make_call();
|
||||
call.set_media_url(uri);
|
||||
call.set_announcement(pipeline == ANNOUNCEMENT_PIPELINE);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
@@ -135,8 +138,12 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med
|
||||
PipelineContext &ps = this->pipelines_[pipeline];
|
||||
|
||||
if (state == media_source::MediaSourceState::IDLE) {
|
||||
// Track whether this IDLE was from an orchestrator-initiated stop (e.g., NEXT/PREV/PLAY_URI)
|
||||
// so we can suppress spurious PLAYLIST_ADVANCE below
|
||||
bool was_stopping = (ps.stopping_source == source);
|
||||
|
||||
// Source went idle - clear stopping flag if this was the source we asked to stop
|
||||
if (ps.stopping_source == source) {
|
||||
if (was_stopping) {
|
||||
ps.stopping_source = nullptr;
|
||||
}
|
||||
|
||||
@@ -152,6 +159,11 @@ void SpeakerSourceMediaPlayer::handle_media_state_changed_(uint8_t pipeline, med
|
||||
|
||||
// Finish the speaker to ensure it's ready for the next playback
|
||||
ps.speaker->finish();
|
||||
|
||||
// Only advance the playlist if the track finished naturally (not stopped by the orchestrator)
|
||||
if (!was_stopping) {
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline);
|
||||
}
|
||||
}
|
||||
} else if (state == media_source::MediaSourceState::PLAYING) {
|
||||
// Source started playing - make it the active source if no one else is active
|
||||
@@ -197,8 +209,9 @@ size_t SpeakerSourceMediaPlayer::handle_media_output_(uint8_t pipeline, media_so
|
||||
return 0;
|
||||
}
|
||||
|
||||
media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_state_(
|
||||
media_source::MediaSource *source) const {
|
||||
// THREAD CONTEXT: Called from main loop (loop)
|
||||
media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_source_state_(
|
||||
media_source::MediaSource *source, bool playlist_active, media_player::MediaPlayerState old_state) const {
|
||||
if (source != nullptr) {
|
||||
switch (source->get_state()) {
|
||||
case media_source::MediaSourceState::PLAYING:
|
||||
@@ -206,7 +219,7 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat
|
||||
case media_source::MediaSourceState::PAUSED:
|
||||
return media_player::MEDIA_PLAYER_STATE_PAUSED;
|
||||
case media_source::MediaSourceState::ERROR:
|
||||
ESP_LOGE(TAG, "Source error");
|
||||
ESP_LOGE(TAG, "Media source error");
|
||||
return media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||
case media_source::MediaSourceState::IDLE:
|
||||
default:
|
||||
@@ -214,97 +227,58 @@ media_player::MediaPlayerState SpeakerSourceMediaPlayer::get_media_pipeline_stat
|
||||
}
|
||||
}
|
||||
|
||||
// No active source. Stay PLAYING during playlist transitions
|
||||
if (playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_PLAYING) {
|
||||
return media_player::MEDIA_PLAYER_STATE_PLAYING;
|
||||
}
|
||||
return media_player::MEDIA_PLAYER_STATE_IDLE;
|
||||
}
|
||||
|
||||
void SpeakerSourceMediaPlayer::loop() {
|
||||
// Process queued control commands
|
||||
MediaPlayerControlCommand control_command;
|
||||
this->process_control_queue_();
|
||||
|
||||
// Use peek to check command without removing it
|
||||
if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) == pdTRUE) {
|
||||
bool command_executed = false;
|
||||
uint8_t pipeline = control_command.pipeline;
|
||||
|
||||
switch (control_command.type) {
|
||||
case MediaPlayerControlCommand::PLAY_URI: {
|
||||
command_executed = this->try_execute_play_uri_(*control_command.data.uri, pipeline);
|
||||
break;
|
||||
}
|
||||
|
||||
case MediaPlayerControlCommand::SEND_COMMAND: {
|
||||
PipelineContext &ps = this->pipelines_[pipeline];
|
||||
|
||||
// Determine target source: prefer active, fall back to last
|
||||
media_source::MediaSource *target_source = nullptr;
|
||||
if (ps.active_source != nullptr) {
|
||||
target_source = ps.active_source;
|
||||
} else if (ps.last_source != nullptr) {
|
||||
target_source = ps.last_source;
|
||||
}
|
||||
|
||||
media_player::MediaPlayerCommand player_command = control_command.data.command;
|
||||
switch (player_command) {
|
||||
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: {
|
||||
media_source::MediaSource *active_source = ps.active_source;
|
||||
if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
|
||||
}
|
||||
} else {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PLAY: {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PAUSE: {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_STOP: {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::STOP);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
command_executed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove from queue if successfully executed
|
||||
if (command_executed) {
|
||||
xQueueReceive(this->media_control_command_queue_, &control_command, 0);
|
||||
|
||||
// Delete the allocated string for PLAY_URI commands
|
||||
if (control_command.type == MediaPlayerControlCommand::PLAY_URI) {
|
||||
delete control_command.data.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update state based on active sources
|
||||
// Update state based on active sources - announcement pipeline takes priority
|
||||
media_player::MediaPlayerState old_state = this->state;
|
||||
|
||||
PipelineContext &ann_ps = this->pipelines_[ANNOUNCEMENT_PIPELINE];
|
||||
PipelineContext &media_ps = this->pipelines_[MEDIA_PIPELINE];
|
||||
this->state = this->get_media_pipeline_state_(media_ps.active_source);
|
||||
|
||||
// Check playlist state to detect transitions between items
|
||||
bool announcement_playlist_active = (ann_ps.playlist_index < ann_ps.playlist.size()) ||
|
||||
(ann_ps.repeat_mode != REPEAT_OFF && !ann_ps.playlist.empty());
|
||||
bool media_playlist_active = (media_ps.playlist_index < media_ps.playlist.size()) ||
|
||||
(media_ps.repeat_mode != REPEAT_OFF && !media_ps.playlist.empty());
|
||||
|
||||
// Check announcement pipeline first
|
||||
media_source::MediaSource *announcement_source = ann_ps.active_source;
|
||||
if (announcement_source != nullptr) {
|
||||
media_source::MediaSourceState announcement_state = announcement_source->get_state();
|
||||
if (announcement_state != media_source::MediaSourceState::IDLE) {
|
||||
// Announcement is active - announcements take priority and never report PAUSED
|
||||
switch (announcement_state) {
|
||||
case media_source::MediaSourceState::PLAYING:
|
||||
case media_source::MediaSourceState::PAUSED: // Treat paused announcements as announcing
|
||||
this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING;
|
||||
break;
|
||||
case media_source::MediaSourceState::ERROR:
|
||||
ESP_LOGE(TAG, "Announcement source error");
|
||||
// Fall through to media pipeline state
|
||||
this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// Announcement source is idle, fall through to media pipeline
|
||||
this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
|
||||
}
|
||||
} else if (announcement_playlist_active && old_state == media_player::MEDIA_PLAYER_STATE_ANNOUNCING) {
|
||||
this->state = media_player::MEDIA_PLAYER_STATE_ANNOUNCING;
|
||||
} else {
|
||||
// No active announcement, check media pipeline
|
||||
this->state = this->get_source_state_(media_ps.active_source, media_playlist_active, old_state);
|
||||
}
|
||||
|
||||
if (this->state != old_state) {
|
||||
this->publish_state();
|
||||
@@ -349,9 +323,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
|
||||
// Only send END command once per source - check if we've already asked this source to stop
|
||||
if (ps.stopping_source != active_source) {
|
||||
ESP_LOGV(TAG, "Pipeline %u: stopping active source", pipeline);
|
||||
ps.stopping_source = active_source;
|
||||
active_source->handle_command(media_source::MediaSourceCommand::STOP);
|
||||
ps.speaker->stop();
|
||||
ps.stopping_source = active_source;
|
||||
}
|
||||
return false; // Leave in queue, retry next loop
|
||||
}
|
||||
@@ -363,9 +337,9 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
|
||||
// Only send STOP command once per source
|
||||
if (ps.stopping_source != target_source) {
|
||||
ESP_LOGV(TAG, "Pipeline %u: target source busy, stopping", pipeline);
|
||||
ps.stopping_source = target_source;
|
||||
target_source->handle_command(media_source::MediaSourceCommand::STOP);
|
||||
ps.speaker->stop();
|
||||
ps.stopping_source = target_source;
|
||||
}
|
||||
return false; // Leave in queue, retry next loop
|
||||
}
|
||||
@@ -385,6 +359,7 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
|
||||
if (!target_source->play_uri(uri)) {
|
||||
ESP_LOGE(TAG, "Pipeline %u: Failed to play URI: %s", pipeline, uri.c_str());
|
||||
ps.pending_source = nullptr;
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAYLIST_ADVANCE, pipeline);
|
||||
}
|
||||
|
||||
// Reset pending frame counter for this pipeline since we're starting a new source
|
||||
@@ -393,6 +368,306 @@ bool SpeakerSourceMediaPlayer::try_execute_play_uri_(const std::string &uri, uin
|
||||
return true; // Remove from queue
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop (process_control_queue_, queue_play_current_, handle_media_state_changed_)
|
||||
void SpeakerSourceMediaPlayer::queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline) {
|
||||
MediaPlayerControlCommand cmd{};
|
||||
cmd.type = type;
|
||||
cmd.pipeline = pipeline;
|
||||
if (xQueueSend(this->media_control_command_queue_, &cmd, 0) != pdTRUE) {
|
||||
ESP_LOGE(TAG, "Queue full, command dropped");
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop via automation commands (direct)
|
||||
void SpeakerSourceMediaPlayer::set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms) {
|
||||
if (pipeline < this->pipelines_.size()) {
|
||||
this->pipelines_[pipeline].playlist_delay_ms = delay_ms;
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop (process_control_queue_).
|
||||
// The timeout callback also runs on the main loop.
|
||||
void SpeakerSourceMediaPlayer::queue_play_current_(uint8_t pipeline, uint32_t delay_ms) {
|
||||
if (delay_ms > 0) {
|
||||
this->set_timeout(PIPELINE_TIMEOUT_IDS[pipeline], delay_ms,
|
||||
[this, pipeline]() { this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline); });
|
||||
} else {
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop (loop)
|
||||
void SpeakerSourceMediaPlayer::process_control_queue_() {
|
||||
MediaPlayerControlCommand control_command{};
|
||||
|
||||
// Use peek to check command without removing it
|
||||
if (xQueuePeek(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
|
||||
return;
|
||||
}
|
||||
|
||||
bool command_executed = false;
|
||||
uint8_t pipeline = control_command.pipeline;
|
||||
|
||||
// Get pipeline state
|
||||
PipelineContext &ps = this->pipelines_[pipeline];
|
||||
media_source::MediaSource *active_source = ps.active_source;
|
||||
|
||||
switch (control_command.type) {
|
||||
case MediaPlayerControlCommand::PLAY_URI: {
|
||||
// Always use our local playlist to start playback
|
||||
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||
ps.playlist.clear();
|
||||
ps.shuffle_indices.clear(); // Clear shuffle when starting fresh playlist
|
||||
ps.playlist_index = 0; // Reset index
|
||||
ps.playlist.push_back(*control_command.data.uri);
|
||||
|
||||
// Queue PLAY_CURRENT to initiate playback
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
command_executed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case MediaPlayerControlCommand::ENQUEUE_URI: {
|
||||
// Always add to our local playlist
|
||||
ps.playlist.push_back(*control_command.data.uri);
|
||||
|
||||
// If shuffle is active, add the new item to the end of the shuffle order
|
||||
if (!ps.shuffle_indices.empty()) {
|
||||
ps.shuffle_indices.push_back(ps.playlist.size() - 1);
|
||||
}
|
||||
|
||||
// If nothing is playing and no upcoming items are queued, start the new item.
|
||||
bool nothing_playing =
|
||||
(active_source == nullptr) || (active_source->get_state() == media_source::MediaSourceState::IDLE);
|
||||
if (nothing_playing && ps.playlist_index >= ps.playlist.size() - 1) {
|
||||
ps.playlist_index = ps.playlist.size() - 1; // Point to newly added item
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
}
|
||||
command_executed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case MediaPlayerControlCommand::PLAYLIST_ADVANCE: {
|
||||
// Internal message: a track finished, advance to next
|
||||
if (ps.repeat_mode != REPEAT_ONE) {
|
||||
ps.playlist_index++;
|
||||
}
|
||||
|
||||
// Check if we should continue playback
|
||||
if (ps.playlist_index < ps.playlist.size()) {
|
||||
this->queue_play_current_(pipeline, ps.playlist_delay_ms);
|
||||
} else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
|
||||
ps.playlist_index = 0;
|
||||
this->queue_play_current_(pipeline, ps.playlist_delay_ms);
|
||||
}
|
||||
command_executed = true;
|
||||
break;
|
||||
}
|
||||
|
||||
case MediaPlayerControlCommand::PLAY_CURRENT: {
|
||||
// Play the item at current playlist index (mapped through shuffle if active)
|
||||
if (ps.playlist_index < ps.playlist.size()) {
|
||||
size_t actual_position = this->get_playlist_position_(pipeline);
|
||||
command_executed = this->try_execute_play_uri_(ps.playlist[actual_position], pipeline);
|
||||
} else {
|
||||
command_executed = true; // Index out of bounds or empty playlist
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MediaPlayerControlCommand::SEND_COMMAND: {
|
||||
this->handle_player_command_(control_command.data.command, pipeline);
|
||||
command_executed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only remove from queue if successfully executed
|
||||
if (command_executed) {
|
||||
xQueueReceive(this->media_control_command_queue_, &control_command, 0);
|
||||
|
||||
// Delete the allocated string for PLAY_URI and ENQUEUE_URI commands
|
||||
if (control_command.type == MediaPlayerControlCommand::PLAY_URI ||
|
||||
control_command.type == MediaPlayerControlCommand::ENQUEUE_URI) {
|
||||
delete control_command.data.uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop only (via process_control_queue_)
|
||||
void SpeakerSourceMediaPlayer::handle_player_command_(media_player::MediaPlayerCommand player_command,
|
||||
uint8_t pipeline) {
|
||||
PipelineContext &ps = this->pipelines_[pipeline];
|
||||
media_source::MediaSource *active_source = ps.active_source;
|
||||
bool has_internal_playlist = (active_source != nullptr) && active_source->has_internal_playlist();
|
||||
|
||||
// Determine target source: prefer active, fall back to last
|
||||
media_source::MediaSource *target_source = nullptr;
|
||||
if (active_source != nullptr) {
|
||||
target_source = active_source;
|
||||
} else if (ps.last_source != nullptr) {
|
||||
target_source = ps.last_source;
|
||||
}
|
||||
|
||||
switch (player_command) {
|
||||
case media_player::MEDIA_PLAYER_COMMAND_TOGGLE: {
|
||||
// Convert TOGGLE to PLAY or PAUSE based on current state
|
||||
if ((active_source != nullptr) && (active_source->get_state() == media_source::MediaSourceState::PLAYING)) {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
|
||||
}
|
||||
} else if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) {
|
||||
bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist();
|
||||
if (last_has_internal_playlist) {
|
||||
ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY);
|
||||
} else {
|
||||
if (ps.playlist_index >= ps.playlist.size()) {
|
||||
ps.playlist_index = 0;
|
||||
}
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
}
|
||||
} else {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PLAY: {
|
||||
if (!has_internal_playlist && active_source == nullptr && !ps.playlist.empty()) {
|
||||
bool last_has_internal_playlist = (ps.last_source != nullptr) && ps.last_source->has_internal_playlist();
|
||||
if (last_has_internal_playlist) {
|
||||
ps.last_source->handle_command(media_source::MediaSourceCommand::PLAY);
|
||||
} else {
|
||||
if (ps.playlist_index >= ps.playlist.size()) {
|
||||
ps.playlist_index = 0;
|
||||
}
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
}
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PLAY);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PAUSE: {
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PAUSE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_STOP: {
|
||||
if (!has_internal_playlist) {
|
||||
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||
ps.playlist.clear();
|
||||
ps.shuffle_indices.clear();
|
||||
ps.playlist_index = 0;
|
||||
}
|
||||
if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::STOP);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_NEXT: {
|
||||
if (!has_internal_playlist) {
|
||||
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||
if (ps.playlist_index + 1 < ps.playlist.size()) {
|
||||
ps.playlist_index++;
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
} else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
|
||||
ps.playlist_index = 0;
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
}
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::NEXT);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_PREVIOUS: {
|
||||
if (!has_internal_playlist) {
|
||||
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||
if (ps.playlist_index > 0) {
|
||||
ps.playlist_index--;
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
} else if (ps.repeat_mode == REPEAT_ALL && !ps.playlist.empty()) {
|
||||
ps.playlist_index = ps.playlist.size() - 1;
|
||||
this->queue_command_(MediaPlayerControlCommand::PLAY_CURRENT, pipeline);
|
||||
}
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::PREVIOUS);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ONE:
|
||||
if (!has_internal_playlist) {
|
||||
ps.repeat_mode = REPEAT_ONE;
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ONE);
|
||||
}
|
||||
break;
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_OFF:
|
||||
if (!has_internal_playlist) {
|
||||
ps.repeat_mode = REPEAT_OFF;
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::REPEAT_OFF);
|
||||
}
|
||||
break;
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_REPEAT_ALL:
|
||||
if (!has_internal_playlist) {
|
||||
ps.repeat_mode = REPEAT_ALL;
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::REPEAT_ALL);
|
||||
}
|
||||
break;
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_CLEAR_PLAYLIST: {
|
||||
if (!has_internal_playlist) {
|
||||
this->cancel_timeout(PIPELINE_TIMEOUT_IDS[pipeline]);
|
||||
if (ps.playlist_index < ps.playlist.size()) {
|
||||
size_t actual_position = this->get_playlist_position_(pipeline);
|
||||
ps.playlist[0] = std::move(ps.playlist[actual_position]);
|
||||
ps.playlist.resize(1);
|
||||
ps.playlist_index = 0;
|
||||
} else {
|
||||
ps.playlist.clear();
|
||||
ps.playlist_index = 0;
|
||||
}
|
||||
ps.shuffle_indices.clear();
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::CLEAR_PLAYLIST);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_SHUFFLE:
|
||||
if (!has_internal_playlist) {
|
||||
this->shuffle_playlist_(pipeline);
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::SHUFFLE);
|
||||
}
|
||||
break;
|
||||
|
||||
case media_player::MEDIA_PLAYER_COMMAND_UNSHUFFLE:
|
||||
if (!has_internal_playlist) {
|
||||
this->unshuffle_playlist_(pipeline);
|
||||
} else if (target_source != nullptr) {
|
||||
target_source->handle_command(media_source::MediaSourceCommand::UNSHUFFLE);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// TURN_ON, TURN_OFF, ENQUEUE (handled separately with URL) are no-ops
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Called from main loop only. Entry points:
|
||||
// - HA/automation commands (direct)
|
||||
// - handle_play_uri_request_() via make_call().perform() (deferred from source tasks)
|
||||
@@ -401,15 +676,38 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call
|
||||
return;
|
||||
}
|
||||
|
||||
MediaPlayerControlCommand control_command;
|
||||
control_command.pipeline = MEDIA_PIPELINE;
|
||||
MediaPlayerControlCommand control_command{};
|
||||
|
||||
// Determine which pipeline to use based on announcement flag, falling back if the preferred pipeline
|
||||
// is not configured
|
||||
auto announcement = call.get_announcement();
|
||||
if (announcement.has_value() && announcement.value()) {
|
||||
if (this->pipelines_[ANNOUNCEMENT_PIPELINE].is_configured()) {
|
||||
control_command.pipeline = ANNOUNCEMENT_PIPELINE;
|
||||
} else {
|
||||
control_command.pipeline = MEDIA_PIPELINE;
|
||||
}
|
||||
} else {
|
||||
if (this->pipelines_[MEDIA_PIPELINE].is_configured()) {
|
||||
control_command.pipeline = MEDIA_PIPELINE;
|
||||
} else {
|
||||
control_command.pipeline = ANNOUNCEMENT_PIPELINE;
|
||||
}
|
||||
}
|
||||
|
||||
auto media_url = call.get_media_url();
|
||||
if (media_url.has_value()) {
|
||||
control_command.type = MediaPlayerControlCommand::PLAY_URI;
|
||||
auto command = call.get_command();
|
||||
bool enqueue = command.has_value() && command.value() == media_player::MEDIA_PLAYER_COMMAND_ENQUEUE;
|
||||
|
||||
if (enqueue) {
|
||||
control_command.type = MediaPlayerControlCommand::ENQUEUE_URI;
|
||||
} else {
|
||||
control_command.type = MediaPlayerControlCommand::PLAY_URI;
|
||||
}
|
||||
// Heap allocation is unavoidable: URIs from Home Assistant are arbitrary-length (media URLs with tokens
|
||||
// can easily exceed 500 bytes). Deleted after the command is consumed. FreeRTOS queues require items to be
|
||||
// copyable, so we store a pointer to the string in the queue rather than the string itself.
|
||||
// can easily exceed 500 bytes). Deleted in process_control_queue_() after the command is consumed. FreeRTOS queues
|
||||
// require items to be copyable, so we store a pointer to the string in the queue rather than the string itself.
|
||||
control_command.data.uri = new std::string(media_url.value());
|
||||
if (xQueueSend(this->media_control_command_queue_, &control_command, 0) != pdTRUE) {
|
||||
delete control_command.data.uri;
|
||||
@@ -454,6 +752,9 @@ void SpeakerSourceMediaPlayer::control(const media_player::MediaPlayerCall &call
|
||||
}
|
||||
|
||||
media_player::MediaPlayerTraits SpeakerSourceMediaPlayer::get_traits() {
|
||||
// This media player supports more traits like playlists, repeat, and shuffle, but the ESPHome API currently (March
|
||||
// 2026) doesn't support those commands, so we only report pause support for now since that's used by the frontend and
|
||||
// supported by our player.
|
||||
auto traits = media_player::MediaPlayerTraits();
|
||||
traits.set_supports_pause(true);
|
||||
|
||||
@@ -541,6 +842,58 @@ void SpeakerSourceMediaPlayer::set_volume_(float volume, bool publish) {
|
||||
this->defer([this, volume]() { this->volume_trigger_.trigger(volume); });
|
||||
}
|
||||
|
||||
size_t SpeakerSourceMediaPlayer::get_playlist_position_(uint8_t pipeline) const {
|
||||
const PipelineContext &ps = this->pipelines_[pipeline];
|
||||
|
||||
if (ps.shuffle_indices.empty() || ps.playlist_index >= ps.shuffle_indices.size()) {
|
||||
return ps.playlist_index;
|
||||
}
|
||||
return ps.shuffle_indices[ps.playlist_index];
|
||||
}
|
||||
|
||||
void SpeakerSourceMediaPlayer::shuffle_playlist_(uint8_t pipeline) {
|
||||
PipelineContext &ps = this->pipelines_[pipeline];
|
||||
|
||||
if (ps.playlist.size() <= 1) {
|
||||
ps.shuffle_indices.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture current actual position BEFORE modifying shuffle_indices
|
||||
size_t current_actual = this->get_playlist_position_(pipeline);
|
||||
|
||||
// Build indices vector
|
||||
ps.shuffle_indices.resize(ps.playlist.size());
|
||||
for (size_t i = 0; i < ps.playlist.size(); i++) {
|
||||
ps.shuffle_indices[i] = i;
|
||||
}
|
||||
|
||||
// Fisher-Yates shuffle using ESPHome's random helper
|
||||
for (size_t i = ps.shuffle_indices.size() - 1; i > 0; i--) {
|
||||
size_t j = random_uint32() % (i + 1);
|
||||
std::swap(ps.shuffle_indices[i], ps.shuffle_indices[j]);
|
||||
}
|
||||
|
||||
// Move current track to current position (so playback continues seamlessly)
|
||||
if (ps.playlist_index < ps.shuffle_indices.size()) {
|
||||
for (size_t i = 0; i < ps.shuffle_indices.size(); i++) {
|
||||
if (ps.shuffle_indices[i] == current_actual) {
|
||||
std::swap(ps.shuffle_indices[i], ps.shuffle_indices[ps.playlist_index]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SpeakerSourceMediaPlayer::unshuffle_playlist_(uint8_t pipeline) {
|
||||
PipelineContext &ps = this->pipelines_[pipeline];
|
||||
|
||||
if (!ps.shuffle_indices.empty() && ps.playlist_index < ps.shuffle_indices.size()) {
|
||||
ps.playlist_index = ps.shuffle_indices[ps.playlist_index];
|
||||
}
|
||||
ps.shuffle_indices.clear();
|
||||
}
|
||||
|
||||
} // namespace esphome::speaker_source
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -28,8 +28,9 @@ namespace esphome::speaker_source {
|
||||
//
|
||||
// - Main loop task: setup(), loop(), dump_config(), handle_media_state_changed_(),
|
||||
// handle_volume_request_(), handle_mute_request_(), handle_play_uri_request_(),
|
||||
// set_volume_(), set_mute_state_(), control(), get_media_pipeline_state_(),
|
||||
// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_()
|
||||
// set_volume_(), set_mute_state_(), control(), get_source_state_(),
|
||||
// find_source_for_uri_(), try_execute_play_uri_(), save_volume_restore_state_(),
|
||||
// process_control_queue_(), handle_player_command_(), queue_command_(), queue_play_current_()
|
||||
//
|
||||
// - Media source task(s): handle_media_output_() via SourceBinding::write_audio().
|
||||
// Called from each source's decode task thread when streaming audio data.
|
||||
@@ -49,11 +50,18 @@ namespace esphome::speaker_source {
|
||||
// - defer(): SourceBinding::request_volume/request_mute/request_play_uri -> main loop
|
||||
// - Atomic fields (active_source, pending_frames): shared between all three thread contexts
|
||||
//
|
||||
// Non-atomic pipeline fields (last_source, stopping_source, pending_source) are only accessed
|
||||
// from the main loop thread.
|
||||
// Non-atomic pipeline fields (last_source, stopping_source, pending_source, playlist,
|
||||
// playlist_index, repeat_mode) are only accessed from the main loop thread.
|
||||
|
||||
enum Pipeline : uint8_t {
|
||||
MEDIA_PIPELINE = 0,
|
||||
ANNOUNCEMENT_PIPELINE = 1,
|
||||
};
|
||||
|
||||
enum RepeatMode : uint8_t {
|
||||
REPEAT_OFF = 0,
|
||||
REPEAT_ONE = 1,
|
||||
REPEAT_ALL = 2,
|
||||
};
|
||||
|
||||
// Forward declaration
|
||||
@@ -78,6 +86,9 @@ struct SourceBinding : public media_source::MediaSourceListener {
|
||||
void request_play_uri(const std::string &uri) override;
|
||||
};
|
||||
|
||||
/// @brief Timeout IDs for playlist delay, indexed by Pipeline enum
|
||||
static constexpr uint32_t PIPELINE_TIMEOUT_IDS[] = {1, 2};
|
||||
|
||||
struct PipelineContext {
|
||||
speaker::Speaker *speaker{nullptr};
|
||||
optional<media_player::MediaPlayerSupportedFormat> format;
|
||||
@@ -92,6 +103,18 @@ struct PipelineContext {
|
||||
// Uses std::vector because the count varies across instances (multiple speaker_source media players may exist).
|
||||
std::vector<std::unique_ptr<SourceBinding>> sources;
|
||||
|
||||
// Dynamic allocation is unavoidable here: URIs from Home Assistant are arbitrary-length strings
|
||||
// (media URLs with tokens can easily exceed 500 bytes), and playlist size is unbounded.
|
||||
// Pre-allocating fixed buffers would waste significant RAM when idle without covering worst cases.
|
||||
std::vector<std::string> playlist;
|
||||
size_t playlist_index{0};
|
||||
RepeatMode repeat_mode{REPEAT_OFF};
|
||||
uint32_t playlist_delay_ms{0};
|
||||
|
||||
// When non-empty, playlist_index indexes into these vectors
|
||||
// which contain the actual playlist indices in shuffled order
|
||||
std::vector<size_t> shuffle_indices;
|
||||
|
||||
// Track frames sent to speaker to correlate with playback callbacks.
|
||||
// Atomic because it is written from the main loop/source tasks and read/decremented from the speaker playback
|
||||
// callback.
|
||||
@@ -103,14 +126,17 @@ struct PipelineContext {
|
||||
|
||||
struct MediaPlayerControlCommand {
|
||||
enum Type : uint8_t {
|
||||
PLAY_URI, // Find a source that can handle this URI and play it
|
||||
SEND_COMMAND, // Send command to active source
|
||||
PLAY_URI, // Clear playlist, reset index, add URI, queue PLAY_CURRENT
|
||||
ENQUEUE_URI, // Add URI to playlist, queue PLAY_CURRENT if idle
|
||||
PLAYLIST_ADVANCE, // Advance index (or wrap for repeat_all), queue PLAY_CURRENT if more items
|
||||
PLAY_CURRENT, // Play item at current playlist index (can retry if speaker not ready)
|
||||
SEND_COMMAND, // Send command to active source
|
||||
};
|
||||
Type type;
|
||||
uint8_t pipeline;
|
||||
uint8_t pipeline; // MEDIA_PIPELINE or ANNOUNCEMENT_PIPELINE
|
||||
|
||||
union {
|
||||
std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI)
|
||||
std::string *uri; // Owned pointer, must delete after xQueueReceive (for PLAY_URI and ENQUEUE_URI)
|
||||
media_player::MediaPlayerCommand command;
|
||||
} data;
|
||||
};
|
||||
@@ -154,6 +180,8 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
|
||||
Trigger<> *get_unmute_trigger() { return &this->unmute_trigger_; }
|
||||
Trigger<float> *get_volume_trigger() { return &this->volume_trigger_; }
|
||||
|
||||
void set_playlist_delay_ms(uint8_t pipeline, uint32_t delay_ms);
|
||||
|
||||
protected:
|
||||
// Callbacks from source bindings (pipeline index is captured at binding creation time)
|
||||
size_t handle_media_output_(uint8_t pipeline, media_source::MediaSource *source, const uint8_t *data, size_t length,
|
||||
@@ -181,17 +209,35 @@ class SpeakerSourceMediaPlayer : public Component, public media_player::MediaPla
|
||||
/// @brief Saves the current volume and mute state to the flash for restoration.
|
||||
void save_volume_restore_state_();
|
||||
|
||||
/// @brief Determine media player state from the media pipeline's active source
|
||||
/// @param media_source Active source for the media pipeline (may be nullptr)
|
||||
/// @brief Determine media player state from a pipeline's active source
|
||||
/// @param media_source Active source (may be nullptr)
|
||||
/// @param playlist_active Whether the pipeline's playlist is in progress
|
||||
/// @param old_state Previous media player state (used for transition smoothing)
|
||||
/// @return The appropriate MediaPlayerState
|
||||
media_player::MediaPlayerState get_media_pipeline_state_(media_source::MediaSource *media_source) const;
|
||||
media_player::MediaPlayerState get_source_state_(media_source::MediaSource *media_source, bool playlist_active,
|
||||
media_player::MediaPlayerState old_state) const;
|
||||
|
||||
void process_control_queue_();
|
||||
void handle_player_command_(media_player::MediaPlayerCommand player_command, uint8_t pipeline);
|
||||
bool try_execute_play_uri_(const std::string &uri, uint8_t pipeline);
|
||||
media_source::MediaSource *find_source_for_uri_(const std::string &uri, uint8_t pipeline);
|
||||
void queue_command_(MediaPlayerControlCommand::Type type, uint8_t pipeline);
|
||||
void queue_play_current_(uint8_t pipeline, uint32_t delay_ms = 0);
|
||||
|
||||
/// @brief Maps playlist_index through shuffle indices if shuffle is active
|
||||
size_t get_playlist_position_(uint8_t pipeline) const;
|
||||
|
||||
/// @brief Generates shuffled indices for the playlist, keeping current track at current position
|
||||
void shuffle_playlist_(uint8_t pipeline);
|
||||
|
||||
/// @brief Clears shuffle indices and adjusts playlist_index to maintain current track
|
||||
void unshuffle_playlist_(uint8_t pipeline);
|
||||
|
||||
QueueHandle_t media_control_command_queue_;
|
||||
|
||||
// Pipeline context for media pipeline. See THREADING MODEL at top of namespace for access rules.
|
||||
std::array<PipelineContext, 1> pipelines_;
|
||||
// Pipeline context for media (index 0) and announcement (index 1) pipelines.
|
||||
// See THREADING MODEL at top of namespace for access rules.
|
||||
std::array<PipelineContext, 2> pipelines_;
|
||||
|
||||
// Used to save volume/mute state for restoration on reboot
|
||||
ESPPreferenceObject pref_;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
|
||||
@@ -26,4 +28,5 @@ MAX_EXECUTOR_WORKERS = 48
|
||||
|
||||
SENTINEL = object()
|
||||
|
||||
DASHBOARD_COMMAND = ["esphome", "--dashboard"]
|
||||
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
|
||||
DASHBOARD_COMMAND = [*ESPHOME_COMMAND, "--dashboard"]
|
||||
|
||||
@@ -52,7 +52,7 @@ from esphome.util import get_serial_ports, shlex_quote
|
||||
from esphome.yaml_util import FastestAvailableSafeLoader
|
||||
|
||||
from ..helpers import write_file
|
||||
from .const import DASHBOARD_COMMAND, DashboardEvent
|
||||
from .const import DASHBOARD_COMMAND, ESPHOME_COMMAND, DashboardEvent
|
||||
from .core import DASHBOARD, ESPHomeDashboard, Event
|
||||
from .entries import UNKNOWN_STATE, DashboardEntry, entry_state_to_bool
|
||||
from .models import build_device_list_response
|
||||
@@ -1079,7 +1079,7 @@ class DownloadBinaryRequestHandler(BaseHandler):
|
||||
return
|
||||
|
||||
if not path.is_file():
|
||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||
args = [*ESPHOME_COMMAND, "idedata", settings.rel_path(configuration)]
|
||||
rc, stdout, _ = await async_run_system_command(args)
|
||||
|
||||
if rc != 0:
|
||||
@@ -1462,7 +1462,7 @@ class JsonConfigRequestHandler(BaseHandler):
|
||||
self.send_error(404)
|
||||
return
|
||||
|
||||
args = ["esphome", "config", str(filename), "--show-secrets"]
|
||||
args = [*ESPHOME_COMMAND, "config", str(filename), "--show-secrets"]
|
||||
|
||||
rc, stdout, stderr = await async_run_system_command(args)
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ speaker:
|
||||
i2s_dout_pin: ${i2s_dout_pin}
|
||||
sample_rate: 48000
|
||||
num_channels: 2
|
||||
- platform: mixer
|
||||
output_speaker: speaker_id
|
||||
source_speakers:
|
||||
- id: announcement_mixer_speaker_id
|
||||
- id: media_mixer_speaker_id
|
||||
|
||||
audio_file:
|
||||
- id: test_audio
|
||||
@@ -19,7 +24,9 @@ audio_file:
|
||||
|
||||
media_source:
|
||||
- platform: audio_file
|
||||
id: audio_file_source
|
||||
id: announcement_audio_file_source
|
||||
- platform: audio_file
|
||||
id: media_audio_file_source
|
||||
|
||||
media_player:
|
||||
- platform: speaker_source
|
||||
@@ -29,15 +36,26 @@ media_player:
|
||||
volume_initial: 0.75
|
||||
volume_max: 0.95
|
||||
volume_min: 0.0
|
||||
media_pipeline:
|
||||
speaker: speaker_id
|
||||
announcement_pipeline:
|
||||
speaker: announcement_mixer_speaker_id
|
||||
format: FLAC
|
||||
num_channels: 1
|
||||
sources:
|
||||
- audio_file_source
|
||||
- announcement_audio_file_source
|
||||
media_pipeline:
|
||||
speaker: media_mixer_speaker_id
|
||||
format: FLAC
|
||||
num_channels: 1
|
||||
sources:
|
||||
- media_audio_file_source
|
||||
on_mute:
|
||||
- media_player.pause:
|
||||
- media_player.shuffle:
|
||||
id: media_player_id
|
||||
on_unmute:
|
||||
- media_player.play:
|
||||
- media_player.unshuffle:
|
||||
id: media_player_id
|
||||
on_volume:
|
||||
- speaker_source.set_playlist_delay:
|
||||
id: media_player_id
|
||||
pipeline: media
|
||||
delay: 500ms
|
||||
|
||||
Reference in New Issue
Block a user