/
/
/
1"""Home Assistant Player implementation."""
2
3from __future__ import annotations
4
5import time
6from typing import TYPE_CHECKING, Any, cast
7
8from hass_client.exceptions import FailedCommand
9from music_assistant_models.enums import (
10 IdentifierType,
11 ImageType,
12 MediaType,
13 PlaybackState,
14 PlayerFeature,
15)
16from music_assistant_models.media_items import MediaItemImage
17
18from music_assistant.constants import (
19 ATTR_ANNOUNCEMENT_IN_PROGRESS,
20 CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN,
21 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
22 CONF_ENTRY_OUTPUT_CODEC_DEFAULT_MP3,
23 EXTERNAL_PAUSE_IDLE_TIMEOUT,
24 HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
25 create_output_codec_config_entry,
26)
27from music_assistant.helpers.datetime import from_iso_string
28from music_assistant.models.player import DeviceInfo, Player, PlayerMedia, PlayerSource
29from music_assistant.models.player_provider import PlayerProvider
30from music_assistant.providers.hass.constants import (
31 OFF_STATES,
32 UNAVAILABLE_STATES,
33 MediaPlayerEntityFeature,
34 StateMap,
35 parse_supported_features,
36)
37
38from .constants import CONF_ENTRY_WARN_HASS_INTEGRATION, NATIVE_SUPPORTED_HASS_INTEGRATIONS
39from .helpers import ESPHomeSupportedAudioFormat, native_player_macs, normalized_mac
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
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 # the wrapped entity keeps reporting an abandoned external session as paused, and
58 # Home Assistant pushes no event when it goes stale.
59 _attr_external_pause_idle_timeout = EXTERNAL_PAUSE_IDLE_TIMEOUT
60
61 def __init__(
62 self,
63 provider: PlayerProvider,
64 hass: HomeAssistantClient,
65 player_id: str,
66 hass_state: HassState,
67 dev_info: dict[str, Any],
68 extra_player_data: dict[str, Any],
69 entity_registry: dict[str, HassEntity],
70 ) -> None:
71 """Initialize the Home Assistant Player."""
72 super().__init__(provider, player_id)
73 self.hass = hass
74 self.hass_state = hass_state
75 self._extra_data = extra_player_data
76 # Set base attributes from Home Assistant state
77 self._attr_available = hass_state["state"] not in UNAVAILABLE_STATES
78 self._attr_device_info = DeviceInfo(
79 model=dev_info.get("model", ""),
80 manufacturer=dev_info.get("manufacturer", ""),
81 software_version=dev_info.get("software_version"),
82 )
83 if mac_address := dev_info.get("mac_address"):
84 self._attr_device_info.add_identifier(IdentifierType.MAC_ADDRESS, mac_address)
85 self._attr_playback_state = StateMap.get(hass_state["state"], PlaybackState.IDLE)
86 # Work out supported features
87 self._attr_supported_features = {PlayerFeature.PLAY_MEDIA}
88 hass_supported_features = parse_supported_features(
89 hass_state["attributes"].get("supported_features"), player_id, self.logger
90 )
91 if MediaPlayerEntityFeature.VOLUME_SET in hass_supported_features:
92 self._attr_supported_features.add(PlayerFeature.VOLUME_SET)
93 if MediaPlayerEntityFeature.VOLUME_MUTE in hass_supported_features:
94 self._attr_supported_features.add(PlayerFeature.VOLUME_MUTE)
95 if MediaPlayerEntityFeature.MEDIA_ANNOUNCE in hass_supported_features:
96 self._attr_supported_features.add(PlayerFeature.PLAY_ANNOUNCEMENT)
97 hass_domain = extra_player_data.get("hass_domain")
98 if hass_domain and MediaPlayerEntityFeature.GROUPING in hass_supported_features:
99 self._attr_supported_features.add(PlayerFeature.SET_MEMBERS)
100 self._attr_can_group_with = {
101 x["entity_id"]
102 for x in entity_registry.values()
103 if x["entity_id"].startswith("media_player") and x["platform"] == hass_domain
104 }
105 if (
106 MediaPlayerEntityFeature.TURN_ON in hass_supported_features
107 and MediaPlayerEntityFeature.TURN_OFF in hass_supported_features
108 ):
109 self._attr_supported_features.add(PlayerFeature.POWER)
110 self._attr_powered = hass_state["state"] not in OFF_STATES
111
112 self.extra_data["hass_supported_features"] = hass_supported_features
113 self._hass_attributes: dict[str, Any] = {}
114 self._ma_playback_active = False
115 self._ma_playback_started = False
116 self._reports_stream_url = False
117
118 # Add External source to support next/prev commands when playing external content
119 self._attr_source_list.append(
120 PlayerSource(
121 id="External",
122 name="External Source",
123 passive=True,
124 )
125 )
126 # Set dynamic features (PAUSE, NEXT_PREVIOUS, SEEK) via shared helper
127 self._update_hass_features(hass_supported_features)
128
129 # Derive supported sample rates from ESPHome's reported formats when available
130 esphome_formats: list[ESPHomeSupportedAudioFormat] | None = self.extra_data.get(
131 "esphome_supported_audio_formats"
132 )
133 if esphome_formats:
134 rates = sorted(
135 {(fmt["sample_rate"], (fmt["sample_bytes"] or 2) * 8) for fmt in esphome_formats}
136 )
137 self._attr_supported_sample_rates = rates or [(48000, 16)]
138
139 self._update_attributes(hass_state["attributes"])
140
141 @property
142 def requires_flow_mode(self) -> bool:
143 """Return if the player requires flow mode."""
144 # hass media players are a hot mess so play it safe and always use flow mode
145 return True
146
147 async def get_config_entries(self) -> list[ConfigEntry]:
148 """Return all (provider/player specific) Config Entries for the player."""
149 base_entries = [*DEFAULT_PLAYER_CONFIG_ENTRIES]
150 # add alert if the player (type) is also supported by a native MA provider
151 if (
152 self.extra_data.get("hass_domain") in NATIVE_SUPPORTED_HASS_INTEGRATIONS
153 or self._has_native_duplicate()
154 ):
155 base_entries = [CONF_ENTRY_WARN_HASS_INTEGRATION, *base_entries]
156 supported_formats: list[ESPHomeSupportedAudioFormat] | None = self.extra_data.get(
157 "esphome_supported_audio_formats"
158 )
159 if supported_formats:
160 # optimized config for new ESPHome mediaplayer
161 # sort on purpose field, so we prefer the media pipeline
162 # but allows fallback to announcements pipeline if no media pipeline is available
163 supported_formats.sort(key=lambda x: x["purpose"])
164 codec = supported_formats[0]["format"] if supported_formats else None
165
166 config_entries = [
167 *base_entries,
168 # New ESPHome mediaplayer (used in Voice PE) uses FLAC 48khz/16 bits
169 CONF_ENTRY_HTTP_PROFILE_FORCED_2,
170 ]
171
172 if codec is not None:
173 config_entries.append(create_output_codec_config_entry(True, codec))
174
175 config_entries.extend(
176 [
177 CONF_ENTRY_ENABLE_ICY_METADATA_HIDDEN,
178 # although the Voice PE supports announcements,
179 # it does not support volume for announcements
180 *HIDDEN_ANNOUNCE_VOLUME_CONFIG_ENTRIES,
181 ]
182 )
183
184 return config_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._ma_playback_active = False
220 self._ma_playback_started = False
221 self._attr_current_media = None
222 self.update_state()
223
224 async def volume_set(self, volume_level: int) -> None:
225 """Handle VOLUME_SET command on the player."""
226 await self.hass.call_service(
227 domain="media_player",
228 service="volume_set",
229 target={"entity_id": self.player_id},
230 service_data={"volume_level": volume_level / 100},
231 )
232
233 async def volume_mute(self, muted: bool) -> None:
234 """Handle VOLUME MUTE command on the player."""
235 await self.hass.call_service(
236 domain="media_player",
237 service="volume_mute",
238 target={"entity_id": self.player_id},
239 service_data={"is_volume_muted": muted},
240 )
241
242 async def power(self, powered: bool) -> None:
243 """Handle POWER command on the player."""
244 await self.hass.call_service(
245 domain="media_player",
246 service="turn_on" if powered else "turn_off",
247 target={"entity_id": self.player_id},
248 )
249
250 async def next_track(self) -> None:
251 """Handle NEXT_TRACK command on the player."""
252 await self.hass.call_service(
253 domain="media_player",
254 service="media_next_track",
255 target={"entity_id": self.player_id},
256 )
257
258 async def previous_track(self) -> None:
259 """Handle PREVIOUS_TRACK command on the player."""
260 await self.hass.call_service(
261 domain="media_player",
262 service="media_previous_track",
263 target={"entity_id": self.player_id},
264 )
265
266 async def play_media(self, media: PlayerMedia) -> None:
267 """Handle PLAY MEDIA on given player."""
268 url = await self.provider.mass.streams.resolve_stream_url(self.player_id, media)
269 extra_data: dict[str, Any] = {
270 # passing metadata to the player
271 # so far only supported by google cast, but maybe others can follow
272 "metadata": {
273 "title": media.title,
274 "artist": media.artist,
275 "metadataType": 3,
276 "album": media.album,
277 "albumName": media.album,
278 "images": [{"url": media.image_url}] if media.image_url else None,
279 "imageUrl": media.image_url,
280 "duration": media.stream_duration or media.duration,
281 },
282 }
283 if self.extra_data.get("hass_domain") == "esphome":
284 # tell esphome mediaproxy to bypass the proxy,
285 # as MA already delivers an optimized stream
286 extra_data["bypass_proxy"] = True
287
288 # stop the player if it is already playing
289 if self._attr_playback_state == PlaybackState.PLAYING:
290 await self.stop()
291
292 await self.hass.call_service(
293 domain="media_player",
294 service="play_media",
295 target={"entity_id": self.player_id},
296 service_data={
297 "media_content_id": url,
298 "media_content_type": "music",
299 "enqueue": "replace",
300 "extra": extra_data,
301 },
302 )
303
304 # Optimistically update state
305 self._ma_playback_active = True
306 # the entity may still be reporting the previous session, so our stream only
307 # counts as started once it is seen playing
308 self._ma_playback_started = False
309 # a source the entity played before is over, and it may never report an
310 # attribute change to tell us so
311 self._attr_active_source = None
312 self._attr_current_media = media
313 self._attr_elapsed_time = 0
314 self._attr_elapsed_time_last_updated = time.time()
315 self._attr_playback_state = PlaybackState.PLAYING
316 self.update_state()
317
318 async def play_announcement(
319 self, announcement: PlayerMedia, volume_level: int | None = None
320 ) -> None:
321 """Handle (provider native) playback of an announcement on given player."""
322 self.logger.info(
323 "Playing announcement %s on %s",
324 announcement.uri,
325 self.display_name,
326 )
327 if volume_level is not None:
328 self.logger.warning(
329 "Announcement volume level is not supported for player %s",
330 self.display_name,
331 )
332 hass_prov = cast("HomeAssistantPlayerProvider", self.provider).hass_prov
333 await hass_prov.play_announcement_on_entity(self.player_id, announcement)
334 self.logger.debug(
335 "Playing announcement on %s completed",
336 self.display_name,
337 )
338
339 async def set_members(
340 self,
341 player_ids_to_add: list[str] | None = None,
342 player_ids_to_remove: list[str] | None = None,
343 ) -> None:
344 """
345 Handle SET_MEMBERS command on the player.
346
347 Group or ungroup the given child player(s) to/from this player.
348 Will only be called if the PlayerFeature.SET_MEMBERS is supported.
349
350 :param player_ids_to_add: List of player_id's to add to the group.
351 :param player_ids_to_remove: List of player_id's to remove from the group.
352 """
353 for player_id_to_remove in player_ids_to_remove or []:
354 await self.hass.call_service(
355 domain="media_player",
356 service="unjoin",
357 target={"entity_id": player_id_to_remove},
358 )
359 if player_ids_to_add:
360 await self.hass.call_service(
361 domain="media_player",
362 service="join",
363 service_data={"group_members": player_ids_to_add},
364 target={"entity_id": self.player_id},
365 )
366
367 def update_from_compressed_state(self, state: CompressedState) -> None:
368 """Handle updating the player with updated info in a HA CompressedState."""
369 if "s" in state:
370 self._attr_playback_state = StateMap.get(state["s"], PlaybackState.IDLE)
371 self._attr_available = state["s"] not in UNAVAILABLE_STATES
372 if PlayerFeature.POWER in self.supported_features:
373 self._attr_powered = state["s"] not in OFF_STATES
374 self._track_ma_playback(state["s"])
375 if "a" in state:
376 self._update_attributes(state["a"])
377 self.update_state()
378
379 def _update_hass_features(self, hass_supported_features: MediaPlayerEntityFeature) -> None:
380 """Update player and External source features based on HA supported features."""
381 # Update player supported features for PAUSE and NEXT_PREVIOUS
382 if MediaPlayerEntityFeature.PAUSE in hass_supported_features:
383 self._attr_supported_features.add(PlayerFeature.PAUSE)
384 else:
385 self._attr_supported_features.discard(PlayerFeature.PAUSE)
386
387 has_next_prev = (
388 MediaPlayerEntityFeature.NEXT_TRACK in hass_supported_features
389 or MediaPlayerEntityFeature.PREVIOUS_TRACK in hass_supported_features
390 )
391 if has_next_prev:
392 self._attr_supported_features.add(PlayerFeature.NEXT_PREVIOUS)
393 else:
394 self._attr_supported_features.discard(PlayerFeature.NEXT_PREVIOUS)
395
396 # Update the External source capabilities
397 for source in self._attr_source_list:
398 if source.id == "External":
399 source.can_play_pause = MediaPlayerEntityFeature.PAUSE in hass_supported_features
400 source.can_next_previous = has_next_prev
401 source.can_seek = MediaPlayerEntityFeature.SEEK in hass_supported_features
402 break
403
404 def _update_attributes(self, attributes: dict[str, Any]) -> None:
405 """Update Player attributes from HA state attributes."""
406 self._hass_attributes.update(attributes)
407
408 # process optional attributes - these may not be present in all states
409 for key, value in attributes.items():
410 if key == "friendly_name":
411 self._attr_name = value
412 elif key == "media_position":
413 self._attr_elapsed_time = value
414 elif key == "media_position_updated_at":
415 self._attr_elapsed_time_last_updated = from_iso_string(value).timestamp()
416 elif key == "volume_level":
417 self._attr_volume_level = int(value * 100)
418 elif key == "is_volume_muted":
419 self._attr_volume_muted = value
420 elif key == "group_members":
421 group_members: list[str] = (
422 [
423 # ignore integrations that incorrectly set the group members attribute
424 # (e.g. linkplay)
425 x
426 for x in value
427 if x.startswith("media_player.")
428 ]
429 if value
430 else []
431 )
432 if group_members and group_members[0] == self.player_id:
433 # first in the list is the group leader
434 self._attr_group_members = group_members
435 elif group_members and group_members[0] != self.player_id:
436 # this player is not the group leader
437 self._attr_group_members.clear()
438 else:
439 self._attr_group_members.clear()
440 elif key == "supported_features":
441 # Update supported features dynamically via shared helper
442 hass_supported_features = parse_supported_features(
443 value, self.player_id, self.logger
444 )
445 self.extra_data["hass_supported_features"] = hass_supported_features
446 self._update_hass_features(hass_supported_features)
447
448 if self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
449 # the media attributes describe the announcement instead of the source that
450 # is restored afterwards, so they tell us nothing about who owns playback.
451 # Drop the announcement's id so a later partial update can not judge by it.
452 self._hass_attributes.pop("media_content_id", None)
453 return
454
455 # Check for external playback (not from Music Assistant).
456 # Not every integration echoes the stream URL we handed it back in
457 # media_content_id; some report device or cloud provided metadata instead. Only
458 # entities that were seen echoing it can be judged by it - for the others the
459 # play command we issued is what tells the two sources apart. Without either
460 # signal the source stays as it was.
461 media_content_id = self._hass_attributes.get("media_content_id", "")
462 if media_content_id.startswith(self.mass.streams.base_url):
463 self._reports_stream_url = True
464 is_ma_playback = True
465 else:
466 is_ma_playback = not self._reports_stream_url and self._ma_playback_active
467 media_title = self._hass_attributes.get("media_title")
468
469 if is_ma_playback:
470 # MA playback - the queue controller resolves the active source and
471 # provides the actual current_media.
472 self._attr_active_source = None
473 elif (
474 media_content_id
475 and media_title
476 and self.playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED)
477 ):
478 # External playback detected - set current_media from HA attributes
479 ha_content_type = self._hass_attributes.get("media_content_type", "")
480 media_type = MediaType.RADIO if ha_content_type == "radio" else MediaType.UNKNOWN
481 current_media = PlayerMedia(
482 uri=media_content_id,
483 media_type=media_type,
484 title=media_title,
485 artist=self._hass_attributes.get("media_artist"),
486 album=self._hass_attributes.get("media_album_name"),
487 image_url=self._get_image_url(self._hass_attributes),
488 duration=int(self._hass_attributes.get("media_duration", 0) or 0) or None,
489 )
490 self._attr_current_media = current_media
491 self._attr_active_source = "External"
492
493 elif self.playback_state == PlaybackState.IDLE:
494 # Clear external media if it was set
495 if self._attr_active_source and self._attr_active_source not in (
496 self.player_id,
497 None,
498 ):
499 self._attr_current_media = None
500 self._attr_active_source = None
501
502 def _track_ma_playback(self, hass_state: str) -> None:
503 """
504 Follow the entity's state to tell whether the stream MA handed it is still playing.
505
506 :param hass_state: The raw state as reported by the entity.
507 """
508 if self.extra_data.get(ATTR_ANNOUNCEMENT_IN_PROGRESS):
509 # an announcement takes the entity over, its states say nothing about our stream
510 return
511 if self._attr_playback_state in (PlaybackState.PLAYING, PlaybackState.PAUSED):
512 self._ma_playback_started = True
513 elif hass_state not in UNAVAILABLE_STATES and self._ma_playback_started:
514 # the entity played our stream and stopped again, so the session ended with it
515 self._ma_playback_active = False
516 self._ma_playback_started = False
517
518 def _get_image_url(self, attributes: dict[str, Any]) -> str | None:
519 """Get the image URL from the attributes."""
520 if entity_picture := attributes.get("entity_picture"):
521 entity_picture = str(entity_picture)
522 if entity_picture.startswith("http"):
523 return entity_picture
524
525 # Access via provider -> hass_prov
526 prov = cast("HomeAssistantPlayerProvider", self.provider)
527
528 # Use proxy for internal HA images
529 # We create a MediaItemImage with the hass provider as source
530 # This will trigger resolve_image on the hass provider when requested
531 image = MediaItemImage(
532 type=ImageType.THUMB,
533 path=entity_picture,
534 provider=prov.hass_prov.instance_id,
535 remotely_accessible=False,
536 )
537 return self.mass.metadata.get_image_url(image)
538 return None
539
540 def _has_native_duplicate(self) -> bool:
541 """Whether this device is also registered as a native Music Assistant player."""
542 if not (mac := self.device_info.identifiers.get(IdentifierType.MAC_ADDRESS)):
543 return False
544 return normalized_mac(mac) in native_player_macs(self.mass)
545