/
/
/
1"""Home Assistant Player implementation."""
2
3from __future__ import annotations
4
5import asyncio
6import time
7from typing import TYPE_CHECKING, Any, cast
8
9from hass_client.exceptions import FailedCommand
10from music_assistant_models.enums import (
11 ImageType,
12 MediaType,
13 PlaybackState,
14 PlayerFeature,
15 PlayerType,
16)
17from music_assistant_models.media_items import MediaItemImage
18
19from music_assistant.constants import (
20 CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN,
21 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
22 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
23 HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
24 create_output_codec_config_entry,
25 create_sample_rates_config_entry,
26)
27from music_assistant.helpers.datetime import from_iso_string
28from music_assistant.helpers.tags import async_parse_tags
29from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
30from music_assistant.models.player_provider import PlayerProvider
31from music_assistant.providers.hass.constants import (
32 OFF_STATES,
33 UNAVAILABLE_STATES,
34 MediaPlayerEntityFeature,
35 StateMap,
36)
37
38from .constants import CONF_ENTRY_WARN_HASS_INTEGRATION, WARN_HASS_INTEGRATIONS
39from .helpers import ESPHomeSupportedAudioFormat
40
41if TYPE_CHECKING:
42 from hass_client import HomeAssistantClient
43 from hass_client.models import CompressedState
44 from hass_client.models import Entity as HassEntity
45 from hass_client.models import State as HassState
46 from music_assistant_models.config_entries import ConfigEntry, ConfigValueType
47
48 from .provider import HomeAssistantPlayerProvider
49
50
51DEFAULT_PLAYER_CONFIG_ENTRIES = (CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,)
52
53
54class HomeAssistantPlayer(Player):
55 """Home Assistant Player implementation."""
56
57 _attr_type = PlayerType.PLAYER
58
59 def __init__(
60 self,
61 provider: PlayerProvider,
62 hass: HomeAssistantClient,
63 player_id: str,
64 hass_state: HassState,
65 dev_info: dict[str, Any],
66 extra_player_data: dict[str, Any],
67 entity_registry: dict[str, HassEntity],
68 ) -> None:
69 """Initialize the Home Assistant Player."""
70 super().__init__(provider, player_id)
71 self.hass = hass
72 self.hass_state = hass_state
73 self._extra_data = extra_player_data
74 # Set base attributes from Home Assistant state
75 self._attr_available = hass_state["state"] not in UNAVAILABLE_STATES
76 self._attr_device_info = DeviceInfo.from_dict(dev_info)
77 self._attr_playback_state = StateMap.get(hass_state["state"], PlaybackState.IDLE)
78 # Work out supported features
79 self._attr_supported_features = set()
80 hass_supported_features = MediaPlayerEntityFeature(
81 hass_state["attributes"]["supported_features"]
82 )
83 if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features:
84 self._attr_supported_features.add(PlayerFeature.VOLUME_SET)
85 if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features:
86 self._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
87 if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features:
88 self._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
89 hass_domain = extra_player_data.get("hass_domain")
90 if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features:
91 self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
92 self._attr_can_group_with = {
93 x["entity_id"]
94 for x in entity_registry.values()
95 if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain
96 }
97 if (
98 MediaPlayerEntityFeature.TURN_ON in hass_supported_features
99 and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features
100 ):
101 self._attr_supported_features.add(PlayerFeature.POWER)
102 self._attr_powered = hass_state["state"] not in OFF_STATES
103
104 self.extra_data["hass_supported_features"] = hass_supported_features
105 self._hass_attributes: dict[str, Any] = {}
106
107 # Add External source to support next/prev commands when playing external content
108 self._attr_source_list.append(
109 PlayerSource(
110 id="External",
111 name="External Source",
112 passive=True,
113 )
114 )
115 # Set dynamic features (PAUSE, NEXT_PREVIOUS, SEEK) via shared helper
116 self._update_hass_features(hass_supported_features)
117
118 self._update_attributes(hass_state["attributes"])
119
120 @property
121 def requires_flow_mode(self) -> bool:
122 """Return if the player requires flow mode."""
123 # hass media players are a hot mess so play it safe and always use flow mode
124 return True
125
126 async def get_config_entries(
127 self,
128 action: str | None = None,
129 values: dict[str, ConfigValueType] | None = None,
130 ) -> list[ConfigEntry]:
131 """Return all (provider/player specific) Config Entries for the player."""
132 base_entries = [*DEFAULT_PLAYER_CONFIG_ENTRIES]
133 if self.extra_data.get("esphome_supported_audio_formats"):
134 # optimized config for new ESPHome mediaplayer
135 supported_sample_rates: list[int] = []
136 supported_bit_depths: list[int] = []
137 codec: str | None = None
138 supported_formats: list[ESPHomeSupportedAudioFormat] = self.extra_data[
139 "esphome_supported_audio_formats"
140 ]
141 # sort on purpose field, so we prefer the media pipeline
142 # but allows fallback to announcements pipeline if no media pipeline is available
143 supported_formats.sort(key=lambda x: x["purpose"])
144 for supported_format in supported_formats:
145 codec = supported_format["format"]
146 if supported_format["sample_rate"] not in supported_sample_rates:
147 supported_sample_rates.append(supported_format["sample_rate"])
148 bit_depth = (supported_format["sample_bytes"] or 2) * 8
149 if bit_depth not in supported_bit_depths:
150 supported_bit_depths.append(bit_depth)
151 if not supported_sample_rates or not supported_bit_depths:
152 # esphome device with no media pipeline configured
153 # simply use the default config of the media pipeline
154 supported_sample_rates = [48000]
155 supported_bit_depths = [16]
156
157 config_entries = [
158 *base_entries,
159 # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits
160 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
161 ]
162
163 if codec is not None:
164 config_entries.append(create_output_codec_config_entry(True, codec))
165
166 config_entries.extend(
167 [
168 CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN,
169 create_sample_rates_config_entry(
170 supported_sample_rates=supported_sample_rates,
171 supported_bit_depths=supported_bit_depths,
172 hidden=True,
173 ),
174 # although the Voice PE supports announcements,
175 # it does not support volume for announcements
176 *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
177 ]
178 )
179
180 return config_entries
181
182 # add alert if player is a known player type that has a native provider in MA
183 if self.extra_data.get("hass_domain") in WARN_HASS_INTEGRATIONS:
184 base_entries = [CONF_ENTRY_WARN_HASS_INTEGRATION, *base_entries]
185
186 return base_entries
187
188 async def play(self) -> None:
189 """Handle PLAY command on the player."""
190 await self.hass.call_service(
191 domain="media_player",
192 service="media_play",
193 target={"entity_id": self.player_id},
194 )
195
196 async def pause(self) -> None:
197 """Handle PAUSE command on the player."""
198 await self.hass.call_service(
199 domain="media_player",
200 service="media_pause",
201 target={"entity_id": self.player_id},
202 )
203
204 async def stop(self) -> None:
205 """Send STOP command to player."""
206 try:
207 await self.hass.call_service(
208 domain="media_player",
209 service="media_stop",
210 target={"entity_id": self.player_id},
211 )
212 except FailedCommand as exc:
213 # some HA players do not support STOP
214 if "does not support" not in str(exc):
215 raise
216 if PlayerFeature.PAUSE in self.supported_features:
217 await self.pause()
218 finally:
219 self._attr_current_media = None
220 self.update_state()
221
222 async def volume_set(self, volume_level: int) -> None:
223 """Handle VOLUME_SET command on the player."""
224 await self.hass.call_service(
225 domain="media_player",
226 service="volume_set",
227 target={"entity_id": self.player_id},
228 service_data={"volume_level": volume_level / 100},
229 )
230
231 async def volume_mute(self, muted: bool) -> None:
232 """Handle VOLUME MUTE command on the player."""
233 await self.hass.call_service(
234 domain="media_player",
235 service="volume_mute",
236 target={"entity_id": self.player_id},
237 service_data={"is_volume_muted": muted},
238 )
239
240 async def power(self, powered: bool) -> None:
241 """Handle POWER command on the player."""
242 await self.hass.call_service(
243 domain="media_player",
244 service="turn_on" if powered else "turn_off",
245 target={"entity_id": self.player_id},
246 )
247
248 async def next_track(self) -> None:
249 """Handle NEXT_TRACK command on the player."""
250 await self.hass.call_service(
251 domain="media_player",
252 service="media_next_track",
253 target={"entity_id": self.player_id},
254 )
255
256 async def previous_track(self) -> None:
257 """Handle PREVIOUS_TRACK command on the player."""
258 await self.hass.call_service(
259 domain="media_player",
260 service="media_previous_track",
261 target={"entity_id": self.player_id},
262 )
263
264 async def play_media(self, media: PlayerMedia) -> None:
265 """Handle PLAY MEDIA on given player."""
266 extra_data: dict[str, Any] = {
267 # passing metadata to the player
268 # so far only supported by google cast, but maybe others can follow
269 "metadata": {
270 "title": media.title,
271 "artist": media.artist,
272 "metadataType": 3,
273 "album": media.album,
274 "albumName": media.album,
275 "images": [{"url": media.image_url}] if media.image_url else None,
276 "imageUrl": media.image_url,
277 "duration": media.duration,
278 },
279 }
280 if self.extra_data.get("hass_domain") == "esphome":
281 # tell esphome mediaproxy to bypass the proxy,
282 # as MA already delivers an optimized stream
283 extra_data["bypass_proxy"] = True
284
285 # stop the player if it is already playing
286 if self.playback_state == PlaybackState.PLAYING:
287 await self.stop()
288
289 await self.hass.call_service(
290 domain="media_player",
291 service="play_media",
292 target={"entity_id": self.player_id},
293 service_data={
294 "media_content_id": media.uri,
295 "media_content_type": "music",
296 "enqueue": "replace",
297 "extra": extra_data,
298 },
299 )
300
301 # Optimistically update state
302 self._attr_current_media = media
303 self._attr_elapsed_time = 0
304 self._attr_elapsed_time_last_updated = time.time()
305 self._attr_playback_state = PlaybackState.PLAYING
306 self.update_state()
307
308 async def play_announcement(
309 self, announcement: PlayerMedia, volume_level: int | None = None
310 ) -> None:
311 """Handle (provider native) playback of an announcement on given player."""
312 self.logger.info(
313 "Playing announcement %s on %s",
314 announcement.uri,
315 self.display_name,
316 )
317 if volume_level is not None:
318 self.logger.warning(
319 "Announcement volume level is not supported for player %s",
320 self.display_name,
321 )
322 await self.hass.call_service(
323 domain="media_player",
324 service="play_media",
325 service_data={
326 "media_content_id": announcement.uri,
327 "media_content_type": "music",
328 "announce": True,
329 },
330 target={"entity_id": self.player_id},
331 )
332 # Wait until the announcement is finished playing
333 # This is helpful for people who want to play announcements in a sequence
334 media_info = await async_parse_tags(announcement.uri, require_duration=True)
335 duration = media_info.duration or 5
336 await asyncio.sleep(duration)
337 self.logger.debug(
338 "Playing announcement on %s completed",
339 self.display_name,
340 )
341
342 async def set_members(
343 self,
344 player_ids_to_add: list[str] | None = None,
345 player_ids_to_remove: list[str] | None = None,
346 ) -> None:
347 """
348 Handle SET_MEMBERS command on the player.
349
350 Group or ungroup the given child player(s) to/from this player.
351 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
352
353 :param player_ids_to_add: List of player_id's to add to the group.
354 :param player_ids_to_remove: List of player_id's to remove from the group.
355 """
356 for player_id_to_remove in player_ids_to_remove or []:
357 await self.hass.call_service(
358 domain="media_player",
359 service="unjoin",
360 target={"entity_id": player_id_to_remove},
361 )
362 if player_ids_to_add:
363 await self.hass.call_service(
364 domain="media_player",
365 service="join",
366 service_data={"group_members": player_ids_to_add},
367 target={"entity_id": self.player_id},
368 )
369
370 def update_from_compressed_state(self, state: CompressedState) -> None:
371 """Handle updating the player with updated info in a HA CompressedState."""
372 if "s" in state:
373 self._attr_playback_state = StateMap.get(state["s"], PlaybackState.IDLE)
374 self._attr_available = state["s"] not in UNAVAILABLE_STATES
375 if PlayerFeature.POWER in self.supported_features:
376 self._attr_powered = state["s"] not in OFF_STATES
377 if "a" in state:
378 self._update_attributes(state["a"])
379 self.update_state()
380
381 def _update_hass_features(self, hass_supported_features: MediaPlayerEntityFeature) -> None:
382 """Update player and External source features based on HA supported features."""
383 # Update player supported features for PAUSE and NEXT_PREVIOUS
384 if MediaPlayerEntityFeature.PAUSE in hass_supported_features:
385 self._attr_supported_features.add(PlayerFeature.PAUSE)
386 else:
387 self._attr_supported_features.discard(PlayerFeature.PAUSE)
388
389 has_next_prev = (
390 MediaPlayerEntityFeature.NEXT_TRACK in hass_supported_features
391 or MediaPlayerEntityFeature.PREVIOUS_TRACK in hass_supported_features
392 )
393 if has_next_prev:
394 self._attr_supported_features.add(PlayerFeature.NEXT_PREVIOUS)
395 else:
396 self._attr_supported_features.discard(PlayerFeature.NEXT_PREVIOUS)
397
398 # Update the External source capabilities
399 for source in self._attr_source_list:
400 if source.id == "External":
401 source.can_play_pause = MediaPlayerEntityFeature.PAUSE in hass_supported_features
402 source.can_next_previous = has_next_prev
403 source.can_seek = MediaPlayerEntityFeature.SEEK in hass_supported_features
404 break
405
406 def _update_attributes(self, attributes: dict[str, Any]) -> None:
407 """Update Player attributes from HA state attributes."""
408 self._hass_attributes.update(attributes)
409
410 # process optional attributes - these may not be present in all states
411 for key, value in attributes.items():
412 if key == "friendly_name":
413 self._attr_name = value
414 elif key == "media_position":
415 self._attr_elapsed_time = value
416 elif key == "media_position_updated_at":
417 self._attr_elapsed_time_last_updated = from_iso_string(value).timestamp()
418 elif key == "volume_level":
419 self._attr_volume_level = int(value * 100)
420 elif key == "is_volume_muted":
421 self._attr_volume_muted = value
422 elif key == "group_members":
423 group_members: list[str] = (
424 [
425 # ignore integrations that incorrectly set the group members attribute
426 # (e.g. linkplay)
427 x
428 for x in value
429 if x.startswith("media_player.")
430 ]
431 if value
432 else []
433 )
434 if group_members and group_members[0] == self.player_id:
435 # first in the list is the group leader
436 self._attr_group_members = group_members
437 elif group_members and group_members[0] != self.player_id:
438 # this player is not the group leader
439 self._attr_group_members.clear()
440 else:
441 self._attr_group_members.clear()
442 elif key == "supported_features":
443 # Update supported features dynamically via shared helper
444 hass_supported_features = MediaPlayerEntityFeature(value)
445 self.extra_data["hass_supported_features"] = hass_supported_features
446 self._update_hass_features(hass_supported_features)
447
448 # Check for external playback (not from Music Assistant).
449 # Without media_content_id we cannot reliably determine the source,
450 # so we later only react to state updates that include it.
451 media_content_id = self._hass_attributes.get("media_content_id", "")
452 is_ma_playback = media_content_id.startswith(self.mass.streams.base_url)
453
454 media_title = self._hass_attributes.get("media_title")
455
456 if media_content_id and is_ma_playback:
457 # MA playback - ensure active_source points to player_id for queue lookup.
458 # The actual current_media will be set by MA's queue controller.
459 self._attr_active_source = self.player_id
460 elif (
461 media_content_id
462 and media_title
463 and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
464 ):
465 # External playback detected - set current_media from HA attributes
466 ha_content_type = self._hass_attributes.get("media_content_type", "")
467 media_type = MediaType.RADIO if ha_content_type == "radio" else MediaType.UNKNOWN
468 current_media = PlayerMedia(
469 uri=media_content_id,
470 media_type=media_type,
471 title=media_title,
472 artist=self._hass_attributes.get("media_artist"),
473 album=self._hass_attributes.get("media_album_name"),
474 image_url=self._get_image_url(self._hass_attributes),
475 duration=int(self._hass_attributes.get("media_duration", 0) or 0) or None,
476 )
477 self._attr_current_media = current_media
478 self._attr_active_source = "External"
479
480 elif self.playback_state == PlaybackState.IDLE:
481 # Clear external media if it was set
482 if self._attr_active_source and self._attr_active_source not in (
483 self.player_id,
484 None,
485 ):
486 self._attr_current_media = None
487 self._attr_active_source = None
488
489 def _get_image_url(self, attributes: dict[str, Any]) -> str | None:
490 """Get the image URL from the attributes."""
491 if entity_picture := attributes.get("entity_picture"):
492 entity_picture = str(entity_picture)
493 if entity_picture.startswith("http"):
494 return entity_picture
495
496 # Access via provider -> hass_prov
497 prov = cast("HomeAssistantPlayerProvider", self.provider)
498
499 # Use proxy for internal HA images
500 # We create a MediaItemImage with the hass provider as source
501 # This will trigger resolve_image on the hass provider when requested
502 image = MediaItemImage(
503 type=ImageType.THUMB,
504 path=entity_picture,
505 provider=prov.hass_prov.instance_id,
506 remotely_accessible=False,
507 )
508 return self.mass.metadata.get_image_url(image)
509 return None
510