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