/
/
/
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 """Return the state of the player."""
192 assert self.zone_data is not None
193 if self.zone_data.power == "on":
194 if self.is_netusb and self.device.data.netusb_playback == "pause":
195 return MusicCastPlayerState.PAUSED
196 if self.is_netusb and self.device.data.netusb_playback == "stop":
197 return MusicCastPlayerState.IDLE
198 return MusicCastPlayerState.PLAYING
199 return MusicCastPlayerState.OFF
200
201 @property
202 def is_server(self) -> bool:
203 """
204 Return whether the media player is the server/host of the group.
205
206 If the media player is not part of a group, False is returned.
207 """
208 return self.is_network_server or (
209 self.zone_name == MC_DEFAULT_ZONE
210 and len(
211 [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MAIN_SYNC]
212 )
213 > 0
214 )
215
216 @property
217 def is_network_client(self) -> bool:
218 """Return True if the current entity is a network client and not just a main sync entity."""
219 return (
220 self.device.data.group_role == "client"
221 and self.device.data.group_id != MC_NULL_GROUP
222 and self.source_id == MC_SOURCE_MC_LINK
223 )
224
225 @property
226 def is_client(self) -> bool:
227 """
228 Return whether the media player is the client of a group.
229
230 If the media player is not part of a group, False is returned.
231 """
232 return self.is_network_client or self.source_id == MC_SOURCE_MAIN_SYNC
233
234 @property
235 def musiccast_zone_entity(self) -> MusicCastZoneDevice:
236 """
237 Return the musiccast entity of the physical device.
238
239 It is possible that multiple zones use MusicCast as client at the same time.
240 In this case the first one is returned.
241 """
242 for entity in self.other_zones:
243 if entity.is_network_server or entity.is_network_client:
244 return entity
245
246 return self
247
248 @property
249 def musiccast_group(self) -> list[MusicCastZoneDevice]:
250 """Return all media players of the current group, if the media player is server."""
251 if self.is_client:
252 # If we are a client we can still share group information, but we will take them from
253 # the server.
254 if (server := self.group_server) != self:
255 return server.musiccast_group
256
257 return [self]
258 if not self.is_server:
259 return [self]
260 entities = self.controller.all_zone_devices
261 clients = [entity for entity in entities if entity.is_part_of_group(self)]
262 return [self, *clients]
263
264 @property
265 def group_server(self) -> MusicCastZoneDevice:
266 """Return the server of the own group if present, self else."""
267 for entity in self.controller.all_server_devices:
268 if self.is_part_of_group(entity):
269 return entity
270 return self
271
272 @property
273 def media_title(self) -> str | None:
274 """Return the title of current playing media."""
275 if self.is_netusb:
276 return cast("str", self.device.data.netusb_track)
277 if self.is_tuner:
278 return cast("str", self.device.tuner_media_title)
279
280 return None
281
282 @property
283 def media_image_url(self) -> str | None:
284 """Return the image url of current playing media."""
285 if self.is_client and self.group_server != self:
286 return cast("str", self.group_server.device.media_image_url)
287 return cast("str", self.device.media_image_url) if self.is_netusb else None
288
289 @property
290 def media_artist(self) -> str | None:
291 """Return the artist of current playing media (Music track only)."""
292 if self.is_netusb:
293 return cast("str", self.device.data.netusb_artist)
294 if self.is_tuner:
295 return cast("str", self.device.tuner_media_artist)
296
297 return None
298
299 @property
300 def media_album_name(self) -> str | None:
301 """Return the album of current playing media (Music track only)."""
302 return cast("str", self.device.data.netusb_album) if self.is_netusb else None
303
304 async def turn_on(self) -> None:
305 """Turn on."""
306 await self.device.turn_on(self.zone_name)
307
308 async def turn_off(self) -> None:
309 """Turn off."""
310 await self.device.turn_off(self.zone_name)
311
312 async def volume_mute(self, mute: bool) -> None:
313 """Volume mute."""
314 await self.device.mute_volume(self.zone_name, mute)
315
316 async def volume_set(self, volume_level: int) -> None:
317 """Volume set."""
318 await self.device.set_volume_level(self.zone_name, volume_level / 100)
319
320 async def play(self) -> None:
321 """Play."""
322 if self.is_netusb:
323 await self.device.netusb_play()
324
325 async def pause(self) -> None:
326 """Pause."""
327 if self.is_netusb:
328 await self.device.netusb_pause()
329
330 async def stop(self) -> None:
331 """Stop."""
332 if self.is_netusb:
333 await self.device.netusb_stop()
334
335 async def previous_track(self) -> None:
336 """Send previous track command."""
337 if self.is_netusb:
338 await self.device.netusb_previous_track()
339 elif self.is_tuner:
340 await self.device.tuner_previous_station()
341
342 async def next_track(self) -> None:
343 """Send next track command."""
344 if self.is_netusb:
345 await self.device.netusb_next_track()
346 elif self.is_tuner:
347 await self.device.tuner_next_station()
348
349 async def play_url(self, url: str) -> None:
350 """Play http url."""
351 await self.device.play_url_media(self.zone_name, media_url=url, title=MC_PLAY_TITLE)
352
353 async def select_source(self, source_id: str, mode: str = "") -> None:
354 """
355 Select input source. Internal source name.
356
357 :param source_id: Internal MusicCast source name.
358 :param mode: Optional MusicCast source mode, e.g. "autoplay_disabled".
359 """
360 await self.device.select_source(self.zone_name, source_id, mode)
361
362 async def select_sound_mode(self, sound_mode_id: str) -> None:
363 """Select sound mode. Internal sound_mode name."""
364 await self.device.select_sound_mode(self.zone_name, sound_mode_id)
365
366 def is_part_of_group(self, group_server: MusicCastZoneDevice) -> bool:
367 """Return True if the given server is the server of self's group."""
368 return group_server != self and (
369 (
370 self.device.ip in group_server.device.data.group_client_list
371 and self.device.data.group_id == group_server.device.data.group_id
372 and self.device.ip != group_server.device.ip
373 and self.source_id == MC_SOURCE_MC_LINK
374 )
375 or (self.device.ip == group_server.device.ip and self.source_id == MC_SOURCE_MAIN_SYNC)
376 )
377
378 async def join_players(self, group_members: list[MusicCastZoneDevice]) -> None:
379 """
380 Add all clients given in entities to the group of the server.
381
382 Creates a new group if necessary. Used for join service.
383 """
384 assert self.zone_data is not None
385 if self.state == MusicCastPlayerState.OFF:
386 await self.turn_on()
387
388 if not self.is_server and self.musiccast_zone_entity.is_server:
389 # The MusicCast Distribution Module of this device is already in use. To use it as a
390 # server, we first have to unjoin and wait until the servers are updated.
391 await self.musiccast_zone_entity._server_close_group()
392 elif self.musiccast_zone_entity.is_client:
393 await self._client_leave_group(True)
394 # Use existing group id if we are server, generate a new one else.
395 group_id = self.device.data.group_id if self.is_server else random_uuid_hex().upper()
396 assert group_id is not None # for type checking
397
398 ip_addresses = set()
399 # First let the clients join
400 for client in group_members:
401 if client != self:
402 try:
403 network_join = await client._client_join(group_id, self)
404 except MusicCastGroupException:
405 network_join = await client._client_join(group_id, self)
406
407 if network_join:
408 ip_addresses.add(client.device.ip)
409
410 if ip_addresses:
411 await self.device.mc_server_group_extend(
412 self.zone_name,
413 list(ip_addresses),
414 group_id,
415 self.controller.distribution_num,
416 )
417
418 await self._group_update()
419
420 async def unjoin_player(self) -> None:
421 """
422 Leave the group.
423
424 Stops the distribution if device is server. Used for unjoin service.
425 """
426 if self.is_server:
427 await self._server_close_group()
428 else:
429 # this is not as in HA
430 await self._client_leave_group(True)
431
432 # Internal client functions
433
434 async def _client_join(self, group_id: str, server: MusicCastZoneDevice) -> bool:
435 """
436 Let the client join a group.
437
438 If this client is a server, the server will stop distributing.
439 If the client is part of a different group,
440 it will leave that group first. Returns True, if the server has to
441 add the client on his side.
442 """
443 # If we should join the group, which is served by the main zone,
444 # we can simply select main_sync as input.
445 if self.state == MusicCastPlayerState.OFF:
446 await self.turn_on()
447 if self.device.ip == server.device.ip:
448 if server.zone_name == MC_DEFAULT_ZONE:
449 await self.select_source(MC_SOURCE_MAIN_SYNC)
450 return False
451
452 # It is not possible to join a group hosted by zone2 from main zone.
453 # raise?
454 return False
455
456 if self.musiccast_zone_entity.is_server:
457 # If one of the zones of the device is a server, we need to unjoin first.
458 await self.musiccast_zone_entity._server_close_group()
459
460 elif self.is_client:
461 if self.is_part_of_group(server):
462 return False
463
464 await self._client_leave_group()
465
466 elif (
467 self.device.ip in server.device.data.group_client_list
468 and self.device.data.group_id == server.device.data.group_id
469 and self.device.data.group_role == "client"
470 ):
471 # The device is already part of this group (e.g. main zone is also a client of this
472 # group).
473 # Just select mc_link as source
474 await self.device.zone_join(self.zone_name)
475 return False
476
477 await self.device.mc_client_join(server.device.ip, group_id, self.zone_name)
478 return True
479
480 async def _client_leave_group(self, force: bool = False) -> None:
481 """
482 Make self leave the group.
483
484 Should only be called for clients.
485 """
486 if not force and (
487 self.source_id == MC_SOURCE_MAIN_SYNC
488 or [entity for entity in self.other_zones if entity.source_id == MC_SOURCE_MC_LINK]
489 ):
490 await self.device.zone_unjoin(self.zone_name)
491 else:
492 servers = [
493 server
494 for server in self.controller.all_server_devices
495 if server.device.data.group_id == self.device.data.group_id
496 ]
497 await self.device.mc_client_unjoin()
498 if servers:
499 await servers[0].device.mc_server_group_reduce(
500 servers[0].zone_name,
501 [self.device.ip],
502 self.controller.distribution_num,
503 )
504
505 # Internal server functions
506
507 async def _server_close_group(self) -> None:
508 """
509 Close group of self.
510
511 Should only be called for servers.
512 """
513 for client in self.musiccast_group:
514 if client != self:
515 await client._client_leave_group()
516 await self.device.mc_server_group_close()
517
518 async def _check_client_list(self) -> None:
519 """Let the server check if all its clients are still part of his group."""
520 if not self.is_server or self.device.data.group_update_lock.locked():
521 return
522
523 client_ips_for_removal = [
524 expected_client_ip
525 for expected_client_ip in self.device.data.group_client_list
526 # The client is no longer part of the group. Prepare removal.
527 if expected_client_ip not in [entity.device.ip for entity in self.musiccast_group]
528 ]
529
530 if client_ips_for_removal:
531 await self.device.mc_server_group_reduce(
532 self.zone_name, client_ips_for_removal, self.controller.distribution_num
533 )
534 if len(self.musiccast_group) < 2:
535 # The group is empty, stop distribution.
536 await self._server_close_group()
537
538
539class MusicCastPhysicalDevice:
540 """
541 Physical MusicCast device.
542
543 May contain multiple zone devices, but at least one, main.
544 """
545
546 def __init__(
547 self,
548 device: MusicCastDevice,
549 controller: MusicCastController,
550 ):
551 """Init."""
552 self.device = device
553 self.zone_devices: dict[str, MusicCastZoneDevice] = {} # zone_name: device
554 self.controller = controller
555 self.controller.physical_devices.append(self)
556
557 async def async_init(self) -> bool:
558 """
559 Async init.
560
561 Returns true if initial fetch was successful.
562 """
563 try:
564 await self.fetch()
565 except MusicCastConnectionException, MusicCastGroupException:
566 return False
567
568 self.device.build_capabilities()
569
570 # enable udp polling
571 await self.enable_polling()
572
573 for zone_name in self.device.data.zones:
574 self.zone_devices[zone_name] = MusicCastZoneDevice(zone_name, self)
575
576 return True
577
578 async def enable_polling(self) -> None:
579 """Enable udp polling."""
580 await self.device.device.enable_polling()
581
582 def disable_polling(self) -> None:
583 """Disable udp polling."""
584 with suppress(AttributeError):
585 # aiomusiccast raises if polling was never enabled or was already disabled
586 self.device.device.disable_polling()
587
588 async def fetch(self) -> None:
589 """
590 Fetch device information.
591
592 Should be called regularly, e.g. every 60s, in case some udp info
593 goes missing.
594 """
595 await self.device.fetch()
596
597 def register_callback(self, fun: Callable[[MusicCastPhysicalDevice], None]) -> None:
598 """Register a non-async callback."""
599
600 def _cb() -> None:
601 fun(self)
602
603 self.device.register_callback(_cb)
604
605 def register_group_update_callback(self, fun: Callable[[], Awaitable[None]]) -> None:
606 """Register an async group update callback."""
607 self.device.register_group_update_callback(fun)
608
609 def remove(self) -> None:
610 """Remove physical device."""
611 self.disable_polling()
612 with suppress(ValueError):
613 # might already be removed from controller
614 self.controller.physical_devices.remove(self)
615
616
617class MusicCastController:
618 """
619 MusicCastController.
620
621 Holds information of full known MC network.
622 """
623
624 def __init__(self, logger: logging.Logger) -> None:
625 """Init."""
626 self.physical_devices: list[MusicCastPhysicalDevice] = []
627 self.logger = logger
628
629 @property
630 def distribution_num(self) -> int:
631 """Return the distribution_num (number of clients in the whole musiccast system)."""
632 return sum(len(x.zone_devices) for x in self.physical_devices)
633
634 @property
635 def all_zone_devices(self) -> list[MusicCastZoneDevice]:
636 """Return all zone devices."""
637 result = []
638 for physical_device in self.physical_devices:
639 result.extend(list(physical_device.zone_devices.values()))
640 return result
641
642 @property
643 def all_server_devices(self) -> list[MusicCastZoneDevice]:
644 """Return server devices."""
645 return [x for x in self.all_zone_devices if x.is_server]
646