Fix: dynamically pick supported sample rates for esphome
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 19 Feb 2025 12:05:55 +0000 (13:05 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Wed, 19 Feb 2025 12:05:55 +0000 (13:05 +0100)
music_assistant/constants.py
music_assistant/providers/airplay/provider.py
music_assistant/providers/chromecast/__init__.py
music_assistant/providers/dlna/__init__.py
music_assistant/providers/hass_players/__init__.py
music_assistant/providers/player_group/__init__.py
music_assistant/providers/slimproto/__init__.py
music_assistant/providers/snapcast/__init__.py
music_assistant/providers/sonos/provider.py
music_assistant/providers/sonos_s1/__init__.py

index c8dc2b2f0c6d5d43d7a4a481b634e4787c7b082f..514a49c9b56cc2fd736ee6031111ab3e7f8f0a3c 100644 (file)
@@ -495,28 +495,41 @@ CONF_ENTRY_WARN_PREVIEW = ConfigEntry(
 
 
 def create_sample_rates_config_entry(
-    max_sample_rate: int,
-    max_bit_depth: int,
+    supported_sample_rates: list[int] | None = None,
+    supported_bit_depths: list[int] | None = None,
+    hidden: bool = False,
+    max_sample_rate: int | None = None,
+    max_bit_depth: int | None = None,
     safe_max_sample_rate: int = 48000,
     safe_max_bit_depth: int = 16,
-    hidden: bool = False,
-    supported_sample_rates: list[int] | None = None,
 ) -> ConfigEntry:
     """Create sample rates config entry based on player specific helpers."""
     assert CONF_ENTRY_SAMPLE_RATES.options
+    if supported_sample_rates is None:
+        supported_sample_rates = []
+    if supported_bit_depths is None:
+        supported_bit_depths = []
     conf_entry = ConfigEntry.from_dict(CONF_ENTRY_SAMPLE_RATES.to_dict())
     conf_entry.hidden = hidden
     options: list[ConfigValueOption] = []
     default_value: list[str] = []
+
     for option in CONF_ENTRY_SAMPLE_RATES.options:
         option_value = cast(str, option.value)
         sample_rate_str, bit_depth_str = option_value.split(MULTI_VALUE_SPLITTER, 1)
         sample_rate = int(sample_rate_str)
         bit_depth = int(bit_depth_str)
-        if supported_sample_rates and sample_rate not in supported_sample_rates:
+        # if no supported sample rates are defined, we accept all within max_sample_rate
+        if not supported_sample_rates and max_sample_rate and sample_rate <= max_sample_rate:
+            supported_sample_rates.append(sample_rate)
+        if not supported_bit_depths and max_bit_depth and bit_depth <= max_bit_depth:
+            supported_bit_depths.append(bit_depth)
+
+        if sample_rate not in supported_sample_rates:
+            continue
+        if bit_depth not in supported_bit_depths:
             continue
-        if sample_rate <= max_sample_rate and bit_depth <= max_bit_depth:
-            options.append(option)
+        options.append(option)
         if sample_rate <= safe_max_sample_rate and bit_depth <= safe_max_bit_depth:
             default_value.append(option_value)
     conf_entry.options = options
index c719330c3c946815df25214bc5199f4d0a36dd82..58313353c34e41e9664880cd4ae0328b074e789b 100644 (file)
@@ -113,7 +113,9 @@ PLAYER_CONFIG_ENTRIES = (
         range=(500, 3000),
     ),
     # airplay has fixed sample rate/bit depth so make this config entry static and hidden
-    create_sample_rates_config_entry(44100, 16, 44100, 16, True),
+    create_sample_rates_config_entry(
+        supported_sample_rates=[44100], supported_bit_depths=[16], hidden=True
+    ),
     ConfigEntry(
         key=CONF_IGNORE_VOLUME,
         type=ConfigEntryType.BOOLEAN,
index f6957c2925c7f628321f31a6238657bad14961a9..1836896acfa2a96df68ebd2934751ec78c27958a 100644 (file)
@@ -67,8 +67,14 @@ PLAYER_CONFIG_ENTRIES = (
 # originally/officially cast supports 96k sample rate (even for groups)
 # but it seems a (recent?) update broke this ?!
 # For now only set safe default values and let the user try out higher values
-CONF_ENTRY_SAMPLE_RATES_CAST = create_sample_rates_config_entry(192000, 24, 48000, 24)
-CONF_ENTRY_SAMPLE_RATES_CAST_GROUP = create_sample_rates_config_entry(96000, 24, 44100, 16)
+CONF_ENTRY_SAMPLE_RATES_CAST = create_sample_rates_config_entry(
+    max_sample_rate=192000,
+    max_bit_depth=24,
+)
+CONF_ENTRY_SAMPLE_RATES_CAST_GROUP = create_sample_rates_config_entry(
+    max_sample_rate=96000,
+    max_bit_depth=24,
+)
 
 
 MASS_APP_ID = "C35B0678"
index 22836391745a130fa3718b0e80e0449260f5d3b0..5497b4e02c5476c72114b4330f9d0a11258a05ec 100644 (file)
@@ -66,7 +66,7 @@ PLAYER_CONFIG_ENTRIES = (
     # enable flow mode by default because
     # most dlna players do not support enqueueing
     CONF_ENTRY_FLOW_MODE_DEFAULT_ENABLED,
-    create_sample_rates_config_entry(192000, 24, 96000, 24),
+    create_sample_rates_config_entry(max_sample_rate=192000, max_bit_depth=24),
 )
 
 
index 67fe2a2beeb621236660e16d52c2d04ca2d66fea..87465827332db5a15b8a0e6c07911d0c8ce1e0c7 100644 (file)
@@ -8,13 +8,15 @@ Requires the Home Assistant Plugin.
 from __future__ import annotations
 
 import asyncio
+import logging
+import os
 import time
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, TypedDict, cast
 
 from hass_client.exceptions import FailedCommand
 from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
 from music_assistant_models.enums import ConfigEntryType, PlayerFeature, PlayerState, PlayerType
-from music_assistant_models.errors import SetupFailedError
+from music_assistant_models.errors import InvalidDataError, LoginFailed, SetupFailedError
 from music_assistant_models.player import DeviceInfo, Player, PlayerMedia
 
 from music_assistant.constants import (
@@ -23,6 +25,7 @@ from music_assistant.constants import (
     CONF_ENTRY_CROSSFADE_DURATION,
     CONF_ENTRY_ENABLE_ICY_METADATA,
     CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED,
+    CONF_ENTRY_ENFORCE_MP3_HIDDEN,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
     CONF_ENTRY_HTTP_PROFILE,
     CONF_ENTRY_HTTP_PROFILE_FORCED_2,
@@ -65,27 +68,6 @@ DEFAULT_PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_ENABLE_ICY_METADATA,
     CONF_ENTRY_FLOW_MODE_ENFORCED,
 )
-ESPHOME_V2_MODELS = (
-    # The Home Assistant Voice PE introduces a new ESPHome mediaplayer
-    # that supports FLAC 48khz/16 bits and has some other optimizations
-    # this player is also used in some other (voice) ESPHome projects
-    # so until the new media player component is merged into ESPHome
-    # we keep a list here of model names that use the new player
-    "Home Assistant Voice PE",
-    "Koala Satellite",
-    "Respeaker Lite Satellite",
-    "Satellite1",
-)
-ESPHOME_V2_MODELS_PLAYER_CONFIG_ENTRIES = (
-    # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits
-    CONF_ENTRY_CROSSFADE,
-    CONF_ENTRY_CROSSFADE_DURATION,
-    CONF_ENTRY_FLOW_MODE_ENFORCED,
-    CONF_ENTRY_HTTP_PROFILE_FORCED_2,
-    create_sample_rates_config_entry(48000, 16, hidden=True, supported_sample_rates=[48000]),
-    # although the Voice PE supports announcements, it does not support volume for announcements
-    *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
-)
 
 
 async def _get_hass_media_players(
@@ -107,6 +89,16 @@ async def _get_hass_media_players(
         yield state
 
 
+class ESPHomeSupportedAudioFormat(TypedDict):
+    """ESPHome Supported Audio Format."""
+
+    format: str  # flac or mp3
+    sample_rate: int  # e.g. 48000
+    num_channels: int  # 1 for announcements, 2 for media
+    purpose: int  # 0 for media, 1 for announcements
+    sample_bytes: int  # 1 for 8 bit, 2 for 16 bit, 4 for 32 bit
+
+
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
@@ -186,9 +178,40 @@ class HomeAssistantPlayers(PlayerProvider):
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
         base_entries = await super().get_player_config_entries(player_id)
         player = self.mass.players.get(player_id)
-        if player and player.device_info.model in ESPHOME_V2_MODELS:
+        if player and player.extra_data.get("esphome_supported_audio_formats"):
             # optimized config for new ESPHome mediaplayer
-            return base_entries + ESPHOME_V2_MODELS_PLAYER_CONFIG_ENTRIES
+            supported_sample_rates: list[int] = []
+            supported_bit_depths: list[int] = []
+            supports_flac: bool = False
+            for supported_format in player.extra_data["esphome_supported_audio_formats"]:
+                if supported_format["purpose"] != 0:
+                    continue
+                if supported_format["format"] == "flac":
+                    supports_flac = True
+                if supported_format["sample_rate"] not in supported_sample_rates:
+                    supported_sample_rates.append(supported_format["sample_rate"])
+                bit_depth = supported_format["sample_bytes"] * 8
+                if bit_depth not in supported_bit_depths:
+                    supported_bit_depths.append(bit_depth)
+            if not supports_flac:
+                base_entries = (*base_entries, CONF_ENTRY_ENFORCE_MP3_HIDDEN)
+            return (
+                *base_entries,
+                # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits
+                CONF_ENTRY_CROSSFADE,
+                CONF_ENTRY_CROSSFADE_DURATION,
+                CONF_ENTRY_FLOW_MODE_ENFORCED,
+                CONF_ENTRY_HTTP_PROFILE_FORCED_2,
+                create_sample_rates_config_entry(
+                    supported_sample_rates=supported_sample_rates,
+                    supported_bit_depths=supported_bit_depths,
+                    hidden=True,
+                ),
+                # although the Voice PE supports announcements,
+                # it does not support volume for announcements
+                *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
+            )
+
         return base_entries + DEFAULT_PLAYER_CONFIG_ENTRIES
 
     async def cmd_stop(self, player_id: str) -> None:
@@ -232,9 +255,9 @@ class HomeAssistantPlayers(PlayerProvider):
 
     async def play_media(self, player_id: str, media: PlayerMedia) -> None:
         """Handle PLAY MEDIA on given player."""
-        is_esphome_v2 = self.mass.players.get(player_id).device_info.model in ESPHOME_V2_MODELS
-        if self.mass.config.get_raw_player_config_value(
-            player_id, CONF_ENFORCE_MP3, not is_esphome_v2
+        if self.mass.config.get_player_config_value(
+            player_id,
+            CONF_ENFORCE_MP3,
         ):
             media.uri = media.uri.replace(".flac", ".mp3")
         player = self.mass.players.get(player_id, True)
@@ -386,23 +409,40 @@ class HomeAssistantPlayers(PlayerProvider):
         """Handle setup of a Player from an hass entity."""
         hass_device: HassDevice | None = None
         hass_domain: str | None = None
+        extra_player_data: dict[str, Any] = {}
         if entity_registry_entry := entity_registry.get(state["entity_id"]):
             hass_device = device_registry.get(entity_registry_entry["device_id"])
             hass_domain = entity_registry_entry["platform"]
+            extra_player_data["entity_registry_id"] = entity_registry_entry["id"]
+            extra_player_data["hass_domain"] = hass_domain
+            extra_player_data["hass_device_id"] = hass_device["id"] if hass_device else None
+            if hass_domain == "esphome":
+                # if the player is an ESPHome player, we need to check if it is a V2 player
+                # as the V2 player has different capabilities and needs different config entries
+                # The new media player component publishes its supported sample rates but that info
+                # is not exposed directly by HA, so we fetch it from the diagnostics.
+                esphome_supported_audio_formats = await self._get_esphome_supported_audio_formats(
+                    entity_registry_entry["config_entry_id"]
+                )
+                extra_player_data["esphome_supported_audio_formats"] = (
+                    esphome_supported_audio_formats
+                )
 
         dev_info: dict[str, Any] = {}
-        if hass_device and (model := hass_device.get("model")):
-            dev_info["model"] = model
-        if hass_device and (manufacturer := hass_device.get("manufacturer")):
-            dev_info["manufacturer"] = manufacturer
-        if hass_device and (model_id := hass_device.get("model_id")):
-            dev_info["model_id"] = model_id
-        if hass_device and (sw_version := hass_device.get("sw_version")):
-            dev_info["software_version"] = sw_version
-        if hass_device and (connections := hass_device.get("connections")):
-            for key, value in connections:
-                if key == "mac":
-                    dev_info["mac_address"] = value
+        if hass_device:
+            extra_player_data["hass_device_id"] = hass_device["id"]
+            if model := hass_device.get("model"):
+                dev_info["model"] = model
+            if manufacturer := hass_device.get("manufacturer"):
+                dev_info["manufacturer"] = manufacturer
+            if model_id := hass_device.get("model_id"):
+                dev_info["model_id"] = model_id
+            if sw_version := hass_device.get("sw_version"):
+                dev_info["software_version"] = sw_version
+            if connections := hass_device.get("connections"):
+                for key, value in connections:
+                    if key == "mac":
+                        dev_info["mac_address"] = value
 
         player = Player(
             player_id=state["entity_id"],
@@ -412,10 +452,7 @@ class HomeAssistantPlayers(PlayerProvider):
             available=state["state"] not in UNAVAILABLE_STATES,
             device_info=DeviceInfo.from_dict(dev_info),
             state=StateMap.get(state["state"], PlayerState.IDLE),
-            extra_data={
-                "hass_domain": hass_domain,
-                "hass_device_id": hass_device["id"] if hass_device else None,
-            },
+            extra_data=extra_player_data,
         )
         # work out supported features
         hass_supported_features = MediaPlayerEntityFeature(
@@ -513,3 +550,40 @@ class HomeAssistantPlayers(PlayerProvider):
             if state["entity_id"] != entity_id:
                 continue
             await self._setup_player(state, entity_registry, device_registry)
+
+    async def _get_esphome_supported_audio_formats(
+        self, conf_entry_id: str
+    ) -> list[ESPHomeSupportedAudioFormat]:
+        """Get supported audio formats for an ESPHome device."""
+        result: list[ESPHomeSupportedAudioFormat] = []
+        try:
+            # TODO: expose this in the hass client lib instead of hacking around private vars
+            ws_url = self.hass_prov.hass._websocket_url or "ws://supervisor/core/websocket"
+            hass_url = ws_url.replace("ws://", "http://").replace("wss://", "https://")
+            hass_url = hass_url.replace("/api/websocket", "").replace("/websocket", "")
+            api_token = self.hass_prov.hass._token or os.environ.get("HASSIO_TOKEN")
+            url = f"{hass_url}/api/diagnostics/config_entry/{conf_entry_id}"
+            headers = {
+                "Authorization": f"Bearer {api_token}",
+                "content-type": "application/json",
+            }
+            async with self.mass.http_session.get(url, headers=headers) as response:
+                if response.status != 200:
+                    raise LoginFailed("Unable to contact Home Assistant to retrieve diagnostics")
+                data = await response.json()
+                if "data" not in data or "storage_data" not in data["data"]:
+                    return result
+                if "media_player" not in data["data"]["storage_data"]:
+                    raise InvalidDataError("Media player info not found in ESPHome diagnostics")
+                for media_player_obj in data["data"]["storage_data"]["media_player"]:
+                    if "supported_formats" not in media_player_obj:
+                        continue
+                    for supported_format_obj in media_player_obj["supported_formats"]:
+                        result.append(cast(ESPHomeSupportedAudioFormat, supported_format_obj))
+        except Exception as exc:
+            self.logger.warning(
+                "Failed to fetch diagnostics for ESPHome player: %s",
+                str(exc),
+                exc_info=exc if self.logger.isEnabledFor(logging.DEBUG) else None,
+            )
+        return result
index 8a06661e63a16e44b16ae0cdc807d66cd8192081..b826a2681b9b5c8d365b787c1b15274455e4e361 100644 (file)
@@ -109,7 +109,9 @@ CONF_ENTRY_GROUP_MEMBERS = ConfigEntry(
     description="Select all players you want to be part of this group",
     required=False,  # otherwise dynamic members won't work (which allows empty members list)
 )
-CONF_ENTRY_SAMPLE_RATES_UGP = create_sample_rates_config_entry(44100, 16, 44100, 16, True)
+CONF_ENTRY_SAMPLE_RATES_UGP = create_sample_rates_config_entry(
+    max_sample_rate=96000, max_bit_depth=24, hidden=True
+)
 CONFIG_ENTRY_UGP_NOTE = ConfigEntry(
     key="ugp_note",
     type=ConfigEntryType.LABEL,
index 02f800b50df6a0c528635eaf6e685f98eef27edf..02ff024b14f1769053de071ec1fb6be95c687e3b 100644 (file)
@@ -316,7 +316,9 @@ class SlimprotoProvider(PlayerProvider):
                 CONF_ENTRY_DISPLAY,
                 CONF_ENTRY_VISUALIZATION,
                 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
-                create_sample_rates_config_entry(max_sample_rate, 24, 48000, 24),
+                create_sample_rates_config_entry(
+                    max_sample_rate=max_sample_rate, max_bit_depth=24, safe_max_bit_depth=24
+                ),
             )
         )
 
index 1768f6578ba49f07bd65fd287f30cfb868e47d99..e5782ced25609aa2372607d39365770140ea7d5b 100644 (file)
@@ -76,7 +76,9 @@ CONF_HELP_LINK = (
 )
 
 # airplay has fixed sample rate/bit depth so make this config entry static and hidden
-CONF_ENTRY_SAMPLE_RATES_SNAPCAST = create_sample_rates_config_entry(48000, 16, 48000, 16, True)
+CONF_ENTRY_SAMPLE_RATES_SNAPCAST = create_sample_rates_config_entry(
+    supported_sample_rates=[48000], supported_bit_depths=[16], hidden=True
+)
 
 DEFAULT_SNAPSERVER_IP = "127.0.0.1"
 DEFAULT_SNAPSERVER_PORT = 1705
index c9e88bd3fa7e58bd0dfe51c08ef70a1d2285d655..f18bd47a6c8247972a163e4358711461fcc577a7 100644 (file)
@@ -157,7 +157,9 @@ class SonosPlayerProvider(PlayerProvider):
             CONF_ENTRY_CROSSFADE,
             CONF_ENTRY_FLOW_MODE_HIDDEN_DISABLED,
             CONF_ENTRY_ENFORCE_MP3,
-            create_sample_rates_config_entry(48000, 24, 48000, 24, True),
+            create_sample_rates_config_entry(
+                max_sample_rate=48000, max_bit_depth=24, safe_max_bit_depth=24, hidden=True
+            ),
         )
         if not (sonos_player := self.sonos_players.get(player_id)):
             # most probably the player is not yet discovered
index 23c0474b4c66e9819d916c28858bb53b7f99f9a8..1746f9bc1073c85413e4dd1747606ceb1b66d802 100644 (file)
@@ -66,7 +66,9 @@ CONF_HOUSEHOLD_ID = "household_id"
 SUBSCRIPTION_TIMEOUT = 1200
 ZGS_SUBSCRIPTION_TIMEOUT = 2
 
-CONF_ENTRY_SAMPLE_RATES = create_sample_rates_config_entry(48000, 16, 48000, 16, True)
+CONF_ENTRY_SAMPLE_RATES = create_sample_rates_config_entry(
+    max_sample_rate=48000, max_bit_depth=16, hidden=True
+)
 
 
 async def setup(