music-assistant-server

9.4 KBPY
provider.py
9.4 KB244 lines • python
1"""MusicCast Provider."""
2
3import asyncio
4import logging
5from dataclasses import dataclass
6
7from aiohttp.client_exceptions import ClientError
8from aiomusiccast.musiccast_device import MusicCastDevice
9from music_assistant_models.config_entries import ProviderConfig
10from music_assistant_models.enums import ProviderFeature
11from music_assistant_models.provider import ProviderManifest
12from zeroconf import ServiceStateChange
13from zeroconf.asyncio import AsyncServiceInfo
14
15from music_assistant.constants import VERBOSE_LOG_LEVEL
16from music_assistant.mass import MusicAssistant
17from music_assistant.models.player_provider import PlayerProvider
18from music_assistant.providers.musiccast.constants import (
19    MC_DEVICE_INFO_ENDPOINT,
20    MC_DEVICE_UPNP_ENDPOINT,
21    MC_DEVICE_UPNP_PORT,
22    PLAYER_ZONE_SPLITTER,
23)
24from music_assistant.providers.sonos.helpers import get_primary_ip_address
25
26from .musiccast import MusicCastController, MusicCastPhysicalDevice, MusicCastZoneDevice
27from .player import MusicCastPlayer, UpnpUpdateHelper
28
29
30@dataclass(kw_only=True)
31class MusicCastPlayerHelper:
32    """MusicCastPlayerHelper.
33
34    Helper class to store MA player alongside physical device.
35    """
36
37    device_id: str  # device_id without ZONE_SPLITTER zone
38    player_main: MusicCastPlayer | None = None  # mass player
39    player_zone2: MusicCastPlayer | None = None  # mass player
40    # I can only test up to zone 2
41    player_zone3: MusicCastPlayer | None = None  # mass player
42    player_zone4: MusicCastPlayer | None = None  # mass player
43
44    # log allowed sources for a device with multiple sources once. see "_handle_zone_grouping"
45    _log_allowed_sources: bool = True
46
47    physical_device: MusicCastPhysicalDevice
48
49    def get_player(self, zone: str) -> MusicCastPlayer | None:
50        """Get Player by zone name."""
51        match zone:
52            case "main":
53                return self.player_main
54            case "zone2":
55                return self.player_zone2
56            case "zone3":
57                return self.player_zone3
58            case "zone4":
59                return self.player_zone4
60        raise RuntimeError(f"Zone {zone} is unknown.")
61
62    def get_all_players(self) -> list[MusicCastPlayer]:
63        """Get all players."""
64        assert self.player_main is not None  # we always have main
65        players = [self.player_main]
66        if self.player_zone2 is not None:
67            players.append(self.player_zone2)
68        if self.player_zone3 is not None:
69            players.append(self.player_zone3)
70        if self.player_zone4 is not None:
71            players.append(self.player_zone4)
72        return players
73
74
75class MusicCastProvider(PlayerProvider):
76    """MusicCast Player Provider."""
77
78    # poll upnp playback information, but not too often. see "_update_player_attributes"
79    # player_id: UpnpUpdateHelper
80    upnp_update_helper: dict[str, UpnpUpdateHelper] = {}
81
82    # str here is the device id, NOT the player_id
83    update_player_locks: dict[str, asyncio.Lock] = {}
84
85    def __init__(
86        self,
87        mass: MusicAssistant,
88        manifest: ProviderManifest,
89        config: ProviderConfig,
90        supported_features: set[ProviderFeature],
91    ) -> None:
92        """Init."""
93        super().__init__(mass, manifest, config, supported_features)
94        # str is device_id here:
95        self.musiccast_player_helpers: dict[str, MusicCastPlayerHelper] = {}
96
97    async def unload(self, is_removed: bool = False) -> None:
98        """Call on unload."""
99        for mc_player in self.mass.players.all_players(provider_filter=self.instance_id):
100            assert isinstance(mc_player, MusicCastPlayer)  # for type checking
101            mc_player.physical_device.remove()
102
103    async def handle_async_init(self) -> None:
104        """Async init."""
105        self.mc_controller = MusicCastController(logger=self.logger)
106        # aiomusiccast logs all fetch requests after udp message as debug.
107        # same approach as in upnp
108        if self.logger.isEnabledFor(VERBOSE_LOG_LEVEL):
109            logging.getLogger("aiomusiccast").setLevel(logging.DEBUG)
110        else:
111            logging.getLogger("aiomusiccast").setLevel(self.logger.level + 10)
112
113    async def on_mdns_service_state_change(
114        self, name: str, state_change: ServiceStateChange, info: AsyncServiceInfo | None
115    ) -> None:
116        """Discovery via mdns."""
117        if state_change == ServiceStateChange.Removed:
118            # Wait for connection to fail, same as sonos.
119            return
120        if info is None:
121            return
122        device_ip = get_primary_ip_address(info)
123        if device_ip is None:
124            return
125        try:
126            device_info = await self.mass.http_session.get(
127                f"http://{device_ip}/{MC_DEVICE_INFO_ENDPOINT}", raise_for_status=True
128            )
129            device_info_json = await device_info.json()
130        except ClientError:
131            # typical Errors are
132            # ClientResponseError -> raise_for_status
133            # ClientConnectorError -> unable to connect/ not existing/ timeout
134            # ContentTypeError -> device returns something, but is not json
135            # but we can use the base exception class, as we only check
136            # if the device is suitable
137            return
138        device_id = device_info_json.get("device_id")
139        if device_id is None:
140            return
141        description_url = f"http://{device_ip}:{MC_DEVICE_UPNP_PORT}/{MC_DEVICE_UPNP_ENDPOINT}"
142
143        _check = await self.mass.http_session.get(description_url)
144        if _check.status == 404:
145            self.logger.debug("Missing description url for Yamaha device at %s", device_ip)
146            return
147        await self._device_discovered(
148            device_id=device_id, device_ip=device_ip, description_url=description_url
149        )
150
151    async def _device_discovered(
152        self, device_id: str, device_ip: str, description_url: str
153    ) -> None:
154        """Handle discovered MusicCast player."""
155        # verify that this is a MusicCast player
156        check: bool = await MusicCastDevice.check_yamaha_ssdp(
157            description_url, self.mass.http_session
158        )
159        if not check:
160            return
161
162        if self.mass.players.get_player(device_id) is not None:
163            return
164        mc_player_known = self.musiccast_player_helpers.get(device_id)
165        if mc_player_known is not None and (
166            mc_player_known.player_main is not None
167            and mc_player_known.physical_device.device.device.upnp_description == description_url
168            and mc_player_known.player_main.available
169        ):
170            # nothing to do, device is already connected
171            return
172        # new or updated player detected
173        physical_device = MusicCastPhysicalDevice(
174            device=MusicCastDevice(
175                client=self.mass.http_session,
176                ip=device_ip,
177                upnp_description=description_url,
178            ),
179            controller=self.mc_controller,
180        )
181        self.update_player_locks[device_id] = asyncio.Lock()
182        success = await physical_device.async_init()  # fetch + polling
183        if not success:
184            self.logger.debug(
185                "Had trouble setting up device at %s. Will be retried on next discovery.",
186                device_ip,
187            )
188            return
189        await self._register_player(physical_device, device_id)
190
191    async def _register_player(
192        self, physical_device: MusicCastPhysicalDevice, device_id: str
193    ) -> None:
194        """Register player including zones."""
195
196        # player features
197        # NOTE: There is seek in the upnp desc
198        # http://{ip}:49154/AVTransport/desc.xml
199        # however, it appears not to work as it should, so we remain at MA's own
200        # seek implementation
201        def get_player(zone_name: str, zone_device: MusicCastZoneDevice) -> MusicCastPlayer:
202            return MusicCastPlayer(
203                provider=self,
204                player_id=f"{device_id}{PLAYER_ZONE_SPLITTER}{zone_name}",
205                physical_device=physical_device,
206                zone_device=zone_device,
207            )
208
209        main_device = physical_device.zone_devices.get("main")
210        if (
211            main_device is None
212            or main_device.zone_data is None
213            or main_device.zone_data.name is None
214        ):
215            return
216
217        musiccast_player_helper = MusicCastPlayerHelper(
218            device_id=device_id,
219            physical_device=physical_device,
220        )
221
222        for zone_name, zone_device in physical_device.zone_devices.items():
223            if zone_device.zone_data is None or zone_device.zone_data.name is None:
224                continue
225            player = get_player(zone_name, zone_device=zone_device)
226            await player.setup()
227            await self.mass.players.register_or_update(player)
228            physical_device.register_callback(player._non_async_udp_callback)
229            setattr(musiccast_player_helper, f"player_{zone_device.zone_name}", player)
230
231        if (
232            musiccast_player_helper.player_zone2 is not None
233            and musiccast_player_helper._log_allowed_sources
234        ):
235            musiccast_player_helper._log_allowed_sources = False
236            player_main = musiccast_player_helper.player_main
237            assert player_main is not None
238            self.logger.info(
239                f"The player {player_main.display_name or player_main.name} has multiple zones. "
240                "Please use the player config to configure a non-net source for grouping. "
241            )
242
243        self.musiccast_player_helpers[device_id] = musiccast_player_helper
244