|
8 | 8 | from urllib.parse import urlparse |
9 | 9 |
|
10 | 10 | import aiohttp |
| 11 | +from async_upnp_client import UpnpFactory |
| 12 | +from async_upnp_client.aiohttp import AiohttpRequester |
| 13 | +from async_upnp_client.profiles.dlna import DmrDevice |
11 | 14 |
|
12 | | -from songpal.common import SongpalException |
| 15 | +from didl_lite import didl_lite |
| 16 | +from songpal.common import ProtocolType, SongpalException |
13 | 17 | from songpal.containers import ( |
14 | 18 | Content, |
15 | 19 | ContentInfo, |
|
28 | 32 | Volume, |
29 | 33 | Zone, |
30 | 34 | ) |
| 35 | +from songpal.discovery import Discover |
31 | 36 | from songpal.notification import ConnectChange, Notification |
32 | 37 | from songpal.service import Service |
33 | 38 |
|
@@ -67,6 +72,10 @@ def __init__(self, endpoint, force_protocol=None, debug=0): |
67 | 72 |
|
68 | 73 | self.callbacks = defaultdict(set) |
69 | 74 |
|
| 75 | + self._upnp_discovery = None |
| 76 | + self._upnp_device = None |
| 77 | + self._upnp_renderer = None |
| 78 | + |
70 | 79 | async def __aenter__(self): |
71 | 80 | """Asynchronous context manager, initializes the list of available methods.""" |
72 | 81 | await self.get_supported_methods() |
@@ -130,31 +139,68 @@ async def get_supported_methods(self): |
130 | 139 | Calling this as the first thing before doing anything else is |
131 | 140 | necessary to fill the available services table. |
132 | 141 | """ |
133 | | - response = await self.request_supported_methods() |
| 142 | + try: |
| 143 | + response = await self.request_supported_methods() |
134 | 144 |
|
135 | | - if "result" in response: |
136 | | - services = response["result"][0] |
137 | | - _LOGGER.debug("Got %s services!" % len(services)) |
| 145 | + if "result" in response: |
| 146 | + services = response["result"][0] |
| 147 | + _LOGGER.debug("Got %s services!" % len(services)) |
138 | 148 |
|
139 | | - for x in services: |
140 | | - serv = await Service.from_payload( |
141 | | - x, self.endpoint, self.idgen, self.debug, self.force_protocol |
142 | | - ) |
143 | | - if serv is not None: |
144 | | - self.services[x["service"]] = serv |
145 | | - else: |
146 | | - _LOGGER.warning("Unable to create service %s", x["service"]) |
| 149 | + for x in services: |
| 150 | + serv = await Service.from_payload( |
| 151 | + x, self.endpoint, self.idgen, self.debug, self.force_protocol |
| 152 | + ) |
| 153 | + if serv is not None: |
| 154 | + self.services[x["service"]] = serv |
| 155 | + else: |
| 156 | + _LOGGER.warning("Unable to create service %s", x["service"]) |
147 | 157 |
|
148 | | - for service in self.services.values(): |
149 | | - if self.debug > 1: |
150 | | - _LOGGER.debug("Service %s", service) |
151 | | - for api in service.methods: |
152 | | - # self.logger.debug("%s > %s" % (service, api)) |
| 158 | + for service in self.services.values(): |
153 | 159 | if self.debug > 1: |
154 | | - _LOGGER.debug("> %s" % api) |
| 160 | + _LOGGER.debug("Service %s", service) |
| 161 | + for api in service.methods: |
| 162 | + # self.logger.debug("%s > %s" % (service, api)) |
| 163 | + if self.debug > 1: |
| 164 | + _LOGGER.debug("> %s" % api) |
| 165 | + return self.services |
| 166 | + |
| 167 | + return None |
| 168 | + except SongpalException as e: |
| 169 | + found_services = None |
| 170 | + if e.code == 12 and e.error_message == "getSupportedApiInfo": |
| 171 | + found_services = await self._get_supported_methods_upnp() |
| 172 | + |
| 173 | + if found_services: |
| 174 | + return found_services |
| 175 | + else: |
| 176 | + raise e |
| 177 | + |
| 178 | + async def _get_supported_methods_upnp(self): |
| 179 | + if self._upnp_discovery: |
155 | 180 | return self.services |
156 | 181 |
|
157 | | - return None |
| 182 | + host = urlparse(self.endpoint).hostname |
| 183 | + |
| 184 | + async def find_device(device): |
| 185 | + if host == urlparse(device.endpoint).hostname: |
| 186 | + self._upnp_discovery = device |
| 187 | + |
| 188 | + await Discover.discover(1, self.debug, callback=find_device) |
| 189 | + |
| 190 | + if self._upnp_discovery is None: |
| 191 | + return None |
| 192 | + |
| 193 | + for service_name in self._upnp_discovery.services: |
| 194 | + service = Service( |
| 195 | + service_name, |
| 196 | + self.endpoint + "/" + service_name, |
| 197 | + ProtocolType.XHRPost, |
| 198 | + self.idgen, |
| 199 | + ) |
| 200 | + await service.fetch_methods(self.debug) |
| 201 | + self.services[service_name] = service |
| 202 | + |
| 203 | + return self.services |
158 | 204 |
|
159 | 205 | async def get_power(self) -> Power: |
160 | 206 | """Get the device state.""" |
@@ -264,11 +310,110 @@ async def activate_system_update(self) -> None: |
264 | 310 |
|
265 | 311 | async def get_inputs(self) -> List[Input]: |
266 | 312 | """Return list of available outputs.""" |
267 | | - res = await self.services["avContent"]["getCurrentExternalTerminalsStatus"]() |
| 313 | + if "avContent" in self.services: |
| 314 | + res = await self.services["avContent"][ |
| 315 | + "getCurrentExternalTerminalsStatus" |
| 316 | + ]() |
| 317 | + return [ |
| 318 | + Input.make(services=self.services, **x) |
| 319 | + for x in res |
| 320 | + if "meta:zone:output" not in x["meta"] |
| 321 | + ] |
| 322 | + else: |
| 323 | + if self._upnp_discovery is None: |
| 324 | + raise SongpalException( |
| 325 | + "avContent service not available and UPnP fallback failed" |
| 326 | + ) |
| 327 | + |
| 328 | + return await self._get_inputs_upnp() |
| 329 | + |
| 330 | + async def _get_upnp_services(self): |
| 331 | + requester = AiohttpRequester() |
| 332 | + factory = UpnpFactory(requester) |
| 333 | + |
| 334 | + if self._upnp_device is None: |
| 335 | + self._upnp_device = await factory.async_create_device( |
| 336 | + self._upnp_discovery.upnp_location |
| 337 | + ) |
| 338 | + |
| 339 | + if self._upnp_renderer is None: |
| 340 | + media_renderers = await DmrDevice.async_search(timeout=1) |
| 341 | + host = urlparse(self.endpoint).hostname |
| 342 | + media_renderer_location = next( |
| 343 | + ( |
| 344 | + r["location"] |
| 345 | + for r in media_renderers |
| 346 | + if urlparse(r["location"]).hostname == host |
| 347 | + ), |
| 348 | + None, |
| 349 | + ) |
| 350 | + if media_renderer_location is None: |
| 351 | + raise SongpalException("Could not find UPnP media renderer") |
| 352 | + |
| 353 | + self._upnp_renderer = await factory.async_create_device( |
| 354 | + media_renderer_location |
| 355 | + ) |
| 356 | + |
| 357 | + async def _get_inputs_upnp(self): |
| 358 | + await self._get_upnp_services() |
| 359 | + |
| 360 | + content_directory = self._upnp_device.service( |
| 361 | + next( |
| 362 | + s for s in self._upnp_discovery.upnp_services if "ContentDirectory" in s |
| 363 | + ) |
| 364 | + ) |
| 365 | + |
| 366 | + browse = content_directory.action("Browse") |
| 367 | + filter = ( |
| 368 | + "av:BIVL,av:liveType,av:containerClass,dc:title,dc:date," |
| 369 | + "res,res@duration,res@resolution,upnp:albumArtURI," |
| 370 | + "upnp:albumArtURI@dlna:profileID,upnp:artist,upnp:album,upnp:genre" |
| 371 | + ) |
| 372 | + result = await browse.async_call( |
| 373 | + ObjectID="0", |
| 374 | + BrowseFlag="BrowseDirectChildren", |
| 375 | + Filter=filter, |
| 376 | + StartingIndex=0, |
| 377 | + RequestedCount=25, |
| 378 | + SortCriteria="", |
| 379 | + ) |
| 380 | + |
| 381 | + root_items = didl_lite.from_xml_string(result["Result"]) |
| 382 | + input_item = next( |
| 383 | + ( |
| 384 | + i |
| 385 | + for i in root_items |
| 386 | + if isinstance(i, didl_lite.Container) and i.title == "Input" |
| 387 | + ), |
| 388 | + None, |
| 389 | + ) |
| 390 | + |
| 391 | + result = await browse.async_call( |
| 392 | + ObjectID=input_item.id, |
| 393 | + BrowseFlag="BrowseDirectChildren", |
| 394 | + Filter=filter, |
| 395 | + StartingIndex=0, |
| 396 | + RequestedCount=25, |
| 397 | + SortCriteria="", |
| 398 | + ) |
| 399 | + |
| 400 | + av_transport = self._upnp_renderer.service( |
| 401 | + next(s for s in self._upnp_renderer.services if "AVTransport" in s) |
| 402 | + ) |
| 403 | + |
| 404 | + media_info = await av_transport.action("GetMediaInfo").async_call(InstanceID=0) |
| 405 | + current_uri = media_info.get("CurrentURI") |
| 406 | + |
| 407 | + inputs = didl_lite.from_xml_string(result["Result"]) |
268 | 408 | return [ |
269 | | - Input.make(services=self.services, **x) |
270 | | - for x in res |
271 | | - if "meta:zone:output" not in x["meta"] |
| 409 | + Input.make( |
| 410 | + title=i.title, |
| 411 | + uri=i.resources[0].uri, |
| 412 | + active="active" if i.resources[0].uri in current_uri else "", |
| 413 | + avTransport=av_transport, |
| 414 | + uriMetadata=didl_lite.to_xml_string(i).decode("utf-8"), |
| 415 | + ) |
| 416 | + for i in inputs |
272 | 417 | ] |
273 | 418 |
|
274 | 419 | async def get_zones(self) -> List[Zone]: |
@@ -386,13 +531,45 @@ async def get_contents(self, uri) -> List[Content]: |
386 | 531 |
|
387 | 532 | async def get_volume_information(self) -> List[Volume]: |
388 | 533 | """Get the volume information.""" |
389 | | - res = await self.services["audio"]["getVolumeInformation"]({}) |
390 | | - volume_info = [Volume.make(services=self.services, **x) for x in res] |
391 | | - if len(volume_info) < 1: |
392 | | - logging.warning("Unable to get volume information") |
393 | | - elif len(volume_info) > 1: |
394 | | - logging.debug("The device seems to have more than one volume setting.") |
395 | | - return volume_info |
| 534 | + if "audio" in self.services and self.services["audio"].has_method( |
| 535 | + "getVolumeInformation" |
| 536 | + ): |
| 537 | + res = await self.services["audio"]["getVolumeInformation"]({}) |
| 538 | + volume_info = [Volume.make(services=self.services, **x) for x in res] |
| 539 | + if len(volume_info) < 1: |
| 540 | + logging.warning("Unable to get volume information") |
| 541 | + elif len(volume_info) > 1: |
| 542 | + logging.debug("The device seems to have more than one volume setting.") |
| 543 | + return volume_info |
| 544 | + else: |
| 545 | + return await self._get_volume_information_upnp() |
| 546 | + |
| 547 | + async def _get_volume_information_upnp(self): |
| 548 | + await self._get_upnp_services() |
| 549 | + |
| 550 | + rendering_control_service = self._upnp_renderer.service( |
| 551 | + next(s for s in self._upnp_renderer.services if "RenderingControl" in s) |
| 552 | + ) |
| 553 | + volume_result = await rendering_control_service.action("GetVolume").async_call( |
| 554 | + InstanceID=0, Channel="Master" |
| 555 | + ) |
| 556 | + mute_result = await rendering_control_service.action("GetMute").async_call( |
| 557 | + InstanceID=0, Channel="Master" |
| 558 | + ) |
| 559 | + |
| 560 | + min_volume = rendering_control_service.state_variables["Volume"].min_value |
| 561 | + max_volume = rendering_control_service.state_variables["Volume"].max_value |
| 562 | + |
| 563 | + return [ |
| 564 | + Volume.make( |
| 565 | + volume=volume_result["CurrentVolume"], |
| 566 | + mute=mute_result["CurrentMute"], |
| 567 | + minVolume=min_volume, |
| 568 | + maxVolume=max_volume, |
| 569 | + step=1, |
| 570 | + renderingControl=rendering_control_service, |
| 571 | + ) |
| 572 | + ] |
396 | 573 |
|
397 | 574 | async def get_sound_settings(self, target="") -> List[Setting]: |
398 | 575 | """Get the current sound settings. |
|
0 commit comments