diff --git a/songpal/containers.py b/songpal/containers.py index ff0f455..1d60c16 100644 --- a/songpal/containers.py +++ b/songpal/containers.py @@ -1,10 +1,12 @@ """Data containers for Songpal.""" import logging from datetime import timedelta -from typing import List, Optional +from typing import List, Optional, Union import attr +from songpal import SongpalException + _LOGGER = logging.getLogger(__name__) @@ -271,7 +273,6 @@ class Volume: make = classmethod(make) - services = attr.ib(repr=False) maxVolume = attr.ib() minVolume = attr.ib() mute = attr.ib() @@ -282,7 +283,7 @@ class Volume: @property def is_muted(self): """Return True if volume is muted.""" - return self.mute == "on" + return self.mute == "on" or self.mute is True def __str__(self): if self.output and self.output.rfind("=") > 0: @@ -299,6 +300,22 @@ def __str__(self): async def set_mute(self, activate: bool): """Set mute on/off.""" + raise NotImplementedError + + async def toggle_mute(self): + """Toggle mute.""" + raise NotImplementedError + + async def set_volume(self, volume: Union[str, int]): + """Set volume level.""" + raise NotImplementedError + + +@attr.s +class VolumeControlSongpal(Volume): + services = attr.ib(repr=False) + + async def set_mute(self, activate: bool): enabled = "off" if activate: enabled = "on" @@ -308,18 +325,51 @@ async def set_mute(self, activate: bool): ) async def toggle_mute(self): - """Toggle mute.""" return await self.services["audio"]["setAudioMute"]( mute="toggle", output=self.output ) - async def set_volume(self, volume: int): - """Set volume level.""" + async def set_volume(self, volume: Union[str, int]): return await self.services["audio"]["setAudioVolume"]( volume=str(volume), output=self.output ) +@attr.s +class VolumeControlUpnp(Volume): + + renderingControl = attr.ib(default=None) + + async def set_mute(self, activate: bool): + """Set mute on/off.""" + + return await self.renderingControl.action("SetMute").async_call( + InstanceID=0, Channel="Master", DesiredMute=activate + ) + + async def toggle_mute(self): + mute_result = await self.renderingControl.action("GetMute").async_call( + InstanceID=0, Channel="Master" + ) + return self.set_mute(not mute_result["CurrentMute"]) + + async def set_volume(self, volume: Union[str, int]): + if isinstance(volume, str): + if "+" in volume or "-" in volume: + raise SongpalException( + "Setting relative volume not supported with UPnP" + ) + desired_volume = int(volume) + elif isinstance(volume, int): + desired_volume = volume + else: + raise SongpalException("Invalid volume %s" % volume) + + return await self.renderingControl.action("SetVolume").async_call( + InstanceID=0, Channel="Master", DesiredVolume=desired_volume + ) + + @attr.s class Power: """Information about power status. @@ -377,12 +427,9 @@ class Input: make = classmethod(make) - meta = attr.ib() - connection = attr.ib() title = attr.ib(converter=convert_title) uri = attr.ib() - services = attr.ib(repr=False) active = attr.ib(converter=convert_is_active) label = attr.ib() iconUrl = attr.ib() @@ -396,12 +443,50 @@ def __str__(self): async def activate(self, output: Zone = None): """Activate this input.""" + raise NotImplementedError + + +@attr.s +class InputControlSongpal(Input): + meta = attr.ib(default=None) + connection = attr.ib(default=None) + services = attr.ib(default=None, repr=False) + + def __str__(self): + s = "%s (uri: %s)" % (self.title, self.uri) + if self.active: + s += " (active)" + return s + + async def activate(self, output: Zone = None): output_uri = output.uri if output else "" return await self.services["avContent"]["setPlayContent"]( uri=self.uri, output=output_uri ) +@attr.s +class InputControlUpnp(Input): + + avTransport = attr.ib(default=None) + uriMetadata = attr.ib(default=None) + + async def activate(self, output: Zone = None): + result = await self.avTransport.action("SetAVTransportURI").async_call( + InstanceID=0, CurrentURI=self.uri, CurrentURIMetaData=self.uriMetadata + ) + + try: + # Attempt to play as the songpal app is doing after changing input, + # sometimes needed so that input emits sound + await self.avTransport.action("Play").async_call(InstanceID=0, Speed="1") + except Exception: + # Play action can cause 500 error in certain cases + pass + + return result + + @attr.s class Storage: """Storage information.""" diff --git a/songpal/device.py b/songpal/device.py index 24d5d88..1b760a3 100644 --- a/songpal/device.py +++ b/songpal/device.py @@ -4,16 +4,23 @@ import logging from collections import defaultdict from pprint import pformat as pf -from typing import Any, Dict, List +from typing import Any, Dict, List, Mapping from urllib.parse import urlparse import aiohttp +from async_upnp_client import UpnpFactory +from async_upnp_client.aiohttp import AiohttpRequester +from async_upnp_client.profiles.dlna import DmrDevice +from async_upnp_client.search import async_search -from songpal.common import SongpalException +from didl_lite import didl_lite +from songpal.common import ProtocolType, SongpalException from songpal.containers import ( Content, ContentInfo, Input, + InputControlSongpal, + InputControlUpnp, InterfaceInfo, PlayInfo, Power, @@ -26,8 +33,11 @@ SupportedFunctions, Sysinfo, Volume, + VolumeControlSongpal, + VolumeControlUpnp, Zone, ) +from songpal.discovery import Discover from songpal.notification import ConnectChange, Notification from songpal.service import Service @@ -266,7 +276,7 @@ async def get_inputs(self) -> List[Input]: """Return list of available outputs.""" res = await self.services["avContent"]["getCurrentExternalTerminalsStatus"]() return [ - Input.make(services=self.services, **x) + InputControlSongpal.make(services=self.services, **x) for x in res if "meta:zone:output" not in x["meta"] ] @@ -387,7 +397,9 @@ async def get_contents(self, uri) -> List[Content]: async def get_volume_information(self) -> List[Volume]: """Get the volume information.""" res = await self.services["audio"]["getVolumeInformation"]({}) - volume_info = [Volume.make(services=self.services, **x) for x in res] + volume_info = [ + VolumeControlSongpal.make(services=self.services, **x) for x in res + ] if len(volume_info) < 1: logging.warning("Unable to get volume information") elif len(volume_info) > 1: @@ -516,3 +528,259 @@ async def raw_command(self, service: str, method: str, params: Any): """ _LOGGER.info("Calling %s.%s(%s)", service, method, params) return await self.services[service][method](params) + + +class UpnpDevice(Device): + def __init__(self, endpoint, force_protocol=None, debug=0): + super().__init__(endpoint, force_protocol=force_protocol, debug=debug) + self._upnp_discovery = None + self._upnp_server = None + self._upnp_renderer = None + + @staticmethod + async def create(endpoint, force_protocol=None, debug=0, timeout=5): + self = UpnpDevice(endpoint, force_protocol=force_protocol, debug=debug) + await self.discover(timeout=timeout) + return self + + async def discover(self, timeout=5): + host = urlparse(self.endpoint).hostname + + sony_device_future = asyncio.Future() + media_renderer_future = asyncio.Future() + + requester = AiohttpRequester() + factory = UpnpFactory(requester) + + search_cancel_event = asyncio.Event() + + async def on_response(data: Mapping[str, str]) -> None: + print(data) + if "st" not in data or "location" not in data: + return + + if urlparse(data["location"]).hostname != host: + return + + if data["st"] == Discover.ST: + device = await Discover.parse_device(data) + if device: + sony_device_future.set_result(device) + + if data["st"] in DmrDevice.DEVICE_TYPES: + media_renderer_future.set_result(data["location"]) + + if sony_device_future.done() and media_renderer_future.done(): + search_cancel_event.set() + + search_finished_future = async_search( + async_callback=on_response, timeout=timeout + ) + data_found_future = asyncio.gather(sony_device_future, media_renderer_future) + await asyncio.wait( + [data_found_future, search_finished_future], + return_when=asyncio.FIRST_COMPLETED, + ) + + if not sony_device_future.done(): + raise SongpalException("Could not find UPnP media server") + + if not media_renderer_future.done(): + raise SongpalException("Could not find UPnP media renderer") + + found_device = sony_device_future.result() + self._upnp_discovery = found_device + self._upnp_server = await factory.async_create_device( + found_device.upnp_location + ) + self._upnp_renderer = await factory.async_create_device( + media_renderer_future.result() + ) + + return self._upnp_server, self._upnp_renderer + + async def get_supported_methods(self): + if self._upnp_discovery is None: + raise SongpalException("Discovery required") + + if len(self.services) > 0: + return self.services + + for service_name in self._upnp_discovery.services: + service = Service( + service_name, + self.endpoint + "/" + service_name, + ProtocolType.XHRPost, + self.idgen, + ) + await service.fetch_methods(self.debug) + self.services[service_name] = service + + return self.services + + async def get_system_info(self) -> Sysinfo: + if "system" in self.services and self.services["system"].has_method( + "getSystemInformation" + ): + return await super().get_system_info() + + if "system" not in self.services or not self.services["system"].has_method( + "getNetworkSettings" + ): + raise SongpalException("getNetworkSettings not supported") + + if self._upnp_discovery is None: + raise SongpalException("Discovery required") + + info = await self.services["system"]["getNetworkSettings"](netif="") + + def get_addr(info, iface): + addr = next((i for i in info if i["netif"] == iface), {}).get("hwAddr") + return addr.lower().replace("-", ":") if addr else addr + + macAddr = get_addr(info, "eth0") + wirelessMacAddr = get_addr(info, "wlan0") + version = self._upnp_discovery.version if self._upnp_discovery else None + return Sysinfo.make( + macAddr=macAddr, wirelessMacAddr=wirelessMacAddr, version=version + ) + + async def get_inputs(self) -> List[Input]: + if "avContent" in self.services and self.services["avContent"].has_method( + "getCurrentExternalTerminalsStatus" + ): + return await super().get_inputs() + + if self._upnp_discovery is None: + raise SongpalException("Discovery required") + + content_directory = self._upnp_server.service( + next( + s for s in self._upnp_discovery.upnp_services if "ContentDirectory" in s + ) + ) + + browse = content_directory.action("Browse") + filter = ( + "av:BIVL,av:liveType,av:containerClass,dc:title,dc:date," + "res,res@duration,res@resolution,upnp:albumArtURI," + "upnp:albumArtURI@dlna:profileID,upnp:artist,upnp:album,upnp:genre" + ) + result = await browse.async_call( + ObjectID="0", + BrowseFlag="BrowseDirectChildren", + Filter=filter, + StartingIndex=0, + RequestedCount=25, + SortCriteria="", + ) + + root_items = didl_lite.from_xml_string(result["Result"]) + input_item = next( + ( + i + for i in root_items + if isinstance(i, didl_lite.Container) and i.title == "Input" + ), + None, + ) + + result = await browse.async_call( + ObjectID=input_item.id, + BrowseFlag="BrowseDirectChildren", + Filter=filter, + StartingIndex=0, + RequestedCount=25, + SortCriteria="", + ) + + av_transport = self._upnp_renderer.service( + next(s for s in self._upnp_renderer.services if "AVTransport" in s) + ) + + media_info = await av_transport.action("GetMediaInfo").async_call(InstanceID=0) + current_uri = media_info.get("CurrentURI") + + inputs = didl_lite.from_xml_string(result["Result"]) + + def is_input_active(input, current_uri): + if not current_uri: + return False + + # when input is switched on device, uri can have file:// format + if current_uri.startswith("file://"): + # UPnP 'Bluetooth AUDIO' can be file://Bluetooth + # UPnP 'AUDIO' can be file://Audio + return current_uri.lower() in "file://" + input.title.lower() + + if current_uri.startswith("local://"): + # current uri can have additional query params, such as zone + return input.resources[0].uri in current_uri + + return [ + InputControlUpnp.make( + title=i.title, + label=i.title, + iconUrl="", + uri=i.resources[0].uri, + active="active" if is_input_active(i, current_uri) else "", + avTransport=av_transport, + uriMetadata=didl_lite.to_xml_string(i).decode("utf-8"), + ) + for i in inputs + ] + + async def get_volume_information(self) -> List[Volume]: + if "audio" in self.services and self.services["audio"].has_method( + "getVolumeInformation" + ): + return await super().get_volume_information() + + if self._upnp_renderer is None: + raise SongpalException("Discovery required") + + rendering_control_service = self._upnp_renderer.service( + next(s for s in self._upnp_renderer.services if "RenderingControl" in s) + ) + volume_result = await rendering_control_service.action("GetVolume").async_call( + InstanceID=0, Channel="Master" + ) + mute_result = await rendering_control_service.action("GetMute").async_call( + InstanceID=0, Channel="Master" + ) + + min_volume = rendering_control_service.state_variables["Volume"].min_value + max_volume = rendering_control_service.state_variables["Volume"].max_value + + return [ + VolumeControlUpnp.make( + volume=volume_result["CurrentVolume"], + mute=mute_result["CurrentMute"], + minVolume=min_volume, + maxVolume=max_volume, + step=1, + output=None, + renderingControl=rendering_control_service, + ) + ] + + +class DeviceFactory: + @staticmethod + async def get(endpoint, force_protocol=None, debug=0, timeout=5): + device = Device(endpoint, force_protocol=force_protocol, debug=debug) + try: + await device.get_supported_methods() + return device + except SongpalException as e: + if e.code == 12 and e.error_message == "getSupportedApiInfo": + device = await UpnpDevice.create( + endpoint, + force_protocol=force_protocol, + debug=debug, + timeout=timeout, + ) + await device.get_supported_methods() + return device + else: + raise e diff --git a/songpal/discovery.py b/songpal/discovery.py index d93f064..8a9b8ad 100644 --- a/songpal/discovery.py +++ b/songpal/discovery.py @@ -2,6 +2,8 @@ from xml import etree import attr +from async_upnp_client import UpnpFactory +from async_upnp_client.aiohttp import AiohttpRequester from async_upnp_client.search import async_search _LOGGER = logging.getLogger(__name__) @@ -20,54 +22,55 @@ class DiscoveredDevice: class Discover: + ST = "urn:schemas-sony-com:service:ScalarWebAPI:1" + @staticmethod async def discover(timeout, debug=0, callback=None): """Discover supported devices.""" - ST = "urn:schemas-sony-com:service:ScalarWebAPI:1" - _LOGGER.info("Discovering for %s seconds" % timeout) - - from async_upnp_client import UpnpFactory - from async_upnp_client.aiohttp import AiohttpRequester - - async def parse_device(device): - requester = AiohttpRequester() - factory = UpnpFactory(requester) - - url = device["location"] - device = await factory.async_create_device(url) - - if debug > 0: - print(etree.ElementTree.tostring(device.xml).decode()) - - NS = {"av": "urn:schemas-sony-com:av"} - info = device.xml.find(".//av:X_ScalarWebAPI_DeviceInfo", NS) - if not info: - _LOGGER.error("Unable to find X_ScalaerWebAPI_DeviceInfo") - return - - endpoint = info.find(".//av:X_ScalarWebAPI_BaseURL", NS).text - version = info.find(".//av:X_ScalarWebAPI_Version", NS).text - services = [ - x.text for x in info.findall(".//av:X_ScalarWebAPI_ServiceType", NS) - ] - - dev = DiscoveredDevice( - name=device.name, - model_number=device.model_number, - udn=device.udn, - endpoint=endpoint, - version=version, - services=services, - upnp_services=list(device.services.keys()), - upnp_location=url, - ) + _LOGGER.info("Discovering for %s seconds" % timeout) + async def parse(device): + dev = Discover.parse_device(device, debug=debug) _LOGGER.debug("Discovered: %s" % dev) - if callback is not None: await callback(dev) await async_search( - timeout=timeout, service_type=ST, async_callback=parse_device + timeout=timeout, service_type=Discover.ST, async_callback=parse + ) + + @staticmethod + async def parse_device(device, debug=0): + requester = AiohttpRequester() + factory = UpnpFactory(requester) + + url = device["location"] + device = await factory.async_create_device(url) + + if debug > 0: + print(etree.ElementTree.tostring(device.xml).decode()) + + NS = {"av": "urn:schemas-sony-com:av"} + + info = device.xml.find(".//av:X_ScalarWebAPI_DeviceInfo", NS) + if not info: + _LOGGER.error("Unable to find X_ScalaerWebAPI_DeviceInfo") + return + + endpoint = info.find(".//av:X_ScalarWebAPI_BaseURL", NS).text + version = info.find(".//av:X_ScalarWebAPI_Version", NS).text + services = [ + x.text for x in info.findall(".//av:X_ScalarWebAPI_ServiceType", NS) + ] + + return DiscoveredDevice( + name=device.name, + model_number=device.model_number, + udn=device.udn, + endpoint=endpoint, + version=version, + services=services, + upnp_services=list(device.services.keys()), + upnp_location=url, ) diff --git a/songpal/main.py b/songpal/main.py index fc80d75..5d2710e 100644 --- a/songpal/main.py +++ b/songpal/main.py @@ -11,6 +11,7 @@ from songpal import Device, SongpalException from songpal.common import ProtocolType from songpal.containers import Setting +from songpal.device import DeviceFactory from songpal.discovery import Discover from songpal.group import GroupControl @@ -139,13 +140,13 @@ async def cli(ctx, endpoint, debug, websocket, post): protocol = ProtocolType.XHRPost logging.debug("Using endpoint %s", endpoint) - x = Device(endpoint, force_protocol=protocol, debug=debug) try: - await x.get_supported_methods() + ctx.obj = await DeviceFactory.get( + endpoint, force_protocol=protocol, debug=debug, timeout=1 + ) except SongpalException as ex: - err("Unable to get supported methods: %s" % ex) + err("Unable to get device: %s" % ex) sys.exit(-1) - ctx.obj = x # this causes RuntimeError: This event loop is already running # if ctx.invoked_subcommand is None: diff --git a/songpal/service.py b/songpal/service.py index bfe7a5f..9aaecd8 100644 --- a/songpal/service.py +++ b/songpal/service.py @@ -90,7 +90,24 @@ async def from_payload(cls, payload, endpoint, idgen, debug, force_protocol=None # creation here we want to pass the created service class to methods. service = cls(service_name, service_endpoint, protocol, idgen, debug) - sigs = await cls.fetch_signatures(service_endpoint, protocol, idgen) + await service.fetch_mehods(debug) + + if "notifications" in payload and "switchNotifications" in service.methods: + notifications = [ + Notification( + service_endpoint, + service.methods["switchNotifications"], + notification, + ) + for notification in payload["notifications"] + ] + service.notifications = notifications + _LOGGER.debug("Got notifications: %s" % notifications) + + async def fetch_methods(self, debug): + sigs = await self.__class__.fetch_signatures( + self.endpoint, self.active_protocol, self.idgen + ) if debug > 1: _LOGGER.debug("Signatures: %s", sigs) @@ -110,21 +127,10 @@ async def from_payload(cls, payload, endpoint, idgen, debug, force_protocol=None methods[name], ) else: - methods[name] = Method(service, parsed_sig, debug) + methods[name] = Method(self, parsed_sig, debug) - service.methods = methods - - if "notifications" in payload and "switchNotifications" in methods: - notifications = [ - Notification( - service_endpoint, methods["switchNotifications"], notification - ) - for notification in payload["notifications"] - ] - service.notifications = notifications - _LOGGER.debug("Got notifications: %s" % notifications) - - return service + self.methods = methods + return self.methods async def call_method(self, method, *args, **kwargs): """Call a method (internal). @@ -256,6 +262,9 @@ def methods(self) -> List[Method]: def methods(self, methods): self._methods = methods + def has_method(self, name): + return name in self._methods + @property def protocols(self): """Protocols supported by this service."""