/
/
/
1"""
2MusicCast Handling for Music Assistant.
3
4This is largely taken from the MusicCast integration in HomeAssistant,
5https://github.com/home-assistant/core/tree/dev/homeassistant/components/yamaha_musiccast
6and then adapted for MA.
7
8We have
9
10MusicCastController - only once, holds state information of MC network
11 MusicCastPhysicalDevice - AV Receiver, Boxes
12 MusicCastZoneDevice - Player entity, which can be controlled.
13"""
14
15import logging
16from collections.abc import Awaitable, Callable
17from contextlib import suppress
18from datetime import datetime
19from enum import Enum, auto
20from random import getrandbits
21from typing import TYPE_CHECKING, cast
22
23from aiomusiccast.exceptions import MusicCastConnectionException, MusicCastGroupException
24
25from .constants import (
26 MC_DEFAULT_ZONE,
27 MC_NULL_GROUP,
28 MC_PLAY_TITLE,
29 MC_SOURCE_MAIN_SYNC,
30 MC_SOURCE_MC_LINK,
31)
32
33if TYPE_CHECKING:
34 from aiomusiccast.musiccast_device import MusicCastDevice
35
36
37def random_uuid_hex() -> str:
38 """
39 Generate a random UUID hex.
40
41 This uuid should not be used for cryptographically secure
42 operations.
43
44 Taken from HA.
45 """
46 return f"{getrandbits(32 * 4):032x}"
47
48
49class MusicCastPlayerState(Enum):
50 """MusicCastPlayerState."""
51
52 PLAYING = auto()
53 PAUSED = auto()
54 IDLE = auto()
55 OFF = auto()
56
57
58class MusicCastZoneDevice:
59 """
60 Zone device.
61
62 A physical device may have different zones, though only a single zone
63 can be used for net playback (but the other ones can be synced internally).
64 """
65
66 def __init__(self, zone_name: str, physical_device: MusicCastPhysicalDevice) -> None:
67 """Init."""
68 self.zone_name = zone_name # this is not the friendly name
69 self.controller = physical_device.controller
70 self.device = physical_device.device
71 self.zone_data = self.device.data.zones.get(self.zone_name)
72 self.physical_device = physical_device
73
74 self.physical_device.register_group_update_callback(self._group_update)
75
76 async def _group_update(self) -> None:
77 for entity in self.controller.all_server_devices:
78 if self.device.group_reduce_by_source:
79 await entity._check_client_list()
80
81 @property
82 def sound_mode_id(self) -> str | None:
83 """ID of current sound mode."""
84 zone = self.device.data.zones.get(self.zone_name)
85 assert zone is not None # for type checking
86 assert isinstance(zone.sound_program, str | None) # for type checking
87 return zone.sound_program
88
89 @property
90 def sound_mode_list(self) -> list[str]:
91 """Return a list of available sound modes."""
92 zone = self.device.data.zones.get(self.zone_name)
93 assert zone is not None # for type checking
94 assert isinstance(zone.sound_program_list, list) # for type checking
95 return zone.sound_program_list
96
97 @property
98 def source_id(self) -> str:
99 """
100 ID of the current input source.
101
102 Internal source name.
103 """
104 zone = self.device.data.zones.get(self.zone_name)
105 assert zone is not None
106 assert isinstance(zone.input, str)
107 return zone.input
108
109 @property
110 def reverse_source_mapping(self) -> dict[str, str]:
111 """Return a mapping from the source label to the source name."""
112 return {v: k for k, v in self.source_mapping.items()}
113
114 @property
115 def source(self) -> str:
116 """Name of the current input source."""
117 return self.source_mapping.get(self.source_id, "UNKNOWN SOURCE")
118
119 @property
120 def source_mapping(self) -> dict[str, str]:
121 """Return a mapping of the actual source names to their labels configured in the App."""
122 assert self.zone_data is not None # for type checking
123 result = {}
124 for input_ in self.zone_data.input_list:
125 label = self.device.data.input_names.get(input_, "")
126 if input_ != label and (
127 label in self.zone_data.input_list
128 or list(self.device.data.input_names.values()).count(label) > 1
129 ):
130 label += f" ({input_})"
131 if label == "":
132 label = input_
133 result[input_] = label
134 return result
135
136 @property
137 def is_netusb(self) -> bool:
138 """Controlled by network if true."""
139 return cast("bool", self.device.data.netusb_input == self.source_id)
140
141 @property
142 def is_tuner(self) -> bool:
143 """Tuner if true."""
144 return self.source_id == "tuner"
145
146 @property
147 def is_controlled_by_mass(self) -> bool:
148 """Controlled by mass if true."""
149 return self.source_id == "server" and self.media_title == MC_PLAY_TITLE
150
151 @property
152 def media_position(self) -> int | None:
153 """Position of current playing media in seconds."""
154 if self.is_netusb:
155 return cast("int", self.device.data.netusb_play_time)
156 return None
157
158 @property
159 def media_position_updated_at(self) -> datetime | None:
160 """When was the position of the current playing media valid."""
161 if self.is_netusb:
162 return cast("datetime", self.device.data.netusb_play_time_updated)
163
164 return None
165
166 @property
167 def is_network_server(self) -> bool:
168 """
169 Return only true if the current entity is a network server.
170
171 I.e. not a main zone with an attached zone2.
172 """
173 return cast(
174 "bool",
175 self.device.data.group_role == "server"
176 and self.device.data.group_id != MC_NULL_GROUP
177 and self.zone_name == self.device.data.group_server_zone,
178 )
179
180 @property
181 def other_zones(self) -> list[MusicCastZoneDevice]:
182 """Return media player entities of the other zones of this device."""
183 return [
184 entity
185 for entity in self.physical_device.zone_devices.values()
186 if entity != self and isinstance(entity, MusicCastZoneDevice)
187 ]
188
189 @property
190 def state(self) -> MusicCastPlayerState:
191 """
192 Return the state of the player.
193
194 A main-sync zone mirrors the driving zone's netusb playback status, since only
195 one zone can hold the shared netusb session at a time.
196 """
197 assert self.zone_data is not None
198 if self.zone_data.power == "on":
199 _main = self.physical_device.zone_devices.get(MC_DEFAULT_ZONE)
200 _tracks_netusb = self.is_netusb or (
201 self.source_id == MC_SOURCE_MAIN_SYNC and _main is not None and _main.is_netusb
202 )
203 if _tracks_netusb and self.device.data.netusb_playback == "pause":
204 return MusicCastPlayerState.PAUSED
205 if _tracks_netusb and self.device.data.netusb_playback == "stop":
206 return MusicCastPlayerState.IDLE
207 return MusicCastPlayerState.PLAYING
208 return MusicCastPlayerState.OFF
209
210 @property
211 def is_server(self) -> bool:
212 """
213 Return whether the media player is the server/host of the group.
214
215 If the media player is not part of a group, False is returned.
216 """
217 return self.is_network_server or (
218 self.zone_name == MC_DEFAULT_ZONE
219 and len(
220 [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MAIN_SYNC]
221 )
222 > 0
223 )
224
225 @property
226 def is_network_client(self) -> bool:
227 """Return True if the current entity is a network client and not just a main sync entity."""
228 return (
229 self.device.data.group_role == "client"
230 and self.device.data.group_id != MC_NULL_GROUP
231 and self.source_id == MC_SOURCE_MC_LINK
232 )
233
234 @property
235 def is_client(self) -> bool:
236 """
237 Return whether the media player is the client of a group.
238
239 If the media player is not part of a group, False is returned.
240 """
241 return self.is_network_client or self.source_id == MC_SOURCE_MAIN_SYNC
242
243 @property
244 def musiccast_zone_entity(self) -> MusicCastZoneDevice:
245 """
246 Return the musiccast entity of the physical device.
247
248 It is possible that multiple zones use MusicCast as client at the same time.
249 In this case the first one is returned.
250 """
251 for entity in self.other_zones:
252 if entity.is_network_server or entity.is_network_client:
253 return entity
254
255 return self
256
257 @property
258 def musiccast_group(self) -> list[MusicCastZoneDevice]:
259 """Return all media players of the current group, if the media player is server."""
260 if self.is_client:
261 # If we are a client we can still share group information, but we will take them from
262 # the server.
263 if (server := self.group_server) != self:
264 return server.musiccast_group
265
266 return [self]
267 if not self.is_server:
268 return [self]
269 entities = self.controller.all_zone_devices
270 clients = [entity for entity in entities if entity.is_part_of_group(self)]
271 return [self, *clients]
272
273 @property
274 def group_server(self) -> MusicCastZoneDevice:
275 """Return the server of the own group if present, self else."""
276 for entity in self.controller.all_server_devices:
277 if self.is_part_of_group(entity):
278 return entity
279 return self
280
281 @property
282 def media_title(self) -> str | None:
283 """Return the title of current playing media."""
284 if self.is_netusb:
285 return cast("str", self.device.data.netusb_track)
286 if self.is_tuner:
287 return cast("str", self.device.tuner_media_title)
288
289 return None
290
291 @property
292 def media_image_url(self) -> str | None:
293 """Return the image url of current playing media."""
294 if self.is_client and self.group_server != self:
295 return cast("str", self.group_server.device.media_image_url)
296 return cast("str", self.device.media_image_url) if self.is_netusb else None
297
298 @property
299 def media_artist(self) -> str | None:
300 """Return the artist of current playing media (Music track only)."""
301 if self.is_netusb:
302 return cast("str", self.device.data.netusb_artist)
303 if self.is_tuner:
304 return cast("str", self.device.tuner_media_artist)
305
306 return None
307
308 @property
309 def media_album_name(self) -> str | None:
310 """Return the album of current playing media (Music track only)."""
311 return cast("str", self.device.data.netusb_album) if self.is_netusb else None
312
313 async def turn_on(self) -> None:
314 """Turn on."""
315 await self.device.turn_on(self.zone_name)
316
317 async def turn_off(self) -> None:
318 """Turn off."""
319 await self.device.turn_off(self.zone_name)
320
321 async def volume_mute(self, mute: bool) -> None:
322 """Volume mute."""
323 await self.device.mute_volume(self.zone_name, mute)
324
325 async def volume_set(self, volume_level: int) -> None:
326 """Volume set."""
327 await self.device.set_volume_level(self.zone_name, volume_level / 100)
328
329 async def play(self) -> None:
330 """Play."""
331 if self.is_netusb:
332 await self.device.netusb_play()
333
334 async def pause(self) -> None:
335 """Pause."""
336 if self.is_netusb:
337 await self.device.netusb_pause()
338
339 async def stop(self) -> None:
340 """Stop."""
341 if self.is_netusb:
342 await self.device.netusb_stop()
343
344 async def previous_track(self) -> None:
345 """Send previous track command."""
346 if self.is_netusb:
347 await self.device.netusb_previous_track()
348 elif self.is_tuner:
349 await self.device.tuner_previous_station()
350
351 async def next_track(self) -> None:
352 """Send next track command."""
353 if self.is_netusb:
354 await self.device.netusb_next_track()
355 elif self.is_tuner:
356 await self.device.tuner_next_station()
357
358 async def play_url(self, url: str) -> None:
359 """Play http url."""
360 await self.device.play_url_media(self.zone_name, media_url=url, title=MC_PLAY_TITLE)
361
362 async def select_source(self, source_id: str, mode: str = "") -> None:
363 """
364 Select input source. Internal source name.
365
366 :param source_id: Internal MusicCast source name.
367 :param mode: Optional MusicCast source mode, e.g. "autoplay_disabled".
368 """
369 await self.device.select_source(self.zone_name, source_id, mode)
370
371 async def select_sound_mode(self, sound_mode_id: str) -> None:
372 """Select sound mode. Internal sound_mode name."""
373 await self.device.select_sound_mode(self.zone_name, sound_mode_id)
374
375 def is_part_of_group(self, group_server: MusicCastZoneDevice) -> bool:
376 """Return True if the given server is the server of self's group."""
377 return group_server != self and (
378 (
379 self.device.ip in group_server.device.data.group_client_list
380 and self.device.data.group_id == group_server.device.data.group_id
381 and self.device.ip != group_server.device.ip
382 and self.source_id == MC_SOURCE_MC_LINK
383 )
384 or (self.device.ip == group_server.device.ip and self.source_id == MC_SOURCE_MAIN_SYNC)
385 )
386
387 async def join_players(self, group_members: list[MusicCastZoneDevice]) -> None:
388 """
389 Add all clients given in entities to the group of the server.
390
391 Creates a new group if necessary. Used for join service.
392 """
393 assert self.zone_data is not None
394 if self.state == MusicCastPlayerState.OFF:
395 await self.turn_on()
396
397 if not self.is_server and self.musiccast_zone_entity.is_server:
398 # The MusicCast Distribution Module of this device is already in use. To use it as a
399 # server, we first have to unjoin and wait until the servers are updated.
400 await self.musiccast_zone_entity._server_close_group()
401 elif self.musiccast_zone_entity.is_client:
402 await self._client_leave_group(True)
403 # Use existing group id if we are server, generate a new one else.
404 group_id = self.device.data.group_id if self.is_server else random_uuid_hex().upper()
405 assert group_id is not None # for type checking
406
407 ip_addresses = set()
408 # First let the clients join
409 for client in group_members:
410 if client != self:
411 try:
412 network_join = await client._client_join(group_id, self)
413 except MusicCastGroupException:
414 network_join = await client._client_join(group_id, self)
415
416 if network_join:
417 ip_addresses.add(client.device.ip)
418
419 if ip_addresses:
420 await self.device.mc_server_group_extend(
421 self.zone_name,
422 list(ip_addresses),
423 group_id,
424 self.controller.distribution_num,
425 )
426
427 await self._group_update()
428
429 async def unjoin_player(self) -> None:
430 """
431 Leave the group.
432
433 Stops the distribution if device is server. Used for unjoin service.
434 """
435 if self.is_server:
436 await self._server_close_group()
437 else:
438 # this is not as in HA
439 await self._client_leave_group(True)
440
441 # Internal client functions
442
443 async def _client_join(self, group_id: str, server: MusicCastZoneDevice) -> bool:
444 """
445 Let the client join a group.
446
447 If this client is a server, the server will stop distributing.
448 If the client is part of a different group,
449 it will leave that group first. Returns True, if the server has to
450 add the client on his side.
451 """
452 # If we should join the group, which is served by the main zone,
453 # we can simply select main_sync as input.
454 if self.state == MusicCastPlayerState.OFF:
455 await self.turn_on()
456 if self.device.ip == server.device.ip:
457 if server.zone_name == MC_DEFAULT_ZONE:
458 await self.select_source(MC_SOURCE_MAIN_SYNC)
459 return False
460
461 # It is not possible to join a group hosted by zone2 from main zone.
462 # raise?
463 return False
464
465 if self.musiccast_zone_entity.is_server:
466 # If one of the zones of the device is a server, we need to unjoin first.
467 await self.musiccast_zone_entity._server_close_group()
468
469 elif self.is_client:
470 if self.is_part_of_group(server):
471 return False
472
473 await self._client_leave_group()
474
475 elif (
476 self.device.ip in server.device.data.group_client_list
477 and self.device.data.group_id == server.device.data.group_id
478 and self.device.data.group_role == "client"
479 ):
480 # The device is already part of this group (e.g. main zone is also a client of this
481 # group).
482 # Just select mc_link as source
483 await self.device.zone_join(self.zone_name)
484 return False
485
486 await self.device.mc_client_join(server.device.ip, group_id, self.zone_name)
487 return True
488
489 async def _client_leave_group(self, force: bool = False) -> None:
490 """
491 Make self leave the group.
492
493 Should only be called for clients.
494 """
495 if not force and (
496 self.source_id == MC_SOURCE_MAIN_SYNC
497 or [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MC_LINK]
498 ):
499 await self.device.zone_unjoin(self.zone_name)
500 else:
501 servers = [
502 server
503 for server in self.controller.all_server_devices
504 if server.device.data.group_id == self.device.data.group_id
505 ]
506 await self.device.mc_client_unjoin()
507 if servers:
508 await servers[0].device.mc_server_group_reduce(
509 servers[0].zone_name,
510 [self.device.ip],
511 self.controller.distribution_num,
512 )
513
514 # Internal server functions
515
516 async def _server_close_group(self) -> None:
517 """
518 Close group of self.
519
520 Should only be called for servers.
521 """
522 for client in self.musiccast_group:
523 if client != self:
524 await client._client_leave_group()
525 await self.device.mc_server_group_close()
526
527 async def _check_client_list(self) -> None:
528 """Let the server check if all its clients are still part of his group."""
529 if not self.is_server or self.device.data.group_update_lock.locked():
530 return
531
532 client_ips_for_removal = [
533 expected_client_ip
534 for expected_client_ip in self.device.data.group_client_list
535 # The client is no longer part of the group. Prepare removal.
536 if expected_client_ip not in [entity.device.ip for entity in self.musiccast_group]
537 ]
538
539 if client_ips_for_removal:
540 await self.device.mc_server_group_reduce(
541 self.zone_name, client_ips_for_removal, self.controller.distribution_num
542 )
543 if len(self.musiccast_group) < 2:
544 # The group is empty, stop distribution.
545 await self._server_close_group()
546
547
548class MusicCastPhysicalDevice:
549 """
550 Physical MusicCast device.
551
552 May contain multiple zone devices, but at least one, main.
553 """
554
555 def __init__(
556 self,
557 device: MusicCastDevice,
558 controller: MusicCastController,
559 ):
560 """Init."""
561 self.device = device
562 self.zone_devices: dict[str, MusicCastZoneDevice] = {} # zone_name: device
563 self.controller = controller
564 self.controller.physical_devices.append(self)
565
566 async def async_init(self) -> bool:
567 """
568 Async init.
569
570 Returns true if initial fetch was successful.
571 """
572 try:
573 await self.fetch()
574 except MusicCastConnectionException, MusicCastGroupException:
575 return False
576
577 self.device.build_capabilities()
578
579 # enable udp polling
580 await self.enable_polling()
581
582 for zone_name in self.device.data.zones:
583 self.zone_devices[zone_name] = MusicCastZoneDevice(zone_name, self)
584
585 return True
586
587 async def enable_polling(self) -> None:
588 """Enable udp polling."""
589 await self.device.device.enable_polling()
590
591 def disable_polling(self) -> None:
592 """Disable udp polling."""
593 with suppress(AttributeError):
594 # aiomusiccast raises if polling was never enabled or was already disabled
595 self.device.device.disable_polling()
596
597 async def fetch(self) -> None:
598 """
599 Fetch device information.
600
601 Should be called regularly, e.g. every 60s, in case some udp info
602 goes missing.
603 """
604 await self.device.fetch()
605
606 def register_callback(self, fun: Callable[[MusicCastPhysicalDevice], None]) -> None:
607 """Register a non-async callback."""
608
609 def _cb() -> None:
610 fun(self)
611
612 self.device.register_callback(_cb)
613
614 def register_group_update_callback(self, fun: Callable[[], Awaitable[None]]) -> None:
615 """Register an async group update callback."""
616 self.device.register_group_update_callback(fun)
617
618 def remove(self) -> None:
619 """Remove physical device."""
620 self.disable_polling()
621 with suppress(ValueError):
622 # might already be removed from controller
623 self.controller.physical_devices.remove(self)
624
625
626class MusicCastController:
627 """
628 MusicCastController.
629
630 Holds information of full known MC network.
631 """
632
633 def __init__(self, logger: logging.Logger) -> None:
634 """Init."""
635 self.physical_devices: list[MusicCastPhysicalDevice] = []
636 self.logger = logger
637
638 @property
639 def distribution_num(self) -> int:
640 """Return the distribution_num (number of clients in the whole musiccast system)."""
641 return sum(len(x.zone_devices) for x in self.physical_devices)
642
643 @property
644 def all_zone_devices(self) -> list[MusicCastZoneDevice]:
645 """Return all zone devices."""
646 result = []
647 for physical_device in self.physical_devices:
648 result.extend(list(physical_device.zone_devices.values()))
649 return result
650
651 @property
652 def all_server_devices(self) -> list[MusicCastZoneDevice]:
653 """Return server devices."""
654 return [x for x in self.all_zone_devices if x.is_server]
655