From 7b5114fa298994c75cf74828e3e63a7cc9ed5310 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Thu, 8 Oct 2020 21:53:49 +0200 Subject: [PATCH 1/7] Add input and volume support for BDV N9200W --- songpal/containers.py | 62 ++++++++--- songpal/device.py | 239 ++++++++++++++++++++++++++++++++++++------ songpal/service.py | 39 ++++--- 3 files changed, 281 insertions(+), 59 deletions(-) diff --git a/songpal/containers.py b/songpal/containers.py index ff0f455..3bbd376 100644 --- a/songpal/containers.py +++ b/songpal/containers.py @@ -5,6 +5,8 @@ import attr +from songpal import SongpalException + _LOGGER = logging.getLogger(__name__) @@ -279,6 +281,8 @@ class Volume: step = attr.ib() volume = attr.ib() + renderingControl = attr.ib(default=None) + @property def is_muted(self): """Return True if volume is muted.""" @@ -303,21 +307,43 @@ async def set_mute(self, activate: bool): if activate: enabled = "on" - return await self.services["audio"]["setAudioMute"]( - mute=enabled, output=self.output - ) + if self.services and self.services["audio"].has_method("setAudioMute"): + return await self.services["audio"]["setAudioMute"]( + mute=enabled, output=self.output + ) + else: + return await self.renderingControl.action("SetMute").async_call( + InstanceID=0, Channel="Master", DesiredMute=activate + ) async def toggle_mute(self): """Toggle mute.""" - return await self.services["audio"]["setAudioMute"]( - mute="toggle", output=self.output - ) + if self.services and self.services["audio"].has_method("setAudioMute"): + return await self.services["audio"]["setAudioMute"]( + mute="toggle", output=self.output + ) + else: + 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: int): + async def set_volume(self, volume: str): """Set volume level.""" - return await self.services["audio"]["setAudioVolume"]( - volume=str(volume), output=self.output - ) + + if self.services and self.services["audio"].has_method("setAudioVolume"): + return await self.services["audio"]["setAudioVolume"]( + volume=str(volume), output=self.output + ) + else: + if "+" in volume or "-" in volume: + raise SongpalException( + "Setting relative volume not supported with UPnP" + ) + + return await self.renderingControl.action("SetVolume").async_call( + InstanceID=0, Channel="Master", DesiredVolume=int(volume) + ) @attr.s @@ -388,6 +414,9 @@ class Input: iconUrl = attr.ib() outputs = attr.ib(default=attr.Factory(list)) + avTransport = attr.ib(default=None) + uriMetadata = attr.ib(default=None) + def __str__(self): s = "%s (uri: %s)" % (self.title, self.uri) if self.active: @@ -397,9 +426,16 @@ def __str__(self): async def activate(self, output: Zone = None): """Activate this input.""" output_uri = output.uri if output else "" - return await self.services["avContent"]["setPlayContent"]( - uri=self.uri, output=output_uri - ) + + if self.services and "avContent" in self.services: + return await self.services["avContent"]["setPlayContent"]( + uri=self.uri, output=output_uri + ) + + if self.avTransport: + return await self.avTransport.action("SetAVTransportURI").async_call( + InstanceID=0, CurrentURI=self.uri, CurrentURIMetaData=self.uriMetadata + ) @attr.s diff --git a/songpal/device.py b/songpal/device.py index 24d5d88..2315512 100644 --- a/songpal/device.py +++ b/songpal/device.py @@ -8,8 +8,12 @@ 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 songpal.common import SongpalException +from didl_lite import didl_lite +from songpal.common import ProtocolType, SongpalException from songpal.containers import ( Content, ContentInfo, @@ -28,6 +32,7 @@ Volume, Zone, ) +from songpal.discovery import Discover from songpal.notification import ConnectChange, Notification from songpal.service import Service @@ -67,6 +72,10 @@ def __init__(self, endpoint, force_protocol=None, debug=0): self.callbacks = defaultdict(set) + self._upnp_discovery = None + self._upnp_device = None + self._upnp_renderer = None + async def __aenter__(self): """Asynchronous context manager, initializes the list of available methods.""" await self.get_supported_methods() @@ -130,31 +139,68 @@ async def get_supported_methods(self): Calling this as the first thing before doing anything else is necessary to fill the available services table. """ - response = await self.request_supported_methods() + try: + response = await self.request_supported_methods() - if "result" in response: - services = response["result"][0] - _LOGGER.debug("Got %s services!" % len(services)) + if "result" in response: + services = response["result"][0] + _LOGGER.debug("Got %s services!" % len(services)) - for x in services: - serv = await Service.from_payload( - x, self.endpoint, self.idgen, self.debug, self.force_protocol - ) - if serv is not None: - self.services[x["service"]] = serv - else: - _LOGGER.warning("Unable to create service %s", x["service"]) + for x in services: + serv = await Service.from_payload( + x, self.endpoint, self.idgen, self.debug, self.force_protocol + ) + if serv is not None: + self.services[x["service"]] = serv + else: + _LOGGER.warning("Unable to create service %s", x["service"]) - for service in self.services.values(): - if self.debug > 1: - _LOGGER.debug("Service %s", service) - for api in service.methods: - # self.logger.debug("%s > %s" % (service, api)) + for service in self.services.values(): if self.debug > 1: - _LOGGER.debug("> %s" % api) + _LOGGER.debug("Service %s", service) + for api in service.methods: + # self.logger.debug("%s > %s" % (service, api)) + if self.debug > 1: + _LOGGER.debug("> %s" % api) + return self.services + + return None + except SongpalException as e: + found_services = None + if e.code == 12 and e.error_message == "getSupportedApiInfo": + found_services = await self._get_supported_methods_upnp() + + if found_services: + return found_services + else: + raise e + + async def _get_supported_methods_upnp(self): + if self._upnp_discovery: return self.services - return None + host = urlparse(self.endpoint).hostname + + async def find_device(device): + if host == urlparse(device.endpoint).hostname: + self._upnp_discovery = device + + await Discover.discover(1, self.debug, callback=find_device) + + if self._upnp_discovery is None: + return None + + 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_power(self) -> Power: """Get the device state.""" @@ -264,11 +310,110 @@ async def activate_system_update(self) -> None: async def get_inputs(self) -> List[Input]: """Return list of available outputs.""" - res = await self.services["avContent"]["getCurrentExternalTerminalsStatus"]() + if "avContent" in self.services: + res = await self.services["avContent"][ + "getCurrentExternalTerminalsStatus" + ]() + return [ + Input.make(services=self.services, **x) + for x in res + if "meta:zone:output" not in x["meta"] + ] + else: + if self._upnp_discovery is None: + raise SongpalException( + "avContent service not available and UPnP fallback failed" + ) + + return await self._get_inputs_upnp() + + async def _get_upnp_services(self): + requester = AiohttpRequester() + factory = UpnpFactory(requester) + + if self._upnp_device is None: + self._upnp_device = await factory.async_create_device( + self._upnp_discovery.upnp_location + ) + + if self._upnp_renderer is None: + media_renderers = await DmrDevice.async_search(timeout=1) + host = urlparse(self.endpoint).hostname + media_renderer_location = next( + ( + r["location"] + for r in media_renderers + if urlparse(r["location"]).hostname == host + ), + None, + ) + if media_renderer_location is None: + raise SongpalException("Could not find UPnP media renderer") + + self._upnp_renderer = await factory.async_create_device( + media_renderer_location + ) + + async def _get_inputs_upnp(self): + await self._get_upnp_services() + + content_directory = self._upnp_device.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"]) return [ - Input.make(services=self.services, **x) - for x in res - if "meta:zone:output" not in x["meta"] + Input.make( + title=i.title, + uri=i.resources[0].uri, + active="active" if i.resources[0].uri in current_uri else "", + avTransport=av_transport, + uriMetadata=didl_lite.to_xml_string(i).decode("utf-8"), + ) + for i in inputs ] async def get_zones(self) -> List[Zone]: @@ -386,13 +531,45 @@ 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] - if len(volume_info) < 1: - logging.warning("Unable to get volume information") - elif len(volume_info) > 1: - logging.debug("The device seems to have more than one volume setting.") - return volume_info + if "audio" in self.services and self.services["audio"].has_method( + "getVolumeInformation" + ): + res = await self.services["audio"]["getVolumeInformation"]({}) + volume_info = [Volume.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: + logging.debug("The device seems to have more than one volume setting.") + return volume_info + else: + return await self._get_volume_information_upnp() + + async def _get_volume_information_upnp(self): + await self._get_upnp_services() + + 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 [ + Volume.make( + volume=volume_result["CurrentVolume"], + mute=mute_result["CurrentMute"], + minVolume=min_volume, + maxVolume=max_volume, + step=1, + renderingControl=rendering_control_service, + ) + ] async def get_sound_settings(self, target="") -> List[Setting]: """Get the current sound settings. 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.""" From 6d420a0927db5c692ecff14cd15104ba23630f84 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Fri, 9 Oct 2020 14:56:43 +0200 Subject: [PATCH 2/7] Add sysinfo support for BDV N9200W --- songpal/device.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/songpal/device.py b/songpal/device.py index 2315512..9ee0e89 100644 --- a/songpal/device.py +++ b/songpal/device.py @@ -278,7 +278,26 @@ async def get_interface_information(self) -> InterfaceInfo: async def get_system_info(self) -> Sysinfo: """Return system information including mac addresses and current version.""" - return Sysinfo.make(**await self.services["system"]["getSystemInformation"]()) + + if self.services["system"].has_method("getSystemInformation"): + return Sysinfo.make( + **await self.services["system"]["getSystemInformation"]() + ) + elif self.services["system"].has_method("getNetworkSettings"): + 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 + ) + else: + raise SongpalException("getSystemInformation not supported") async def get_sleep_timer_settings(self) -> List[Setting]: """Get sleep timer settings.""" From 2999778ca22e5cc7d96fc012f24153b02ab7be93 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Fri, 9 Oct 2020 15:12:58 +0200 Subject: [PATCH 3/7] Volume only supports absolute numbers --- README.rst | 3 --- songpal/containers.py | 11 ++--------- songpal/main.py | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/README.rst b/README.rst index e8a2c02..7311125 100644 --- a/README.rst +++ b/README.rst @@ -166,9 +166,6 @@ Volume Control $ songpal volume 20 - $ songpal volume +5 - - $ songpal volume -10 $ songpal volume --output 'Zone 2' diff --git a/songpal/containers.py b/songpal/containers.py index 3bbd376..bbfb0b9 100644 --- a/songpal/containers.py +++ b/songpal/containers.py @@ -5,8 +5,6 @@ import attr -from songpal import SongpalException - _LOGGER = logging.getLogger(__name__) @@ -328,7 +326,7 @@ async def toggle_mute(self): ) return self.set_mute(not mute_result["CurrentMute"]) - async def set_volume(self, volume: str): + async def set_volume(self, volume: int): """Set volume level.""" if self.services and self.services["audio"].has_method("setAudioVolume"): @@ -336,13 +334,8 @@ async def set_volume(self, volume: str): volume=str(volume), output=self.output ) else: - if "+" in volume or "-" in volume: - raise SongpalException( - "Setting relative volume not supported with UPnP" - ) - return await self.renderingControl.action("SetVolume").async_call( - InstanceID=0, Channel="Master", DesiredVolume=int(volume) + InstanceID=0, Channel="Master", DesiredVolume=volume ) diff --git a/songpal/main.py b/songpal/main.py index fc80d75..ceaff01 100644 --- a/songpal/main.py +++ b/songpal/main.py @@ -377,7 +377,7 @@ async def volume(dev: Device, volume, output): await vol.set_mute(False) elif volume: click.echo("Setting volume to %s" % volume) - await vol.set_volume(volume) + await vol.set_volume(int(volume)) if output is not None: click.echo(vol) From 5a4724324dec81fc537f4202250472e3808ce2f8 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Fri, 9 Oct 2020 18:18:51 +0200 Subject: [PATCH 4/7] Detect selected BDV N9200W input when changed on device --- songpal/device.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/songpal/device.py b/songpal/device.py index 9ee0e89..6b4bef2 100644 --- a/songpal/device.py +++ b/songpal/device.py @@ -424,11 +424,26 @@ async def _get_inputs_upnp(self): 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 [ Input.make( title=i.title, uri=i.resources[0].uri, - active="active" if i.resources[0].uri in current_uri else "", + active="active" if is_input_active(i, current_uri) else "", avTransport=av_transport, uriMetadata=didl_lite.to_xml_string(i).decode("utf-8"), ) From dda008c31560bfe3e4a8717e3df3fbb769dcf699 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Fri, 9 Oct 2020 18:44:12 +0200 Subject: [PATCH 5/7] Fix missing sound when changing BDV N9200W input Attempt to UPnP play the same way as the Songpal app is doing --- songpal/containers.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/songpal/containers.py b/songpal/containers.py index bbfb0b9..084f643 100644 --- a/songpal/containers.py +++ b/songpal/containers.py @@ -426,10 +426,22 @@ async def activate(self, output: Zone = None): ) if self.avTransport: - return await self.avTransport.action("SetAVTransportURI").async_call( + 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: From d0cb347c61b30ae52ce436ec521b750898f6aa50 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Tue, 17 Nov 2020 10:59:28 +0100 Subject: [PATCH 6/7] refactor: extract UPnP code to seperate classes --- songpal/containers.py | 143 +++++++----- songpal/device.py | 531 +++++++++++++++++++++++------------------- songpal/discovery.py | 83 +++---- songpal/main.py | 9 +- 4 files changed, 429 insertions(+), 337 deletions(-) diff --git a/songpal/containers.py b/songpal/containers.py index 084f643..89b0002 100644 --- a/songpal/containers.py +++ b/songpal/containers.py @@ -271,7 +271,6 @@ class Volume: make = classmethod(make) - services = attr.ib(repr=False) maxVolume = attr.ib() minVolume = attr.ib() mute = attr.ib() @@ -279,12 +278,10 @@ class Volume: step = attr.ib() volume = attr.ib() - renderingControl = attr.ib(default=None) - @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: @@ -301,42 +298,63 @@ 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: 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" - if self.services and self.services["audio"].has_method("setAudioMute"): - return await self.services["audio"]["setAudioMute"]( - mute=enabled, output=self.output - ) - else: - return await self.renderingControl.action("SetMute").async_call( - InstanceID=0, Channel="Master", DesiredMute=activate - ) + return await self.services["audio"]["setAudioMute"]( + mute=enabled, output=self.output + ) async def toggle_mute(self): - """Toggle mute.""" - if self.services and self.services["audio"].has_method("setAudioMute"): - return await self.services["audio"]["setAudioMute"]( - mute="toggle", output=self.output - ) - else: - mute_result = await self.renderingControl.action("GetMute").async_call( - InstanceID=0, Channel="Master" - ) - return self.set_mute(not mute_result["CurrentMute"]) + return await self.services["audio"]["setAudioMute"]( + mute="toggle", output=self.output + ) async def set_volume(self, volume: int): - """Set volume level.""" + return await self.services["audio"]["setAudioVolume"]( + volume=str(volume), output=self.output + ) - if self.services and self.services["audio"].has_method("setAudioVolume"): - return await self.services["audio"]["setAudioVolume"]( - volume=str(volume), output=self.output - ) - else: - return await self.renderingControl.action("SetVolume").async_call( - InstanceID=0, Channel="Master", DesiredVolume=volume - ) + +@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: int): + return await self.renderingControl.action("SetVolume").async_call( + InstanceID=0, Channel="Master", DesiredVolume=volume + ) @attr.s @@ -396,20 +414,14 @@ 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() outputs = attr.ib(default=attr.Factory(list)) - avTransport = attr.ib(default=None) - uriMetadata = attr.ib(default=None) - def __str__(self): s = "%s (uri: %s)" % (self.title, self.uri) if self.active: @@ -418,29 +430,48 @@ 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 + ) - if self.services and "avContent" in self.services: - return await self.services["avContent"]["setPlayContent"]( - uri=self.uri, output=output_uri - ) - if self.avTransport: - result = await self.avTransport.action("SetAVTransportURI").async_call( - InstanceID=0, CurrentURI=self.uri, CurrentURIMetaData=self.uriMetadata - ) +@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 - 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 + return result @attr.s diff --git a/songpal/device.py b/songpal/device.py index 6b4bef2..1b760a3 100644 --- a/songpal/device.py +++ b/songpal/device.py @@ -4,13 +4,14 @@ 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 didl_lite import didl_lite from songpal.common import ProtocolType, SongpalException @@ -18,6 +19,8 @@ Content, ContentInfo, Input, + InputControlSongpal, + InputControlUpnp, InterfaceInfo, PlayInfo, Power, @@ -30,6 +33,8 @@ SupportedFunctions, Sysinfo, Volume, + VolumeControlSongpal, + VolumeControlUpnp, Zone, ) from songpal.discovery import Discover @@ -72,10 +77,6 @@ def __init__(self, endpoint, force_protocol=None, debug=0): self.callbacks = defaultdict(set) - self._upnp_discovery = None - self._upnp_device = None - self._upnp_renderer = None - async def __aenter__(self): """Asynchronous context manager, initializes the list of available methods.""" await self.get_supported_methods() @@ -139,68 +140,31 @@ async def get_supported_methods(self): Calling this as the first thing before doing anything else is necessary to fill the available services table. """ - try: - response = await self.request_supported_methods() + response = await self.request_supported_methods() - if "result" in response: - services = response["result"][0] - _LOGGER.debug("Got %s services!" % len(services)) + if "result" in response: + services = response["result"][0] + _LOGGER.debug("Got %s services!" % len(services)) - for x in services: - serv = await Service.from_payload( - x, self.endpoint, self.idgen, self.debug, self.force_protocol - ) - if serv is not None: - self.services[x["service"]] = serv - else: - _LOGGER.warning("Unable to create service %s", x["service"]) + for x in services: + serv = await Service.from_payload( + x, self.endpoint, self.idgen, self.debug, self.force_protocol + ) + if serv is not None: + self.services[x["service"]] = serv + else: + _LOGGER.warning("Unable to create service %s", x["service"]) - for service in self.services.values(): + for service in self.services.values(): + if self.debug > 1: + _LOGGER.debug("Service %s", service) + for api in service.methods: + # self.logger.debug("%s > %s" % (service, api)) if self.debug > 1: - _LOGGER.debug("Service %s", service) - for api in service.methods: - # self.logger.debug("%s > %s" % (service, api)) - if self.debug > 1: - _LOGGER.debug("> %s" % api) - return self.services - - return None - except SongpalException as e: - found_services = None - if e.code == 12 and e.error_message == "getSupportedApiInfo": - found_services = await self._get_supported_methods_upnp() - - if found_services: - return found_services - else: - raise e - - async def _get_supported_methods_upnp(self): - if self._upnp_discovery: + _LOGGER.debug("> %s" % api) return self.services - host = urlparse(self.endpoint).hostname - - async def find_device(device): - if host == urlparse(device.endpoint).hostname: - self._upnp_discovery = device - - await Discover.discover(1, self.debug, callback=find_device) - - if self._upnp_discovery is None: - return None - - 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 + return None async def get_power(self) -> Power: """Get the device state.""" @@ -278,26 +242,7 @@ async def get_interface_information(self) -> InterfaceInfo: async def get_system_info(self) -> Sysinfo: """Return system information including mac addresses and current version.""" - - if self.services["system"].has_method("getSystemInformation"): - return Sysinfo.make( - **await self.services["system"]["getSystemInformation"]() - ) - elif self.services["system"].has_method("getNetworkSettings"): - 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 - ) - else: - raise SongpalException("getSystemInformation not supported") + return Sysinfo.make(**await self.services["system"]["getSystemInformation"]()) async def get_sleep_timer_settings(self) -> List[Setting]: """Get sleep timer settings.""" @@ -329,125 +274,11 @@ async def activate_system_update(self) -> None: async def get_inputs(self) -> List[Input]: """Return list of available outputs.""" - if "avContent" in self.services: - res = await self.services["avContent"][ - "getCurrentExternalTerminalsStatus" - ]() - return [ - Input.make(services=self.services, **x) - for x in res - if "meta:zone:output" not in x["meta"] - ] - else: - if self._upnp_discovery is None: - raise SongpalException( - "avContent service not available and UPnP fallback failed" - ) - - return await self._get_inputs_upnp() - - async def _get_upnp_services(self): - requester = AiohttpRequester() - factory = UpnpFactory(requester) - - if self._upnp_device is None: - self._upnp_device = await factory.async_create_device( - self._upnp_discovery.upnp_location - ) - - if self._upnp_renderer is None: - media_renderers = await DmrDevice.async_search(timeout=1) - host = urlparse(self.endpoint).hostname - media_renderer_location = next( - ( - r["location"] - for r in media_renderers - if urlparse(r["location"]).hostname == host - ), - None, - ) - if media_renderer_location is None: - raise SongpalException("Could not find UPnP media renderer") - - self._upnp_renderer = await factory.async_create_device( - media_renderer_location - ) - - async def _get_inputs_upnp(self): - await self._get_upnp_services() - - content_directory = self._upnp_device.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 - + res = await self.services["avContent"]["getCurrentExternalTerminalsStatus"]() return [ - Input.make( - title=i.title, - 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 + InputControlSongpal.make(services=self.services, **x) + for x in res + if "meta:zone:output" not in x["meta"] ] async def get_zones(self) -> List[Zone]: @@ -565,45 +396,15 @@ async def get_contents(self, uri) -> List[Content]: async def get_volume_information(self) -> List[Volume]: """Get the volume information.""" - if "audio" in self.services and self.services["audio"].has_method( - "getVolumeInformation" - ): - res = await self.services["audio"]["getVolumeInformation"]({}) - volume_info = [Volume.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: - logging.debug("The device seems to have more than one volume setting.") - return volume_info - else: - return await self._get_volume_information_upnp() - - async def _get_volume_information_upnp(self): - await self._get_upnp_services() - - 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 [ - Volume.make( - volume=volume_result["CurrentVolume"], - mute=mute_result["CurrentMute"], - minVolume=min_volume, - maxVolume=max_volume, - step=1, - renderingControl=rendering_control_service, - ) + res = await self.services["audio"]["getVolumeInformation"]({}) + 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: + logging.debug("The device seems to have more than one volume setting.") + return volume_info async def get_sound_settings(self, target="") -> List[Setting]: """Get the current sound settings. @@ -727,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 ceaff01..e32939f 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: From 91d011864535392c99c440ff76860d6a9b2b8d82 Mon Sep 17 00:00:00 2001 From: broglep <20624281+broglep@users.noreply.github.com> Date: Tue, 17 Nov 2020 11:47:33 +0100 Subject: [PATCH 7/7] fix: handle both absolute and relative volume numbers This reverts commit 2999778ca22e5cc7d96fc012f24153b02ab7be93. --- README.rst | 3 +++ songpal/containers.py | 23 ++++++++++++++++++----- songpal/main.py | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 7311125..e8a2c02 100644 --- a/README.rst +++ b/README.rst @@ -166,6 +166,9 @@ Volume Control $ songpal volume 20 + $ songpal volume +5 + + $ songpal volume -10 $ songpal volume --output 'Zone 2' diff --git a/songpal/containers.py b/songpal/containers.py index 89b0002..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__) @@ -304,7 +306,7 @@ async def toggle_mute(self): """Toggle mute.""" raise NotImplementedError - async def set_volume(self, volume: int): + async def set_volume(self, volume: Union[str, int]): """Set volume level.""" raise NotImplementedError @@ -327,7 +329,7 @@ async def toggle_mute(self): mute="toggle", output=self.output ) - async def set_volume(self, volume: int): + async def set_volume(self, volume: Union[str, int]): return await self.services["audio"]["setAudioVolume"]( volume=str(volume), output=self.output ) @@ -351,9 +353,20 @@ async def toggle_mute(self): ) return self.set_mute(not mute_result["CurrentMute"]) - async def set_volume(self, volume: int): + 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=volume + InstanceID=0, Channel="Master", DesiredVolume=desired_volume ) diff --git a/songpal/main.py b/songpal/main.py index e32939f..5d2710e 100644 --- a/songpal/main.py +++ b/songpal/main.py @@ -378,7 +378,7 @@ async def volume(dev: Device, volume, output): await vol.set_mute(False) elif volume: click.echo("Setting volume to %s" % volume) - await vol.set_volume(int(volume)) + await vol.set_volume(volume) if output is not None: click.echo(vol)