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