music-assistant-server

8.5 KBPY
provider.py
8.5 KB190 lines • python
1"""
2Home Assistant PlayerProvider for Music Assistant.
3
4Allows using media_player entities in HA to be used as players in MA.
5Requires the Home Assistant Plugin.
6"""
7
8from __future__ import annotations
9
10from collections.abc import Callable
11from typing import TYPE_CHECKING, Any, cast
12
13from music_assistant_models.enums import ProviderFeature
14
15from music_assistant.mass import MusicAssistant
16from music_assistant.models.player_provider import PlayerProvider
17
18from .constants import CONF_PLAYERS
19from .helpers import get_esphome_supported_audio_formats, get_hass_media_players
20from .player import HomeAssistantPlayer
21
22if TYPE_CHECKING:
23    from hass_client.models import CompressedState, EntityStateEvent
24    from hass_client.models import Device as HassDevice
25    from hass_client.models import Entity as HassEntity
26    from hass_client.models import State as HassState
27    from music_assistant_models.config_entries import ProviderConfig
28    from music_assistant_models.provider import ProviderManifest
29
30    from music_assistant.providers.hass import HomeAssistantProvider
31
32
33class HomeAssistantPlayerProvider(PlayerProvider):
34    """Home Assistant PlayerProvider for Music Assistant."""
35
36    hass_prov: HomeAssistantProvider
37    on_unload_callbacks: list[Callable[[], None]] | None = None
38
39    def __init__(
40        self,
41        mass: MusicAssistant,
42        manifest: ProviderManifest,
43        config: ProviderConfig,
44        hass_prov: HomeAssistantProvider,
45    ) -> None:
46        """Initialize MusicProvider."""
47        supported_features = {ProviderFeature.REMOVE_PLAYER}
48        super().__init__(mass, manifest, config, supported_features=supported_features)
49        self.hass_prov = hass_prov
50
51    async def loaded_in_mass(self) -> None:
52        """Call after the provider has been loaded."""
53        await super().loaded_in_mass()
54        player_ids = cast("list[str]", self.config.get_value(CONF_PLAYERS))
55        # prefetch the device- and entity registry
56        device_registry = {x["id"]: x for x in await self.hass_prov.hass.get_device_registry()}
57        entity_registry = {
58            x["entity_id"]: x for x in await self.hass_prov.hass.get_entity_registry()
59        }
60        # setup players from hass entities
61        async for state in get_hass_media_players(self.hass_prov):
62            if state["entity_id"] not in player_ids:
63                continue
64            await self._setup_player(state, entity_registry, device_registry)
65        # register for entity state updates
66        self.on_unload_callbacks = [
67            await self.hass_prov.hass.subscribe_entities(self._on_entity_state_update, player_ids)
68        ]
69        # cleanup any players that are no longer in the config
70        for player_conf in await self.mass.config.get_player_configs(
71            provider=self.instance_id, include_unavailable=True, include_disabled=True
72        ):
73            if player_conf.player_id not in player_ids:
74                await self.mass.players.remove(player_conf.player_id)
75
76    async def unload(self, is_removed: bool = False) -> None:
77        """
78        Handle unload/close of the provider.
79
80        Called when provider is deregistered (e.g. MA exiting or config reloading).
81        is_removed will be set to True when the provider is removed from the configuration.
82        """
83        if self.on_unload_callbacks:
84            for callback in self.on_unload_callbacks:
85                callback()
86
87    async def remove_player(self, player_id: str) -> None:
88        """Remove a player."""
89        player_ids = cast("list[str]", self.config.get_value(CONF_PLAYERS))
90        if player_id in player_ids:
91            player_ids.remove(player_id)
92            self.mass.config.set_raw_provider_config_value(
93                self.instance_id, CONF_PLAYERS, player_ids
94            )
95        await self.mass.players.unregister(player_id, True)
96
97    async def _setup_player(
98        self,
99        state: HassState,
100        entity_registry: dict[str, HassEntity],
101        device_registry: dict[str, HassDevice],
102    ) -> None:
103        """Handle setup of a Player from an hass entity."""
104        hass_device: HassDevice | None = None
105        hass_domain: str | None = None
106        # collect extra player data
107        extra_player_data: dict[str, Any] = {}
108        if entity_registry_entry := entity_registry.get(state["entity_id"]):
109            hass_device = device_registry.get(entity_registry_entry["device_id"])
110            hass_domain = entity_registry_entry["platform"]
111            extra_player_data["entity_registry_id"] = entity_registry_entry["id"]
112            extra_player_data["hass_domain"] = hass_domain
113            extra_player_data["hass_device_id"] = hass_device["id"] if hass_device else None
114            if hass_domain == "esphome":
115                # if the player is an ESPHome player, we need to check if it is a V2 player
116                # as the V2 player has different capabilities and needs different config entries
117                # The new media player component publishes its supported sample rates but that info
118                # is not exposed directly by HA, so we fetch it from the diagnostics.
119                esphome_supported_audio_formats = await get_esphome_supported_audio_formats(
120                    self.hass_prov, entity_registry_entry["config_entry_id"]
121                )
122                extra_player_data["esphome_supported_audio_formats"] = (
123                    esphome_supported_audio_formats
124                )
125        # collect device info
126        dev_info: dict[str, Any] = {}
127        if hass_device:
128            extra_player_data["hass_device_id"] = hass_device["id"]
129            if model := hass_device.get("model"):
130                dev_info["model"] = model
131            if manufacturer := hass_device.get("manufacturer"):
132                dev_info["manufacturer"] = manufacturer
133            if model_id := hass_device.get("model_id"):
134                dev_info["model_id"] = model_id
135            if sw_version := hass_device.get("sw_version"):
136                dev_info["software_version"] = sw_version
137            if connections := hass_device.get("connections"):
138                for key, value in connections:
139                    if key == "mac":
140                        dev_info["mac_address"] = value
141
142        # create the player
143        player = HomeAssistantPlayer(
144            provider=self,
145            hass=self.hass_prov.hass,
146            player_id=state["entity_id"],
147            hass_state=state,
148            dev_info=dev_info,
149            extra_player_data=extra_player_data,
150            entity_registry=entity_registry,
151        )
152        await self.mass.players.register(player)
153
154    def _on_entity_state_update(self, event: EntityStateEvent) -> None:
155        """Handle Entity State event."""
156
157        def update_player_from_state_msg(entity_id: str, state: CompressedState) -> None:
158            """Handle updating MA player with updated info in a HA CompressedState."""
159            player = cast("HomeAssistantPlayer | None", self.mass.players.get_player(entity_id))
160            if player is None:
161                # edge case - one of our subscribed entities was not available at startup
162                # and now came available - we should still set it up
163                player_ids = cast("list[str]", self.config.get_value(CONF_PLAYERS))
164                if entity_id not in player_ids:
165                    return  # should not happen, but guard just in case
166                self.mass.create_task(self._late_add_player(entity_id))
167                return
168            player.update_from_compressed_state(state)
169
170        if entity_additions := event.get("a"):
171            for entity_id, state in entity_additions.items():
172                update_player_from_state_msg(entity_id, state)
173        if entity_changes := event.get("c"):
174            for entity_id, state_diff in entity_changes.items():
175                if "+" not in state_diff:
176                    continue
177                update_player_from_state_msg(entity_id, state_diff["+"])
178
179    async def _late_add_player(self, entity_id: str) -> None:
180        """Handle setup of Player from HA entity that became available after startup."""
181        # prefetch the device- and entity registry
182        device_registry = {x["id"]: x for x in await self.hass_prov.hass.get_device_registry()}
183        entity_registry = {
184            x["entity_id"]: x for x in await self.hass_prov.hass.get_entity_registry()
185        }
186        async for state in get_hass_media_players(self.hass_prov):
187            if state["entity_id"] != entity_id:
188                continue
189            await self._setup_player(state, entity_registry, device_registry)
190