Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Implementation of the musiccast media player."""
2 
3 from __future__ import annotations
4 
5 import contextlib
6 import logging
7 from typing import Any
8 
9 from aiomusiccast import MusicCastGroupException, MusicCastMediaContent
10 from aiomusiccast.features import ZoneFeature
11 
12 from homeassistant.components import media_source
14  BrowseMedia,
15  MediaClass,
16  MediaPlayerEntity,
17  MediaPlayerEntityFeature,
18  MediaPlayerState,
19  MediaType,
20  RepeatMode,
21  async_process_play_media_url,
22 )
23 from homeassistant.config_entries import ConfigEntry
24 from homeassistant.core import HomeAssistant, callback
25 from homeassistant.exceptions import HomeAssistantError
26 from homeassistant.helpers.entity import Entity
27 from homeassistant.helpers.entity_platform import AddEntitiesCallback
28 from homeassistant.util import uuid
29 
30 from .const import (
31  ATTR_MAIN_SYNC,
32  ATTR_MC_LINK,
33  DEFAULT_ZONE,
34  DOMAIN,
35  HA_REPEAT_MODE_TO_MC_MAPPING,
36  MC_REPEAT_MODE_TO_HA_MAPPING,
37  MEDIA_CLASS_MAPPING,
38  NULL_GROUP,
39 )
40 from .coordinator import MusicCastDataUpdateCoordinator
41 from .entity import MusicCastDeviceEntity
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 MUSIC_PLAYER_BASE_SUPPORT = (
46  MediaPlayerEntityFeature.SHUFFLE_SET
47  | MediaPlayerEntityFeature.REPEAT_SET
48  | MediaPlayerEntityFeature.SELECT_SOUND_MODE
49  | MediaPlayerEntityFeature.SELECT_SOURCE
50  | MediaPlayerEntityFeature.GROUPING
51  | MediaPlayerEntityFeature.PLAY_MEDIA
52 )
53 
54 
56  hass: HomeAssistant,
57  entry: ConfigEntry,
58  async_add_entities: AddEntitiesCallback,
59 ) -> None:
60  """Set up MusicCast sensor based on a config entry."""
61  coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
62 
63  name = coordinator.data.network_name
64 
65  media_players: list[Entity] = []
66 
67  for zone in coordinator.data.zones:
68  zone_name = name if zone == DEFAULT_ZONE else f"{name} {zone}"
69 
70  media_players.append(
71  MusicCastMediaPlayer(zone, zone_name, entry.entry_id, coordinator)
72  )
73 
74  async_add_entities(media_players)
75 
76 
78  """The musiccast media player."""
79 
80  _attr_media_content_type = MediaType.MUSIC
81  _attr_should_poll = False
82 
83  def __init__(self, zone_id, name, entry_id, coordinator):
84  """Initialize the musiccast device."""
85  self._player_state_player_state = MediaPlayerState.PLAYING
86  self._volume_muted_volume_muted = False
87  self._shuffle_shuffle = False
88  self._zone_id_zone_id_zone_id = zone_id
89 
90  super().__init__(
91  name=name,
92  icon="mdi:speaker",
93  coordinator=coordinator,
94  )
95 
96  self._volume_min_volume_min = self.coordinator.data.zones[self._zone_id_zone_id_zone_id].min_volume
97  self._volume_max_volume_max = self.coordinator.data.zones[self._zone_id_zone_id_zone_id].max_volume
98 
99  self._cur_track_cur_track = 0
100  self._repeat_repeat = RepeatMode.OFF
101 
102  async def async_added_to_hass(self) -> None:
103  """Run when this Entity has been added to HA."""
104  await super().async_added_to_hass()
105  self.coordinator.entities.append(self)
106  # Sensors should also register callbacks to HA when their state changes
107  self.coordinator.musiccast.register_group_update_callback(
108  self.update_all_mc_entitiesupdate_all_mc_entities
109  )
110  self.async_on_removeasync_on_remove(
111  self.coordinator.async_add_listener(self.async_schedule_check_client_listasync_schedule_check_client_list)
112  )
113 
114  async def async_will_remove_from_hass(self) -> None:
115  """Entity being removed from hass."""
116  await super().async_will_remove_from_hass()
117  self.coordinator.entities.remove(self)
118  # The opposite of async_added_to_hass. Remove any registered call backs here.
119  self.coordinator.musiccast.remove_group_update_callback(
120  self.update_all_mc_entitiesupdate_all_mc_entities
121  )
122 
123  @property
124  def ip_address(self):
125  """Return the ip address of the musiccast device."""
126  return self.coordinator.musiccast.ip
127 
128  @property
129  def zone_id(self):
130  """Return the zone id of the musiccast device."""
131  return self._zone_id_zone_id_zone_id
132 
133  @property
134  def _is_netusb(self):
135  return self.coordinator.data.netusb_input == self.source_idsource_idsource_id
136 
137  @property
138  def _is_tuner(self):
139  return self.source_idsource_idsource_id == "tuner"
140 
141  @property
142  def media_content_id(self):
143  """Return the content ID of current playing media."""
144  return None
145 
146  @property
147  def state(self) -> MediaPlayerState:
148  """Return the state of the player."""
149  if self.coordinator.data.zones[self._zone_id_zone_id_zone_id].power == "on":
150  if self._is_netusb_is_netusb and self.coordinator.data.netusb_playback == "pause":
151  return MediaPlayerState.PAUSED
152  if self._is_netusb_is_netusb and self.coordinator.data.netusb_playback == "stop":
153  return MediaPlayerState.IDLE
154  return MediaPlayerState.PLAYING
155  return MediaPlayerState.OFF
156 
157  @property
158  def source_mapping(self):
159  """Return a mapping of the actual source names to their labels configured in the MusicCast App."""
160  ret = {}
161  for inp in self.coordinator.data.zones[self._zone_id_zone_id_zone_id].input_list:
162  label = self.coordinator.data.input_names.get(inp, "")
163  if inp != label and (
164  label in self.coordinator.data.zones[self._zone_id_zone_id_zone_id].input_list
165  or list(self.coordinator.data.input_names.values()).count(label) > 1
166  ):
167  label += f" ({inp})"
168  if label == "":
169  label = inp
170  ret[inp] = label
171  return ret
172 
173  @property
175  """Return a mapping from the source label to the source name."""
176  return {v: k for k, v in self.source_mappingsource_mapping.items()}
177 
178  @property
179  def volume_level(self):
180  """Return the volume level of the media player (0..1)."""
181  if ZoneFeature.VOLUME in self.coordinator.data.zones[self._zone_id_zone_id_zone_id].features:
182  volume = self.coordinator.data.zones[self._zone_id_zone_id_zone_id].current_volume
183  return (volume - self._volume_min_volume_min) / (self._volume_max_volume_max - self._volume_min_volume_min)
184  return None
185 
186  @property
187  def is_volume_muted(self):
188  """Return boolean if volume is currently muted."""
189  if ZoneFeature.VOLUME in self.coordinator.data.zones[self._zone_id_zone_id_zone_id].features:
190  return self.coordinator.data.zones[self._zone_id_zone_id_zone_id].mute
191  return None
192 
193  @property
194  def shuffle(self):
195  """Boolean if shuffling is enabled."""
196  return (
197  self.coordinator.data.netusb_shuffle == "on" if self._is_netusb_is_netusb else False
198  )
199 
200  @property
201  def sound_mode(self):
202  """Return the current sound mode."""
203  return self.coordinator.data.zones[self._zone_id_zone_id_zone_id].sound_program
204 
205  @property
206  def sound_mode_list(self):
207  """Return a list of available sound modes."""
208  return self.coordinator.data.zones[self._zone_id_zone_id_zone_id].sound_program_list
209 
210  @property
211  def zone(self):
212  """Return the zone of the media player."""
213  return self._zone_id_zone_id_zone_id
214 
215  @property
216  def unique_id(self) -> str:
217  """Return the unique ID for this media_player."""
218  return f"{self.coordinator.data.device_id}_{self._zone_id}"
219 
220  async def async_turn_on(self) -> None:
221  """Turn the media player on."""
222  await self.coordinator.musiccast.turn_on(self._zone_id_zone_id_zone_id)
223  self.async_write_ha_stateasync_write_ha_state()
224 
225  async def async_turn_off(self) -> None:
226  """Turn the media player off."""
227  await self.coordinator.musiccast.turn_off(self._zone_id_zone_id_zone_id)
228  self.async_write_ha_stateasync_write_ha_state()
229 
230  async def async_mute_volume(self, mute: bool) -> None:
231  """Mute the volume."""
232 
233  await self.coordinator.musiccast.mute_volume(self._zone_id_zone_id_zone_id, mute)
234  self.async_write_ha_stateasync_write_ha_state()
235 
236  async def async_set_volume_level(self, volume: float) -> None:
237  """Set the volume level, range 0..1."""
238  await self.coordinator.musiccast.set_volume_level(self._zone_id_zone_id_zone_id, volume)
239  self.async_write_ha_stateasync_write_ha_state()
240 
241  async def async_volume_up(self) -> None:
242  """Turn volume up for media player."""
243  await self.coordinator.musiccast.volume_up(self._zone_id_zone_id_zone_id)
244 
245  async def async_volume_down(self) -> None:
246  """Turn volume down for media player."""
247  await self.coordinator.musiccast.volume_down(self._zone_id_zone_id_zone_id)
248 
249  async def async_media_play(self) -> None:
250  """Send play command."""
251  if self._is_netusb_is_netusb:
252  await self.coordinator.musiccast.netusb_play()
253  else:
254  raise HomeAssistantError(
255  "Service play is not supported for non NetUSB sources."
256  )
257 
258  async def async_media_pause(self) -> None:
259  """Send pause command."""
260  if self._is_netusb_is_netusb:
261  await self.coordinator.musiccast.netusb_pause()
262  else:
263  raise HomeAssistantError(
264  "Service pause is not supported for non NetUSB sources."
265  )
266 
267  async def async_media_stop(self) -> None:
268  """Send stop command."""
269  if self._is_netusb_is_netusb:
270  await self.coordinator.musiccast.netusb_stop()
271  else:
272  raise HomeAssistantError(
273  "Service stop is not supported for non NetUSB sources."
274  )
275 
276  async def async_set_shuffle(self, shuffle: bool) -> None:
277  """Enable/disable shuffle mode."""
278  if self._is_netusb_is_netusb:
279  await self.coordinator.musiccast.netusb_shuffle(shuffle)
280  else:
281  raise HomeAssistantError(
282  "Service shuffle is not supported for non NetUSB sources."
283  )
284 
285  async def async_play_media(
286  self, media_type: MediaType | str, media_id: str, **kwargs: Any
287  ) -> None:
288  """Play media."""
289  if media_source.is_media_source_id(media_id):
290  play_item = await media_source.async_resolve_media(
291  self.hasshasshass, media_id, self.entity_identity_id
292  )
293  media_id = play_item.url
294 
295  if self.statestatestatestatestatestate == MediaPlayerState.OFF:
296  await self.async_turn_onasync_turn_onasync_turn_on()
297 
298  if media_id:
299  parts = media_id.split(":")
300 
301  if parts[0] == "list":
302  if (index := parts[3]) == "-1":
303  index = "0"
304 
305  await self.coordinator.musiccast.play_list_media(index, self._zone_id_zone_id_zone_id)
306  return
307 
308  if parts[0] == "presets":
309  index = parts[1]
310  await self.coordinator.musiccast.recall_netusb_preset(
311  self._zone_id_zone_id_zone_id, index
312  )
313  return
314 
315  if parts[0] in ("http", "https") or media_id.startswith("/"):
316  media_id = async_process_play_media_url(self.hasshasshass, media_id)
317 
318  await self.coordinator.musiccast.play_url_media(
319  self._zone_id_zone_id_zone_id, media_id, "HomeAssistant"
320  )
321  return
322 
323  raise HomeAssistantError(
324  "Only presets, media from media browser and http URLs are supported"
325  )
326 
327  async def async_browse_media(self, media_content_type=None, media_content_id=None):
328  """Implement the websocket media browsing helper."""
329  if media_content_id and media_source.is_media_source_id(media_content_id):
330  return await media_source.async_browse_media(
331  self.hasshasshass,
332  media_content_id,
333  content_filter=lambda item: item.media_content_type.startswith(
334  "audio/"
335  ),
336  )
337 
338  if self.statestatestatestatestatestate == MediaPlayerState.OFF:
339  raise HomeAssistantError(
340  "The device has to be turned on to be able to browse media."
341  )
342 
343  if media_content_id:
344  media_content_path = media_content_id.split(":")
345  media_content_provider = await MusicCastMediaContent.browse_media(
346  self.coordinator.musiccast, self._zone_id_zone_id_zone_id, media_content_path, 24
347  )
348  add_media_source = False
349 
350  else:
351  media_content_provider = MusicCastMediaContent.categories(
352  self.coordinator.musiccast, self._zone_id_zone_id_zone_id
353  )
354  add_media_source = True
355 
356  def get_content_type(item):
357  if item.can_play:
358  return MediaClass.TRACK
359  return MediaClass.DIRECTORY
360 
361  children = [
362  BrowseMedia(
363  title=child.title,
364  media_class=MEDIA_CLASS_MAPPING.get(child.content_type),
365  media_content_id=child.content_id,
366  media_content_type=get_content_type(child),
367  can_play=child.can_play,
368  can_expand=child.can_browse,
369  thumbnail=child.thumbnail,
370  )
371  for child in media_content_provider.children
372  ]
373 
374  if add_media_source:
375  with contextlib.suppress(media_source.BrowseError):
376  item = await media_source.async_browse_media(
377  self.hasshasshass,
378  None,
379  content_filter=lambda item: item.media_content_type.startswith(
380  "audio/"
381  ),
382  )
383  # If domain is None, it's overview of available sources
384  if item.domain is None:
385  children.extend(item.children)
386  else:
387  children.append(item)
388 
389  return BrowseMedia(
390  title=media_content_provider.title,
391  media_class=MEDIA_CLASS_MAPPING.get(media_content_provider.content_type),
392  media_content_id=media_content_provider.content_id,
393  media_content_type=get_content_type(media_content_provider),
394  can_play=False,
395  can_expand=media_content_provider.can_browse,
396  children=children,
397  )
398 
399  async def async_select_sound_mode(self, sound_mode: str) -> None:
400  """Select sound mode."""
401  await self.coordinator.musiccast.select_sound_mode(self._zone_id_zone_id_zone_id, sound_mode)
402 
403  @property
404  def media_image_url(self):
405  """Return the image url of current playing media."""
406  if self.is_clientis_client and self.group_servergroup_server != self:
407  return self.group_servergroup_server.coordinator.musiccast.media_image_url
408  return self.coordinator.musiccast.media_image_url if self._is_netusb_is_netusb else None
409 
410  @property
411  def media_title(self):
412  """Return the title of current playing media."""
413  if self._is_netusb_is_netusb:
414  return self.coordinator.data.netusb_track
415  if self._is_tuner_is_tuner:
416  return self.coordinator.musiccast.tuner_media_title
417 
418  return None
419 
420  @property
421  def media_artist(self):
422  """Return the artist of current playing media (Music track only)."""
423  if self._is_netusb_is_netusb:
424  return self.coordinator.data.netusb_artist
425  if self._is_tuner_is_tuner:
426  return self.coordinator.musiccast.tuner_media_artist
427 
428  return None
429 
430  @property
431  def media_album_name(self):
432  """Return the album of current playing media (Music track only)."""
433  return self.coordinator.data.netusb_album if self._is_netusb_is_netusb else None
434 
435  @property
436  def repeat(self):
437  """Return current repeat mode."""
438  return (
439  MC_REPEAT_MODE_TO_HA_MAPPING.get(self.coordinator.data.netusb_repeat)
440  if self._is_netusb_is_netusb
441  else RepeatMode.OFF
442  )
443 
444  @property
445  def supported_features(self) -> MediaPlayerEntityFeature:
446  """Flag media player features that are supported."""
447  supported_features = MUSIC_PLAYER_BASE_SUPPORT
448  zone = self.coordinator.data.zones[self._zone_id_zone_id_zone_id]
449 
450  if ZoneFeature.POWER in zone.features:
451  supported_features |= (
452  MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
453  )
454  if ZoneFeature.VOLUME in zone.features:
455  supported_features |= (
456  MediaPlayerEntityFeature.VOLUME_SET
457  | MediaPlayerEntityFeature.VOLUME_STEP
458  )
459  if ZoneFeature.MUTE in zone.features:
460  supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
461 
462  if self._is_netusb_is_netusb or self._is_tuner_is_tuner:
463  supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
464  supported_features |= MediaPlayerEntityFeature.NEXT_TRACK
465 
466  if self._is_netusb_is_netusb:
467  supported_features |= MediaPlayerEntityFeature.PAUSE
468  supported_features |= MediaPlayerEntityFeature.PLAY
469  supported_features |= MediaPlayerEntityFeature.STOP
470 
471  if self.statestatestatestatestatestate != MediaPlayerState.OFF:
472  supported_features |= MediaPlayerEntityFeature.BROWSE_MEDIA
473 
474  return supported_features
475 
476  async def async_media_previous_track(self) -> None:
477  """Send previous track command."""
478  if self._is_netusb_is_netusb:
479  await self.coordinator.musiccast.netusb_previous_track()
480  elif self._is_tuner_is_tuner:
481  await self.coordinator.musiccast.tuner_previous_station()
482  else:
483  raise HomeAssistantError(
484  "Service previous track is not supported for non NetUSB or Tuner"
485  " sources."
486  )
487 
488  async def async_media_next_track(self) -> None:
489  """Send next track command."""
490  if self._is_netusb_is_netusb:
491  await self.coordinator.musiccast.netusb_next_track()
492  elif self._is_tuner_is_tuner:
493  await self.coordinator.musiccast.tuner_next_station()
494  else:
495  raise HomeAssistantError(
496  "Service next track is not supported for non NetUSB or Tuner sources."
497  )
498 
499  async def async_set_repeat(self, repeat: RepeatMode) -> None:
500  """Enable/disable repeat mode."""
501  if self._is_netusb_is_netusb:
502  await self.coordinator.musiccast.netusb_repeat(
503  HA_REPEAT_MODE_TO_MC_MAPPING.get(repeat, "off")
504  )
505  else:
506  raise HomeAssistantError(
507  "Service set repeat is not supported for non NetUSB sources."
508  )
509 
510  async def async_select_source(self, source: str) -> None:
511  """Select input source."""
512  await self.coordinator.musiccast.select_source(
513  self._zone_id_zone_id_zone_id, self.reverse_source_mappingreverse_source_mapping.get(source, source)
514  )
515 
516  @property
517  def source_id(self):
518  """ID of the current input source."""
519  return self.coordinator.data.zones[self._zone_id_zone_id_zone_id].input
520 
521  @property
522  def source(self):
523  """Name of the current input source."""
524  return self.source_mappingsource_mapping.get(self.source_idsource_idsource_id)
525 
526  @property
527  def source_list(self):
528  """List of available input sources."""
529  return list(self.source_mappingsource_mapping.values())
530 
531  @property
532  def media_duration(self):
533  """Duration of current playing media in seconds."""
534  if self._is_netusb_is_netusb:
535  return self.coordinator.data.netusb_total_time
536 
537  return None
538 
539  @property
540  def media_position(self):
541  """Position of current playing media in seconds."""
542  if self._is_netusb_is_netusb:
543  return self.coordinator.data.netusb_play_time
544 
545  return None
546 
547  @property
549  """When was the position of the current playing media valid.
550 
551  Returns value from homeassistant.util.dt.utcnow().
552  """
553  if self._is_netusb_is_netusb:
554  return self.coordinator.data.netusb_play_time_updated
555 
556  return None
557 
558  # Group and MusicCast System specific functions/properties
559 
560  @property
561  def is_network_server(self) -> bool:
562  """Return only true if the current entity is a network server and not a main zone with an attached zone2."""
563  return (
564  self.coordinator.data.group_role == "server"
565  and self.coordinator.data.group_id != NULL_GROUP
566  and self._zone_id_zone_id_zone_id == self.coordinator.data.group_server_zone
567  )
568 
569  @property
570  def other_zones(self) -> list[MusicCastMediaPlayer]:
571  """Return media player entities of the other zones of this device."""
572  return [
573  entity
574  for entity in self.coordinator.entities
575  if entity != self and isinstance(entity, MusicCastMediaPlayer)
576  ]
577 
578  @property
579  def is_server(self) -> bool:
580  """Return whether the media player is the server/host of the group.
581 
582  If the media player is not part of a group, False is returned.
583  """
584  return self.is_network_serveris_network_server or (
585  self._zone_id_zone_id_zone_id == DEFAULT_ZONE
586  and len(
587  [
588  entity
589  for entity in self.other_zonesother_zones
590  if entity.source == ATTR_MAIN_SYNC
591  ]
592  )
593  > 0
594  )
595 
596  @property
597  def is_network_client(self) -> bool:
598  """Return True if the current entity is a network client and not just a main syncing entity."""
599  return (
600  self.coordinator.data.group_role == "client"
601  and self.coordinator.data.group_id != NULL_GROUP
602  and self.source_idsource_idsource_id == ATTR_MC_LINK
603  )
604 
605  @property
606  def is_client(self) -> bool:
607  """Return whether the media player is the client of a group.
608 
609  If the media player is not part of a group, False is returned.
610  """
611  return self.is_network_clientis_network_client or self.source_idsource_idsource_id == ATTR_MAIN_SYNC
612 
613  def get_all_mc_entities(self) -> list[MusicCastMediaPlayer]:
614  """Return all media player entities of the musiccast system."""
615  entities = []
616  for coordinator in self.hasshasshass.data[DOMAIN].values():
617  entities += [
618  entity
619  for entity in coordinator.entities
620  if isinstance(entity, MusicCastMediaPlayer)
621  ]
622  return entities
623 
624  def get_all_server_entities(self) -> list[MusicCastMediaPlayer]:
625  """Return all media player entities in the musiccast system, which are in server mode."""
626  entities = self.get_all_mc_entitiesget_all_mc_entities()
627  return [entity for entity in entities if entity.is_server]
628 
629  def get_distribution_num(self) -> int:
630  """Return the distribution_num (number of clients in the whole musiccast system)."""
631  return sum(
632  len(server.coordinator.data.group_client_list)
633  for server in self.get_all_server_entitiesget_all_server_entities()
634  )
635 
636  def is_part_of_group(self, group_server) -> bool:
637  """Return True if the given server is the server of self's group."""
638  return group_server != self and (
639  (
640  self.ip_addressip_addressip_address in group_server.coordinator.data.group_client_list
641  and self.coordinator.data.group_id
642  == group_server.coordinator.data.group_id
643  and self.ip_addressip_addressip_address != group_server.ip_address
644  and self.source_idsource_idsource_id == ATTR_MC_LINK
645  )
646  or (
647  self.ip_addressip_addressip_address == group_server.ip_address
648  and self.source_idsource_idsource_id == ATTR_MAIN_SYNC
649  )
650  )
651 
652  @property
653  def group_server(self):
654  """Return the server of the own group if present, self else."""
655  for entity in self.get_all_server_entitiesget_all_server_entities():
656  if self.is_part_of_groupis_part_of_group(entity):
657  return entity
658  return self
659 
660  @property
661  def group_members(self) -> list[str] | None:
662  """Return a list of entity_ids, which belong to the group of self."""
663  return [entity.entity_id for entity in self.musiccast_groupmusiccast_group]
664 
665  @property
666  def musiccast_group(self) -> list[MusicCastMediaPlayer]:
667  """Return all media players of the current group, if the media player is server."""
668  if self.is_clientis_client:
669  # If we are a client we can still share group information, but we will take them from the server.
670  if (server := self.group_servergroup_server) != self:
671  return server.musiccast_group
672 
673  return [self]
674  if not self.is_serveris_server:
675  return [self]
676  entities = self.get_all_mc_entitiesget_all_mc_entities()
677  clients = [entity for entity in entities if entity.is_part_of_group(self)]
678  return [self, *clients]
679 
680  @property
681  def musiccast_zone_entity(self) -> MusicCastMediaPlayer:
682  """Return the entity of the zone, which is using MusicCast at the moment, if there is one, self else.
683 
684  It is possible that multiple zones use MusicCast as client at the same time. In this case the first one is
685  returned.
686  """
687  for entity in self.other_zonesother_zones:
688  if entity.is_network_server or entity.is_network_client:
689  return entity
690 
691  return self
692 
693  async def update_all_mc_entities(self, check_clients=False):
694  """Update the whole musiccast system when group data change."""
695  # First update all servers as they provide the group information for their clients
696  for entity in self.get_all_server_entitiesget_all_server_entities():
697  if check_clients or self.coordinator.musiccast.group_reduce_by_source:
698  await entity.async_check_client_list()
699  else:
700  entity.async_write_ha_state()
701  # Then update all other entities
702  for entity in self.get_all_mc_entitiesget_all_mc_entities():
703  if not entity.is_server:
704  entity.async_write_ha_state()
705 
706  # Services
707 
708  async def async_join_players(self, group_members: list[str]) -> None:
709  """Add all clients given in entities to the group of the server.
710 
711  Creates a new group if necessary. Used for join service.
712  """
713  _LOGGER.debug(
714  "%s wants to add the following entities %s",
715  self.entity_identity_id,
716  str(group_members),
717  )
718 
719  entities = [
720  entity
721  for entity in self.get_all_mc_entitiesget_all_mc_entities()
722  if entity.entity_id in group_members
723  ]
724 
725  if self.statestatestatestatestatestate == MediaPlayerState.OFF:
726  await self.async_turn_onasync_turn_onasync_turn_on()
727 
728  if not self.is_serveris_server and self.musiccast_zone_entitymusiccast_zone_entity.is_server:
729  # The MusicCast Distribution Module of this device is already in use. To use it as a server, we first
730  # have to unjoin and wait until the servers are updated.
731  await self.musiccast_zone_entitymusiccast_zone_entity.async_server_close_group()
732  elif self.musiccast_zone_entitymusiccast_zone_entity.is_client:
733  await self.async_client_leave_groupasync_client_leave_group(True)
734  # Use existing group id if we are server, generate a new one else.
735  group = (
736  self.coordinator.data.group_id
737  if self.is_serveris_server
738  else uuid.random_uuid_hex().upper()
739  )
740 
741  ip_addresses = set()
742  # First let the clients join
743  for client in entities:
744  if client != self:
745  try:
746  network_join = await client.async_client_join(group, self)
747  except MusicCastGroupException:
748  _LOGGER.warning(
749  (
750  "%s is struggling to update its group data. Will retry"
751  " perform the update"
752  ),
753  client.entity_id,
754  )
755  network_join = await client.async_client_join(group, self)
756 
757  if network_join:
758  ip_addresses.add(client.ip_address)
759 
760  if ip_addresses:
761  await self.coordinator.musiccast.mc_server_group_extend(
762  self._zone_id_zone_id_zone_id,
763  list(ip_addresses),
764  group,
765  self.get_distribution_numget_distribution_num(),
766  )
767  _LOGGER.debug(
768  "%s added the following entities %s", self.entity_identity_id, str(entities)
769  )
770  _LOGGER.debug(
771  "%s has now the following musiccast group %s",
772  self.entity_identity_id,
773  str(self.musiccast_groupmusiccast_group),
774  )
775 
776  await self.update_all_mc_entitiesupdate_all_mc_entities(True)
777 
778  async def async_unjoin_player(self) -> None:
779  """Leave the group.
780 
781  Stops the distribution if device is server. Used for unjoin service.
782  """
783  _LOGGER.debug("%s called service unjoin", self.entity_identity_id)
784  if self.is_serveris_server:
785  await self.async_server_close_groupasync_server_close_group()
786 
787  else:
788  await self.async_client_leave_groupasync_client_leave_group()
789 
790  await self.update_all_mc_entitiesupdate_all_mc_entities(True)
791 
792  # Internal client functions
793 
794  async def async_client_join(self, group_id, server) -> bool:
795  """Let the client join a group.
796 
797  If this client is a server, the server will stop distributing.
798  If the client is part of a different group,
799  it will leave that group first. Returns True, if the server has to
800  add the client on his side.
801  """
802  # If we should join the group, which is served by the main zone,
803  # we can simply select main_sync as input.
804  _LOGGER.debug("%s called service client join", self.entity_identity_id)
805  if self.statestatestatestatestatestate == MediaPlayerState.OFF:
806  await self.async_turn_onasync_turn_onasync_turn_on()
807  if self.ip_addressip_addressip_address == server.ip_address:
808  if server.zone == DEFAULT_ZONE:
809  await self.async_select_sourceasync_select_sourceasync_select_source(ATTR_MAIN_SYNC)
810  server.async_write_ha_state()
811  return False
812 
813  # It is not possible to join a group hosted by zone2 from main zone.
814  raise HomeAssistantError(
815  "Can not join a zone other than main of the same device."
816  )
817 
818  if self.musiccast_zone_entitymusiccast_zone_entity.is_server:
819  # If one of the zones of the device is a server, we need to unjoin first.
820  _LOGGER.debug(
821  (
822  "%s is a server of a group and has to stop distribution "
823  "to use MusicCast for %s"
824  ),
825  self.musiccast_zone_entitymusiccast_zone_entity.entity_id,
826  self.entity_identity_id,
827  )
828  await self.musiccast_zone_entitymusiccast_zone_entity.async_server_close_group()
829 
830  elif self.is_clientis_client:
831  if self.is_part_of_groupis_part_of_group(server):
832  _LOGGER.warning("%s is already part of the group", self.entity_identity_id)
833  return False
834 
835  _LOGGER.debug(
836  "%s is client in a different group, will unjoin first",
837  self.entity_identity_id,
838  )
839  await self.async_client_leave_groupasync_client_leave_group()
840 
841  elif (
842  self.ip_addressip_addressip_address in server.coordinator.data.group_client_list
843  and self.coordinator.data.group_id == server.coordinator.data.group_id
844  and self.coordinator.data.group_role == "client"
845  ):
846  # The device is already part of this group (e.g. main zone is also a client of this group).
847  # Just select mc_link as source
848  await self.coordinator.musiccast.zone_join(self._zone_id_zone_id_zone_id)
849  return False
850 
851  _LOGGER.debug("%s will now join as a client", self.entity_identity_id)
852  await self.coordinator.musiccast.mc_client_join(
853  server.ip_address, group_id, self._zone_id_zone_id_zone_id
854  )
855  return True
856 
857  async def async_client_leave_group(self, force=False):
858  """Make self leave the group.
859 
860  Should only be called for clients.
861  """
862  _LOGGER.debug("%s client leave called", self.entity_identity_id)
863  if not force and (
864  self.source_idsource_idsource_id == ATTR_MAIN_SYNC
865  or [
866  entity
867  for entity in self.other_zonesother_zones
868  if entity.source_id == ATTR_MC_LINK
869  ]
870  ):
871  await self.coordinator.musiccast.zone_unjoin(self._zone_id_zone_id_zone_id)
872  else:
873  servers = [
874  server
875  for server in self.get_all_server_entitiesget_all_server_entities()
876  if server.coordinator.data.group_id == self.coordinator.data.group_id
877  ]
878  await self.coordinator.musiccast.mc_client_unjoin()
879  if servers:
880  await servers[0].coordinator.musiccast.mc_server_group_reduce(
881  servers[0].zone_id, [self.ip_addressip_addressip_address], self.get_distribution_numget_distribution_num()
882  )
883 
884  # Internal server functions
885 
886  async def async_server_close_group(self):
887  """Close group of self.
888 
889  Should only be called for servers.
890  """
891  _LOGGER.debug("%s closes his group", self.entity_identity_id)
892  for client in self.musiccast_groupmusiccast_group:
893  if client != self:
894  await client.async_client_leave_group()
895  await self.coordinator.musiccast.mc_server_group_close()
896 
897  async def async_check_client_list(self):
898  """Let the server check if all its clients are still part of his group."""
899  if not self.is_serveris_server or self.coordinator.data.group_update_lock.locked():
900  return
901 
902  _LOGGER.debug("%s updates his group members", self.entity_identity_id)
903  client_ips_for_removal = [
904  expected_client_ip
905  for expected_client_ip in self.coordinator.data.group_client_list
906  # The client is no longer part of the group. Prepare removal.
907  if expected_client_ip
908  not in [entity.ip_address for entity in self.musiccast_groupmusiccast_group]
909  ]
910 
911  if client_ips_for_removal:
912  _LOGGER.debug(
913  "%s says good bye to the following members %s",
914  self.entity_identity_id,
915  str(client_ips_for_removal),
916  )
917  await self.coordinator.musiccast.mc_server_group_reduce(
918  self._zone_id_zone_id_zone_id, client_ips_for_removal, self.get_distribution_numget_distribution_num()
919  )
920  if len(self.musiccast_groupmusiccast_group) < 2:
921  # The group is empty, stop distribution.
922  await self.async_server_close_groupasync_server_close_group()
923 
924  self.async_write_ha_stateasync_write_ha_state()
925 
926  @callback
928  """Schedule async_check_client_list."""
929  self.hasshasshass.async_create_task(self.async_check_client_listasync_check_client_list(), eager_start=True)
def __init__(self, zone_id, name, entry_id, coordinator)
Definition: media_player.py:83
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
def async_browse_media(self, media_content_type=None, media_content_id=None)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
Callable[[], None] async_add_listener(self, CALLBACK_TYPE update_callback, Any context=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
str async_process_play_media_url(HomeAssistant hass, str media_content_id, *bool allow_relative_url=False, bool for_supervisor_network=False)
Definition: browse_media.py:36
None async_setup_entry(HomeAssistant hass, ConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:59