Fix: announcements on HA players
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 21 Nov 2024 15:16:25 +0000 (16:16 +0100)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Thu, 21 Nov 2024 15:16:25 +0000 (16:16 +0100)
music_assistant/controllers/players.py
music_assistant/providers/hass_players/__init__.py

index 9e6edfe66576a9f1acbbf55605e792a2ae76d376..669077a5ac823ca0fab11ef7da7f95746ebaf76c 100644 (file)
@@ -46,7 +46,7 @@ from music_assistant.constants import (
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.tags import parse_tags
 from music_assistant.helpers.throttle_retry import Throttler
-from music_assistant.helpers.util import TaskManager, get_changed_values
+from music_assistant.helpers.util import TaskManager, get_changed_values, lock
 from music_assistant.models.core_controller import CoreController
 from music_assistant.models.player_provider import PlayerProvider
 
@@ -214,9 +214,6 @@ class PlayerController(CoreController):
         - player_id: player_id of the player to handle the command.
         """
         player = self._get_player_with_redirect(player_id)
-        if player.announcement_in_progress:
-            self.logger.warning("Ignore command: An announcement is in progress")
-            return
         if PlayerFeature.PAUSE not in player.supported_features:
             # if player does not support pause, we need to send stop
             self.logger.info(
@@ -526,6 +523,7 @@ class PlayerController(CoreController):
             await player_provider.cmd_volume_mute(player_id, muted)
 
     @api_command("players/cmd/play_announcement")
+    @lock
     async def play_announcement(
         self,
         player_id: str,
@@ -537,10 +535,6 @@ class PlayerController(CoreController):
         player = self.get(player_id, True)
         if not url.startswith("http"):
             raise PlayerCommandFailed("Only URLs are supported for announcements")
-        if player.announcement_in_progress:
-            raise PlayerCommandFailed(
-                f"An announcement is already in progress to player {player.display_name}"
-            )
         try:
             # mark announcement_in_progress on player
             player.announcement_in_progress = True
@@ -589,7 +583,8 @@ class PlayerController(CoreController):
             # handle native announce support
             if native_announce_support:
                 if prov := self.mass.get_provider(player.provider):
-                    await prov.play_announcement(player_id, announcement, volume_level)
+                    announcement_volume = self.get_announcement_volume(player_id, volume_level)
+                    await prov.play_announcement(player_id, announcement, announcement_volume)
                     return
             # use fallback/default implementation
             await self._play_announcement(player, announcement, volume_level)
index 7771a7e413b2d8dd55be9c1f73857451168daafc..01d4c8203fa57340f5071bd69cc6f5e80e6f2f07 100644 (file)
@@ -7,6 +7,7 @@ Requires the Home Assistant Plugin.
 
 from __future__ import annotations
 
+import asyncio
 import time
 from enum import IntFlag
 from typing import TYPE_CHECKING, Any
@@ -26,6 +27,7 @@ from music_assistant.constants import (
     CONF_ENTRY_HTTP_PROFILE,
 )
 from music_assistant.helpers.datetime import from_iso_string
+from music_assistant.helpers.tags import parse_tags
 from music_assistant.models.player_provider import PlayerProvider
 from music_assistant.providers.hass import DOMAIN as HASS_DOMAIN
 
@@ -93,6 +95,7 @@ PLAYER_CONFIG_ENTRIES = (
     CONF_ENTRY_ENFORCE_MP3_DEFAULT_ENABLED,
     CONF_ENTRY_HTTP_PROFILE,
     CONF_ENTRY_ENABLE_ICY_METADATA,
+    CONF_ENTRY_FLOW_MODE_ENFORCED,
 )
 
 
@@ -103,7 +106,7 @@ async def _get_hass_media_players(
     for state in await hass_prov.hass.get_states():
         if not state["entity_id"].startswith("media_player"):
             continue
-        if "mass_player_id" in state["attributes"]:
+        if "mass_player_type" in state["attributes"]:
             # filter out mass players
             continue
         if "friendly_name" not in state["attributes"]:
@@ -115,16 +118,6 @@ async def _get_hass_media_players(
         yield state
 
 
-async def _get_hass_media_player(
-    hass_prov: HomeAssistantProvider, entity_id: str
-) -> HassState | None:
-    """Return Hass state object for a single media_player entity."""
-    for state in await hass_prov.hass.get_states():
-        if state["entity_id"] == entity_id:
-            return state
-    return None
-
-
 async def setup(
     mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig
 ) -> ProviderInstanceType:
@@ -202,16 +195,8 @@ class HomeAssistantPlayers(PlayerProvider):
         player_id: str,
     ) -> tuple[ConfigEntry, ...]:
         """Return all (provider/player specific) Config Entries for the given player (if any)."""
-        entries = await super().get_player_config_entries(player_id)
-        entries = entries + PLAYER_CONFIG_ENTRIES
-        if hass_state := await _get_hass_media_player(self.hass_prov, player_id):
-            hass_supported_features = MediaPlayerEntityFeature(
-                hass_state["attributes"]["supported_features"]
-            )
-            if MediaPlayerEntityFeature.MEDIA_ENQUEUE not in hass_supported_features:
-                entries += (CONF_ENTRY_FLOW_MODE_ENFORCED,)
-
-        return entries
+        base_entries = await super().get_player_config_entries(player_id)
+        return base_entries + PLAYER_CONFIG_ENTRIES
 
     async def cmd_stop(self, player_id: str) -> None:
         """Send STOP command to given player.
@@ -281,20 +266,39 @@ class HomeAssistantPlayers(PlayerProvider):
             player.elapsed_time = 0
             player.elapsed_time_last_updated = time.time()
 
-    async def enqueue_next_media(self, player_id: str, media: PlayerMedia) -> None:
-        """Handle enqueuing of the next queue item on the player."""
-        if self.mass.config.get_raw_player_config_value(player_id, CONF_ENFORCE_MP3, True):
-            media.uri = media.uri.replace(".flac", ".mp3")
+    async def play_announcement(
+        self, player_id: str, announcement: PlayerMedia, volume_level: int | None = None
+    ) -> None:
+        """Handle (provider native) playback of an announcement on given player."""
+        player = self.mass.players.get(player_id, True)
+        self.logger.info(
+            "Playing announcement %s on %s",
+            announcement.uri,
+            player.display_name,
+        )
+        if volume_level is not None:
+            self.logger.warning(
+                "Announcement volume level is not supported for player %s", player.display_name
+            )
         await self.hass_prov.hass.call_service(
             domain="media_player",
             service="play_media",
             service_data={
-                "media_content_id": media.uri,
+                "media_content_id": announcement.uri,
                 "media_content_type": "music",
-                "enqueue": "next",
+                "announce": True,
             },
             target={"entity_id": player_id},
         )
+        # Wait until the announcement is finished playing
+        # This is helpful for people who want to play announcements in a sequence
+        media_info = await parse_tags(announcement.uri)
+        duration = media_info.duration or 5
+        await asyncio.sleep(duration)
+        self.logger.debug(
+            "Playing announcement on %s completed",
+            player.display_name,
+        )
 
     async def cmd_power(self, player_id: str, powered: bool) -> None:
         """Send POWER command to given player.
@@ -372,41 +376,60 @@ class HomeAssistantPlayers(PlayerProvider):
     ) -> None:
         """Handle setup of a Player from an hass entity."""
         hass_device: HassDevice | None = None
+        hass_domain: str | None = None
         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"]
+
+        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
+
+        player = Player(
+            player_id=state["entity_id"],
+            provider=self.instance_id,
+            type=PlayerType.PLAYER,
+            name=state["attributes"]["friendly_name"],
+            available=state["state"] not in ("unavailable", "unknown"),
+            powered=state["state"] not in ("unavailable", "unknown", "standby", "off"),
+            device_info=DeviceInfo.from_dict(dev_info),
+            state=StateMap.get(state["state"], PlayerState.IDLE),
+        )
+        # work out supported features
         hass_supported_features = MediaPlayerEntityFeature(
             state["attributes"]["supported_features"]
         )
-        supported_features: set[PlayerFeature] = set()
         if MediaPlayerEntityFeature.PAUSE in hass_supported_features:
-            supported_features.add(PlayerFeature.PAUSE)
+            player.supported_features.add(PlayerFeature.PAUSE)
         if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features:
-            supported_features.add(PlayerFeature.VOLUME_SET)
+            player.supported_features.add(PlayerFeature.VOLUME_SET)
         if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features:
-            supported_features.add(PlayerFeature.VOLUME_MUTE)
-        if MediaPlayerEntityFeature.MEDIA_ENQUEUE in hass_supported_features:
-            supported_features.add(PlayerFeature.ENQUEUE)
+            player.supported_features.add(PlayerFeature.VOLUME_MUTE)
+        if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features:
+            player.supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
+        if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features:
+            player.supported_features.add(PlayerFeature.SET_MEMBERS)
+            player.can_group_with = {
+                x["entity_id"]
+                for x in entity_registry.values()
+                if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain
+            }
         if (
             MediaPlayerEntityFeature.TURN_ON in hass_supported_features
             and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features
         ):
-            supported_features.add(PlayerFeature.POWER)
-        player = Player(
-            player_id=state["entity_id"],
-            provider=self.instance_id,
-            type=PlayerType.PLAYER,
-            name=state["attributes"]["friendly_name"],
-            available=state["state"] not in ("unavailable", "unknown"),
-            powered=state["state"] not in ("unavailable", "unknown", "standby", "off"),
-            device_info=DeviceInfo(
-                model=hass_device["model"] if hass_device else "Unknown model",
-                manufacturer=(
-                    hass_device["manufacturer"] if hass_device else "Unknown Manufacturer"
-                ),
-            ),
-            supported_features=supported_features,
-            state=StateMap.get(state["state"], PlayerState.IDLE),
-        )
+            player.supported_features.add(PlayerFeature.POWER)
+
         self._update_player_attributes(player, state["attributes"])
         await self.mass.players.register_or_update(player)