/
/
/
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