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