diff --git a/esphome/components/sendspin/__init__.py b/esphome/components/sendspin/__init__.py index 570fd3fadd..8ef11a7f90 100644 --- a/esphome/components/sendspin/__init__.py +++ b/esphome/components/sendspin/__init__.py @@ -30,6 +30,7 @@ CONF_SENDSPIN_ID = "sendspin_id" CONF_INITIAL_STATIC_DELAY = "initial_static_delay" CONF_FIXED_DELAY = "fixed_delay" CONF_DECODE_MEMORY = "decode_memory" +CONF_CODECS = "codecs" # Matches ARTWORK_MAX_SLOTS in sendspin-cpp. MAX_ARTWORK_SLOTS = 4 @@ -44,6 +45,20 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS") CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM") CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED") +CODEC_FLAC = "flac" +CODEC_OPUS = "opus" +CODEC_PCM = "pcm" + +CODECS = { + CODEC_FLAC: CODEC_FORMAT_FLAC, + CODEC_OPUS: CODEC_FORMAT_OPUS, + CODEC_PCM: CODEC_FORMAT_PCM, +} + +# Opus only supports 48 kHz audio, so it is left out of the default list at other rates. +DEFAULT_CODECS = [CODEC_FLAC, CODEC_OPUS, CODEC_PCM] +OPUS_SAMPLE_RATE = 48000 + SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True) IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG") IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG") @@ -286,16 +301,13 @@ 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 + # Configures the player role. Each configured codec is advertised for 16 bits per sample + # mono and stereo at the configured sample rate. The order is a preference order, both for + # the codecs themselves and for stereo over mono. 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) + codecs = player_cfg[CONF_CODECS] def _audio_format(codec: MockObj, channels: int) -> cg.StructInitializer: return cg.StructInitializer( diff --git a/esphome/components/sendspin/media_source/__init__.py b/esphome/components/sendspin/media_source/__init__.py index 6af244d41f..6a9f1f18ba 100644 --- a/esphome/components/sendspin/media_source/__init__.py +++ b/esphome/components/sendspin/media_source/__init__.py @@ -13,11 +13,16 @@ from esphome.cpp_generator import MockObj, TemplateArgsType from esphome.types import ConfigType from .. import ( + CODEC_OPUS, + CODECS, + CONF_CODECS, CONF_DECODE_MEMORY, CONF_FIXED_DELAY, CONF_INITIAL_STATIC_DELAY, CONF_SENDSPIN_ID, + DEFAULT_CODECS, MEMORY_LOCATIONS, + OPUS_SAMPLE_RATE, SendspinHub, register_player_config, request_controller_support, @@ -49,10 +54,32 @@ DisableStaticDelayAdjustmentAction = sendspin_ns.class_( ) +def _resolve_codecs(config: ConfigType) -> ConfigType: + """Validate the codec preference list, filling in the default when it is not set.""" + sample_rate = config[CONF_SAMPLE_RATE] + if (codecs := config.get(CONF_CODECS)) is None: + config[CONF_CODECS] = [ + codec + for codec in DEFAULT_CODECS + if codec != CODEC_OPUS or sample_rate == OPUS_SAMPLE_RATE + ] + return config + + if len(set(codecs)) != len(codecs): + raise cv.Invalid("Each codec may only be listed once", path=[CONF_CODECS]) + if CODEC_OPUS in codecs and sample_rate != OPUS_SAMPLE_RATE: + raise cv.Invalid( + f"Codec '{CODEC_OPUS}' requires a {CONF_SAMPLE_RATE} of {OPUS_SAMPLE_RATE}", + path=[CONF_CODECS], + ) + return config + + def _register(config: ConfigType) -> ConfigType: request_controller_support() register_player_config( { + CONF_CODECS: config[CONF_CODECS], CONF_SAMPLE_RATE: config[CONF_SAMPLE_RATE], CONF_BUFFER_SIZE: config[CONF_BUFFER_SIZE], CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY], @@ -85,9 +112,13 @@ CONFIG_SCHEMA = cv.All( min=16000, max=96000 ), cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True), + cv.Optional(CONF_CODECS): cv.All( + cv.ensure_list(cv.enum(CODECS, lower=True)), cv.Length(min=1) + ), } ), cv.only_on_esp32, + _resolve_codecs, _register, ) diff --git a/tests/component_tests/sendspin/test_media_source.py b/tests/component_tests/sendspin/test_media_source.py new file mode 100644 index 0000000000..6c2f79198d --- /dev/null +++ b/tests/component_tests/sendspin/test_media_source.py @@ -0,0 +1,90 @@ +"""Validation tests for the sendspin media_source platform. + +These cover the codec preference list, whose rejection branches a compile test +cannot reach: a `test*.yaml` can only assert that a configuration is accepted. +""" + +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sendspin import CONF_CODECS, _get_data +from esphome.components.sendspin.media_source import CONFIG_SCHEMA +from esphome.const import PlatformFramework +from esphome.types import ConfigType +from tests.component_tests.types import SetCoreConfigCallable + + +def _media_source_config(**overrides: Any) -> ConfigType: + """Build a minimal valid media source config, allowing field overrides.""" + config: ConfigType = { + "id": "sendspin_media_source", + "sendspin_id": "sendspin_hub", + } + config.update(overrides) + return config + + +def test_default_codecs_at_48_khz(set_core_config: SetCoreConfigCallable) -> None: + """Every codec is advertised when the sample rate suits all of them.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config()) + + assert config[CONF_CODECS] == ["flac", "opus", "pcm"] + + +def test_default_codecs_drop_opus_at_other_rates( + set_core_config: SetCoreConfigCallable, +) -> None: + """Opus only supports 48 kHz, so it leaves the default list at other rates.""" + set_core_config(PlatformFramework.ESP32_IDF) + + config = CONFIG_SCHEMA(_media_source_config(sample_rate=44100)) + + assert config[CONF_CODECS] == ["flac", "pcm"] + + +def test_configured_order_is_preserved(set_core_config: SetCoreConfigCallable) -> None: + """The list is a preference order, so it reaches the player role as written.""" + set_core_config(PlatformFramework.ESP32_IDF) + + CONFIG_SCHEMA(_media_source_config(codecs=["pcm", "flac"])) + + assert _get_data().player_config[CONF_CODECS] == ["pcm", "flac"] + + +def test_empty_codec_list_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A player with no codecs at all could never be given a stream.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="length of value must be at least 1"): + CONFIG_SCHEMA(_media_source_config(codecs=[])) + + +def test_duplicate_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """A repeated codec has no meaning in a preference order.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="may only be listed once"): + CONFIG_SCHEMA(_media_source_config(codecs=["flac", "flac"])) + + +def test_unknown_codec_rejected(set_core_config: SetCoreConfigCallable) -> None: + """Only codecs the player role can decode are accepted.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="Unknown value"): + CONFIG_SCHEMA(_media_source_config(codecs=["mp3"])) + + +def test_opus_at_wrong_sample_rate_rejected( + set_core_config: SetCoreConfigCallable, +) -> None: + """Asking for Opus at a rate it cannot handle fails rather than silently + dropping the stated preference.""" + set_core_config(PlatformFramework.ESP32_IDF) + + with pytest.raises(cv.Invalid, match="requires a sample_rate of 48000"): + CONFIG_SCHEMA(_media_source_config(codecs=["opus"], sample_rate=44100)) diff --git a/tests/components/sendspin/common-media_source.yaml b/tests/components/sendspin/common-media_source.yaml index 1977b79c04..0c136fbd43 100644 --- a/tests/components/sendspin/common-media_source.yaml +++ b/tests/components/sendspin/common-media_source.yaml @@ -9,3 +9,4 @@ media_source: static_delay_adjustable: true fixed_delay: 480us decode_memory: internal + codecs: [pcm, opus, flac]