Skip to content

Commit 7b5114f

Browse files
committed
Add input and volume support for BDV N9200W
1 parent 03677b2 commit 7b5114f

3 files changed

Lines changed: 281 additions & 59 deletions

File tree

songpal/containers.py

Lines changed: 49 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import attr
77

8+
from songpal import SongpalException
9+
810
_LOGGER = logging.getLogger(__name__)
911

1012

@@ -279,6 +281,8 @@ class Volume:
279281
step = attr.ib()
280282
volume = attr.ib()
281283

284+
renderingControl = attr.ib(default=None)
285+
282286
@property
283287
def is_muted(self):
284288
"""Return True if volume is muted."""
@@ -303,21 +307,43 @@ async def set_mute(self, activate: bool):
303307
if activate:
304308
enabled = "on"
305309

306-
return await self.services["audio"]["setAudioMute"](
307-
mute=enabled, output=self.output
308-
)
310+
if self.services and self.services["audio"].has_method("setAudioMute"):
311+
return await self.services["audio"]["setAudioMute"](
312+
mute=enabled, output=self.output
313+
)
314+
else:
315+
return await self.renderingControl.action("SetMute").async_call(
316+
InstanceID=0, Channel="Master", DesiredMute=activate
317+
)
309318

310319
async def toggle_mute(self):
311320
"""Toggle mute."""
312-
return await self.services["audio"]["setAudioMute"](
313-
mute="toggle", output=self.output
314-
)
321+
if self.services and self.services["audio"].has_method("setAudioMute"):
322+
return await self.services["audio"]["setAudioMute"](
323+
mute="toggle", output=self.output
324+
)
325+
else:
326+
mute_result = await self.renderingControl.action("GetMute").async_call(
327+
InstanceID=0, Channel="Master"
328+
)
329+
return self.set_mute(not mute_result["CurrentMute"])
315330

316-
async def set_volume(self, volume: int):
331+
async def set_volume(self, volume: str):
317332
"""Set volume level."""
318-
return await self.services["audio"]["setAudioVolume"](
319-
volume=str(volume), output=self.output
320-
)
333+
334+
if self.services and self.services["audio"].has_method("setAudioVolume"):
335+
return await self.services["audio"]["setAudioVolume"](
336+
volume=str(volume), output=self.output
337+
)
338+
else:
339+
if "+" in volume or "-" in volume:
340+
raise SongpalException(
341+
"Setting relative volume not supported with UPnP"
342+
)
343+
344+
return await self.renderingControl.action("SetVolume").async_call(
345+
InstanceID=0, Channel="Master", DesiredVolume=int(volume)
346+
)
321347

322348

323349
@attr.s
@@ -388,6 +414,9 @@ class Input:
388414
iconUrl = attr.ib()
389415
outputs = attr.ib(default=attr.Factory(list))
390416

417+
avTransport = attr.ib(default=None)
418+
uriMetadata = attr.ib(default=None)
419+
391420
def __str__(self):
392421
s = "%s (uri: %s)" % (self.title, self.uri)
393422
if self.active:
@@ -397,9 +426,16 @@ def __str__(self):
397426
async def activate(self, output: Zone = None):
398427
"""Activate this input."""
399428
output_uri = output.uri if output else ""
400-
return await self.services["avContent"]["setPlayContent"](
401-
uri=self.uri, output=output_uri
402-
)
429+
430+
if self.services and "avContent" in self.services:
431+
return await self.services["avContent"]["setPlayContent"](
432+
uri=self.uri, output=output_uri
433+
)
434+
435+
if self.avTransport:
436+
return await self.avTransport.action("SetAVTransportURI").async_call(
437+
InstanceID=0, CurrentURI=self.uri, CurrentURIMetaData=self.uriMetadata
438+
)
403439

404440

405441
@attr.s

songpal/device.py

Lines changed: 208 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@
88
from urllib.parse import urlparse
99

1010
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
1114

12-
from songpal.common import SongpalException
15+
from didl_lite import didl_lite
16+
from songpal.common import ProtocolType, SongpalException
1317
from songpal.containers import (
1418
Content,
1519
ContentInfo,
@@ -28,6 +32,7 @@
2832
Volume,
2933
Zone,
3034
)
35+
from songpal.discovery import Discover
3136
from songpal.notification import ConnectChange, Notification
3237
from songpal.service import Service
3338

@@ -67,6 +72,10 @@ def __init__(self, endpoint, force_protocol=None, debug=0):
6772

6873
self.callbacks = defaultdict(set)
6974

75+
self._upnp_discovery = None
76+
self._upnp_device = None
77+
self._upnp_renderer = None
78+
7079
async def __aenter__(self):
7180
"""Asynchronous context manager, initializes the list of available methods."""
7281
await self.get_supported_methods()
@@ -130,31 +139,68 @@ async def get_supported_methods(self):
130139
Calling this as the first thing before doing anything else is
131140
necessary to fill the available services table.
132141
"""
133-
response = await self.request_supported_methods()
142+
try:
143+
response = await self.request_supported_methods()
134144

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))
138148

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"])
147157

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():
153159
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:
155180
return self.services
156181

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
158204

159205
async def get_power(self) -> Power:
160206
"""Get the device state."""
@@ -264,11 +310,110 @@ async def activate_system_update(self) -> None:
264310

265311
async def get_inputs(self) -> List[Input]:
266312
"""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"])
268408
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
272417
]
273418

274419
async def get_zones(self) -> List[Zone]:
@@ -386,13 +531,45 @@ async def get_contents(self, uri) -> List[Content]:
386531

387532
async def get_volume_information(self) -> List[Volume]:
388533
"""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+
]
396573

397574
async def get_sound_settings(self, target="") -> List[Setting]:
398575
"""Get the current sound settings.

0 commit comments

Comments
 (0)