/
/
/
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