music-assistant-server

20.6 KBPY
musiccast.py
20.6 KB602 lines • python
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