You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

729 lines
24 KiB

4 years ago
  1. # -*- coding: utf-8 -*-
  2. """UPnP DLNA module."""
  3. import asyncio
  4. import logging
  5. import re
  6. from datetime import timedelta
  7. from typing import Any
  8. from typing import Dict
  9. from typing import List
  10. from typing import Mapping
  11. from typing import Optional
  12. from xml.sax import parseString
  13. from xml.sax.handler import ContentHandler
  14. from xml.sax.handler import ErrorHandler
  15. from urllib.parse import quote_plus
  16. from urllib.parse import urljoin
  17. from urllib.parse import urlparse
  18. from urllib.parse import urlunparse
  19. from didl_lite import didl_lite
  20. from async_upnp_client import UpnpDevice
  21. from async_upnp_client import UpnpError
  22. from async_upnp_client import UpnpService
  23. from async_upnp_client import UpnpStateVariable
  24. from async_upnp_client.profile import UpnpProfileDevice
  25. _LOGGER = logging.getLogger(__name__)
  26. STATE_ON = 'ON'
  27. STATE_PLAYING = 'PLAYING'
  28. STATE_PAUSED = 'PAUSED'
  29. STATE_IDLE = 'IDLE'
  30. def _time_to_str(time: timedelta) -> str:
  31. """Convert timedelta to str/units."""
  32. total_seconds = abs(time.total_seconds())
  33. target = {
  34. 'sign': '-' if time.total_seconds() < 0 else '',
  35. 'hours': int(total_seconds // 3600),
  36. 'minutes': int(total_seconds // 60),
  37. 'seconds': int(total_seconds % 60),
  38. }
  39. return '{sign}{hours}:{minutes}:{seconds}'.format(**target)
  40. def _str_to_time(string: str) -> timedelta:
  41. """Convert a string to timedelta."""
  42. regexp = r"(?P<sign>[-+])?(?P<h>\d+):(?P<m>\d+):(?P<s>\d+)\.?(?P<ms>\d+)?"
  43. match = re.match(regexp, string)
  44. if not match:
  45. return None
  46. sign = -1 if match.group('sign') == '-' else 1
  47. hours = int(match.group('h'))
  48. minutes = int(match.group('m'))
  49. seconds = int(match.group('s'))
  50. if match.group('ms'):
  51. msec = int(match.group('ms'))
  52. else:
  53. msec = 0
  54. return sign * timedelta(hours=hours, minutes=minutes, seconds=seconds, milliseconds=msec)
  55. def _absolute_url(device: UpnpDevice, url: str) -> str:
  56. """
  57. Convert a relative URL to an absolute URL pointing at device.
  58. If url is already an absolute url (i.e., starts with http:/https:),
  59. then the url itself is returned.
  60. """
  61. if url.startswith('http:') or \
  62. url.startswith('https:'):
  63. return url
  64. return urljoin(device.device_url, url)
  65. class DlnaDmrEventContentHandler(ContentHandler):
  66. """Content Handler to parse DLNA DMR Event data."""
  67. def __init__(self):
  68. """Initializer."""
  69. super().__init__()
  70. self.changes = {}
  71. self._current_instance = None
  72. def startElement(self, name, attrs):
  73. """Handle startElement."""
  74. if 'val' not in attrs:
  75. return
  76. if name == 'InstanceID':
  77. self._current_instance = attrs.get('val', '0')
  78. else:
  79. current_instance = self._current_instance or "0" # safety
  80. if current_instance not in self.changes:
  81. self.changes[current_instance] = {}
  82. self.changes[current_instance][name] = attrs.get('val')
  83. def endElement(self, name):
  84. """Handle endElement."""
  85. self._current_instance = None
  86. class DlnaDmrEventErrorHandler(ErrorHandler):
  87. """Error handler which ignores errors."""
  88. def error(self, exception):
  89. """Handle error."""
  90. _LOGGER.debug("Error during parsing: %s", exception)
  91. def fatalError(self, exception):
  92. """Handle error."""
  93. _LOGGER.debug("Fatal error during parsing: %s", exception)
  94. def _parse_last_change_event(text: str) -> Dict[str, Dict[str, str]]:
  95. """
  96. Parse a LastChange event.
  97. :param text Text to parse.
  98. :return Dict per Instance, containing changed state variables with values.
  99. """
  100. content_handler = DlnaDmrEventContentHandler()
  101. error_handler = DlnaDmrEventErrorHandler()
  102. parseString(text, content_handler, error_handler)
  103. return content_handler.changes
  104. def dlna_handle_notify_last_change(state_var: UpnpStateVariable):
  105. """
  106. Handle changes to LastChange state variable.
  107. This expands all changed state variables in the LastChange state variable.
  108. Note that the callback is called twice:
  109. - for the original event;
  110. - for the expanded event, via this function.
  111. """
  112. if state_var.name != 'LastChange':
  113. raise UpnpError('Call this only on state variable LastChange')
  114. service = state_var.service
  115. event_data = state_var.value
  116. changes = _parse_last_change_event(event_data)
  117. if '0' not in changes:
  118. _LOGGER.warning('Only InstanceID 0 is supported')
  119. return
  120. changes_0 = changes['0']
  121. service.notify_changed_state_variables(changes_0)
  122. class DmrDevice(UpnpProfileDevice):
  123. """Representation of a DLNA DMR device."""
  124. # pylint: disable=too-many-public-methods
  125. DEVICE_TYPES = [
  126. 'urn:schemas-upnp-org:device:MediaRenderer:1',
  127. 'urn:schemas-upnp-org:device:MediaRenderer:2',
  128. 'urn:schemas-upnp-org:device:MediaRenderer:3',
  129. ]
  130. _SERVICE_TYPES = {
  131. 'RC': {
  132. 'urn:schemas-upnp-org:service:RenderingControl:3',
  133. 'urn:schemas-upnp-org:service:RenderingControl:2',
  134. 'urn:schemas-upnp-org:service:RenderingControl:1',
  135. },
  136. 'AVT': {
  137. 'urn:schemas-upnp-org:service:AVTransport:3',
  138. 'urn:schemas-upnp-org:service:AVTransport:2',
  139. 'urn:schemas-upnp-org:service:AVTransport:1',
  140. },
  141. }
  142. async def async_update(self):
  143. """Retrieve the latest data."""
  144. # call GetTransportInfo/GetPositionInfo regularly
  145. avt_service = self._service('AVT')
  146. if avt_service:
  147. await self._async_poll_transport_info()
  148. if self.state == STATE_PLAYING or \
  149. self.state == STATE_PAUSED:
  150. # playing something, get position info
  151. await self._async_poll_position_info()
  152. else:
  153. await self._device.async_ping()
  154. async def _async_poll_transport_info(self):
  155. """Update transport info from device."""
  156. action = self._action('AVT', 'GetTransportInfo')
  157. result = await action.async_call(InstanceID=0)
  158. # set/update state_variable 'TransportState'
  159. changed = []
  160. state_var = self._state_variable('AVT', 'TransportState')
  161. if state_var.value != result['CurrentTransportState']:
  162. state_var.value = result['CurrentTransportState']
  163. changed.append(state_var)
  164. service = action.service
  165. self._on_event(service, changed)
  166. async def _async_poll_position_info(self):
  167. """Update position info."""
  168. action = self._action('AVT', 'GetPositionInfo')
  169. result = await action.async_call(InstanceID=0)
  170. changed = []
  171. track_duration = self._state_variable('AVT', 'CurrentTrackDuration')
  172. if track_duration.value != result['TrackDuration']:
  173. track_duration.value = result['TrackDuration']
  174. changed.append(track_duration)
  175. time_position = self._state_variable('AVT', 'RelativeTimePosition')
  176. if time_position.value != result['RelTime']:
  177. time_position.value = result['RelTime']
  178. changed.append(time_position)
  179. service = action.service
  180. self._on_event(service, changed)
  181. def _on_event(self, service: UpnpService, state_variables: List[UpnpStateVariable]):
  182. """State variable(s) changed, let home-assistant know."""
  183. # handle DLNA specific event
  184. for state_variable in state_variables:
  185. if state_variable.name == 'LastChange':
  186. dlna_handle_notify_last_change(state_variable)
  187. if self.on_event:
  188. # pylint: disable=not-callable
  189. self.on_event(service, state_variables)
  190. @property
  191. def state(self):
  192. """Get current state."""
  193. state_var = self._state_variable('AVT', 'TransportState')
  194. if not state_var:
  195. return STATE_ON
  196. state_value = (state_var.value or '').strip().lower()
  197. if state_value == 'playing':
  198. return STATE_PLAYING
  199. if state_value == 'paused':
  200. return STATE_PAUSED
  201. return STATE_IDLE
  202. @property
  203. def _current_transport_actions(self):
  204. state_var = self._state_variable('AVT', 'CurrentTransportActions')
  205. transport_actions = (state_var.value or '').split(',')
  206. return [a.lower().strip() for a in transport_actions]
  207. def _supports(self, var_name: str) -> bool:
  208. return self._state_variable('RC', var_name) is not None and \
  209. self._action('RC', 'Set%s' % var_name) is not None
  210. def _level(self, var_name: str) -> Optional[float]:
  211. state_var = self._state_variable('RC', var_name)
  212. value = state_var.value
  213. if value is None:
  214. _LOGGER.debug('Got no value for %s', var_name)
  215. return None
  216. max_value = state_var.max_value or 100
  217. return min(value / max_value, 1.0)
  218. async def _async_set_level(self, var_name: str, level: float, **kwargs: Dict[str, Any]) -> None:
  219. action = self._action('RC', 'Set%s' % var_name)
  220. argument = action.argument('Desired%s' % var_name)
  221. state_variable = argument.related_state_variable
  222. min_ = state_variable.min_value or 0
  223. max_ = state_variable.max_value or 100
  224. desired_level = int(min_ + level * (max_ - min_))
  225. args = kwargs.copy()
  226. args.update({'Desired%s' % var_name: desired_level})
  227. await action.async_call(InstanceID=0, **args)
  228. # region RC/Picture
  229. @property
  230. def has_brightness_level(self) -> bool:
  231. """Check if device has brightness level controls."""
  232. return self._supports('Brightness')
  233. @property
  234. def brightness_level(self) -> Optional[float]:
  235. """Brightness level of the media player (0..1)."""
  236. return self._level('Brightness')
  237. async def async_set_brightness_level(self, brightness: float) -> None:
  238. """Set brightness level, range 0..1."""
  239. await self._async_set_level('Brightness', brightness)
  240. @property
  241. def has_contrast_level(self) -> bool:
  242. """Check if device has contrast level controls."""
  243. return self._supports('Contrast')
  244. @property
  245. def contrast_level(self) -> Optional[float]:
  246. """Contrast level of the media player (0..1)."""
  247. return self._level('Contrast')
  248. async def async_set_contrast_level(self, contrast: float) -> None:
  249. """Set contrast level, range 0..1."""
  250. await self._async_set_level('Contrast', contrast)
  251. @property
  252. def has_sharpness_level(self) -> bool:
  253. """Check if device has sharpness level controls."""
  254. return self._supports('Sharpness')
  255. @property
  256. def sharpness_level(self) -> Optional[float]:
  257. """Sharpness level of the media player (0..1)."""
  258. return self._level('Sharpness')
  259. async def async_set_sharpness_level(self, sharpness: float) -> None:
  260. """Set sharpness level, range 0..1."""
  261. await self._async_set_level('Sharpness', sharpness)
  262. @property
  263. def has_color_temperature_level(self) -> bool:
  264. """Check if device has color temperature level controls."""
  265. return self._supports('ColorTemperature')
  266. @property
  267. def color_temperature_level(self) -> Optional[float]:
  268. """Color temperature level of the media player (0..1)."""
  269. return self._level('ColorTemperature')
  270. async def async_set_color_temperature_level(self, color_temperature: float):
  271. """Set color temperature level, range 0..1."""
  272. # pylint: disable=invalid-name
  273. await self._async_set_level('ColorTemperature', color_temperature)
  274. # endregion
  275. # region RC/Volume
  276. @property
  277. def has_volume_level(self):
  278. """Check if device has Volume level controls."""
  279. return self._supports('Volume')
  280. @property
  281. def volume_level(self):
  282. """Volume level of the media player (0..1)."""
  283. return self._level('Volume')
  284. async def async_set_volume_level(self, volume: float):
  285. """Set volume level, range 0..1."""
  286. await self._async_set_level('Volume', volume, Channel='Master')
  287. @property
  288. def has_volume_mute(self):
  289. """Check if device has Volume mute controls."""
  290. return self._supports('Mute')
  291. @property
  292. def is_volume_muted(self):
  293. """Boolean if volume is currently muted."""
  294. state_var = self._state_variable('RC', 'Mute')
  295. value = state_var.value
  296. if value is None:
  297. _LOGGER.debug('Got no value for Volume_mute')
  298. return None
  299. return value
  300. async def async_mute_volume(self, mute):
  301. """Mute the volume."""
  302. action = self._action('RC', 'SetMute')
  303. desired_mute = bool(mute)
  304. await action.async_call(InstanceID=0,
  305. Channel='Master',
  306. DesiredMute=desired_mute)
  307. # endregion
  308. # region AVT/Transport actions
  309. @property
  310. def has_pause(self):
  311. """Check if device has Pause controls."""
  312. return self._action('AVT', 'Pause') is not None
  313. @property
  314. def can_pause(self):
  315. """Check if the device can currently Pause."""
  316. return self.has_pause and \
  317. 'pause' in self._current_transport_actions
  318. async def async_pause(self):
  319. """Send pause command."""
  320. if 'pause' not in self._current_transport_actions:
  321. _LOGGER.debug('Cannot do Pause')
  322. return
  323. action = self._action('AVT', 'Pause')
  324. await action.async_call(InstanceID=0)
  325. @property
  326. def has_play(self):
  327. """Check if device has Play controls."""
  328. return self._action('AVT', 'Play') is not None
  329. @property
  330. def can_play(self):
  331. """Check if the device can currently play."""
  332. return self.has_play and \
  333. 'play' in self._current_transport_actions
  334. async def async_play(self):
  335. """Send play command."""
  336. if 'play' not in self._current_transport_actions:
  337. _LOGGER.debug('Cannot do Play')
  338. return
  339. action = self._action('AVT', 'Play')
  340. await action.async_call(InstanceID=0, Speed='1')
  341. @property
  342. def can_stop(self):
  343. """Check if the device can currently stop."""
  344. return self.has_stop and \
  345. 'stop' in self._current_transport_actions
  346. @property
  347. def has_stop(self):
  348. """Check if device has Play controls."""
  349. return self._action('AVT', 'Stop') is not None
  350. async def async_stop(self):
  351. """Send stop command."""
  352. if 'stop' not in self._current_transport_actions:
  353. _LOGGER.debug('Cannot do Stop')
  354. return
  355. action = self._action('AVT', 'Stop')
  356. await action.async_call(InstanceID=0)
  357. @property
  358. def has_previous(self):
  359. """Check if device has Previous controls."""
  360. return self._action('AVT', 'Previous')
  361. @property
  362. def can_previous(self):
  363. """Check if the device can currently Previous."""
  364. return self.has_previous and \
  365. 'previous' in self._current_transport_actions
  366. async def async_previous(self):
  367. """Send previous track command."""
  368. if 'previous' not in self._current_transport_actions:
  369. _LOGGER.debug('Cannot do Previous')
  370. return
  371. action = self._action('AVT', 'Previous')
  372. await action.async_call(InstanceID=0)
  373. @property
  374. def has_next(self):
  375. """Check if device has Next controls."""
  376. return self._action('AVT', 'Next') is not None
  377. @property
  378. def can_next(self):
  379. """Check if the device can currently Next."""
  380. return self.has_next and \
  381. 'next' in self._current_transport_actions
  382. async def async_next(self):
  383. """Send next track command."""
  384. if 'next' not in self._current_transport_actions:
  385. _LOGGER.debug('Cannot do Next')
  386. return
  387. action = self._action('AVT', 'Next')
  388. await action.async_call(InstanceID=0)
  389. def _has_seek_with_mode(self, mode: str):
  390. """Check if device has Seek mode."""
  391. action = self._action('AVT', 'Seek')
  392. state_var = self._state_variable('AVT', 'A_ARG_TYPE_SeekMode')
  393. if action is None or state_var is None:
  394. return False
  395. seek_modes = [mode.lower().strip()
  396. for mode in self._state_variable('AVT', 'A_ARG_TYPE_SeekMode').allowed_values]
  397. return mode.lower() in seek_modes
  398. @property
  399. def has_seek_abs_time(self):
  400. """Check if device has Seek controls, by ABS_TIME."""
  401. return self._has_seek_with_mode('ABS_TIME')
  402. @property
  403. def can_seek_abs_time(self):
  404. """Check if the device can currently Seek with ABS_TIME."""
  405. return self.has_seek_abs_time and \
  406. 'seek' in self._current_transport_actions
  407. async def async_seek_abs_time(self, time: timedelta):
  408. """Send seek command with ABS_TIME."""
  409. if 'seek' not in self._current_transport_actions:
  410. _LOGGER.debug('Cannot do Seek by ABS_TIME')
  411. return
  412. target = _time_to_str(time)
  413. action = self._action('AVT', 'Seek')
  414. await action.async_call(InstanceID=0, Unit='ABS_TIME', Target=target)
  415. @property
  416. def has_seek_rel_time(self):
  417. """Check if device has Seek controls, by REL_TIME."""
  418. return self._has_seek_with_mode('REL_TIME')
  419. @property
  420. def can_seek_rel_time(self):
  421. """Check if the device can currently Seek with REL_TIME."""
  422. return self.has_seek_rel_time and \
  423. 'seek' in self._current_transport_actions
  424. async def async_seek_rel_time(self, time: timedelta):
  425. """Send seek command with REL_TIME."""
  426. if 'seek' not in self._current_transport_actions:
  427. _LOGGER.debug('Cannot do Seek by REL_TIME')
  428. return
  429. target = _time_to_str(time)
  430. action = self._action('AVT', 'Seek')
  431. await action.async_call(InstanceID=0, Unit='REL_TIME', Target=target)
  432. @property
  433. def has_play_media(self):
  434. """Check if device has Play controls."""
  435. return self._action('AVT', 'SetAVTransportURI') is not None
  436. async def async_set_transport_uri(self, media_url, media_title, mime_type, upnp_class):
  437. """Play a piece of media."""
  438. # escape media_url
  439. _LOGGER.debug('Set transport uri: %s', media_url)
  440. media_url_parts = urlparse(media_url)
  441. media_url = urlunparse([
  442. media_url_parts.scheme,
  443. media_url_parts.netloc,
  444. media_url_parts.path,
  445. None,
  446. quote_plus(media_url_parts.query),
  447. None])
  448. # queue media
  449. meta_data = await self._construct_play_media_metadata(media_url,
  450. media_title,
  451. mime_type,
  452. upnp_class)
  453. action = self._action('AVT', 'SetAVTransportURI')
  454. await action.async_call(InstanceID=0,
  455. CurrentURI=media_url,
  456. CurrentURIMetaData=meta_data)
  457. async def async_wait_for_can_play(self, max_wait_time=5):
  458. """Wait for play command to be ready."""
  459. loop_time = 0.25
  460. count = int(max_wait_time / loop_time)
  461. # wait for state variable AVT.AVTransportURI to change and
  462. for _ in range(count):
  463. if 'play' in self._current_transport_actions:
  464. break
  465. await asyncio.sleep(loop_time)
  466. else:
  467. _LOGGER.debug('break out of waiting game')
  468. async def _fetch_headers(self, url: str, headers: Mapping):
  469. """Do a HEAD/GET to get resources headers."""
  470. requester = self._device.requester
  471. # try a HEAD first
  472. status, headers, _ = await requester.async_http_request('HEAD',
  473. url,
  474. headers=headers,
  475. body_type='ignore')
  476. if 200 <= status < 300:
  477. return headers
  478. # then try a GET
  479. status, headers, _ = await requester.async_http_request('GET',
  480. url,
  481. headers=headers,
  482. body_type='ignore')
  483. if 200 <= status < 300:
  484. return headers
  485. return None
  486. async def _construct_play_media_metadata(self, media_url, media_title, mime_type, upnp_class):
  487. """Construct the metadata for play_media command."""
  488. media_info = {
  489. 'mime_type': mime_type,
  490. 'dlna_features': 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;'
  491. 'DLNA.ORG_FLAGS=00000000000000000000000000000000',
  492. }
  493. # do a HEAD/GET, to retrieve content-type/mime-type
  494. try:
  495. headers = await self._fetch_headers(media_url, {'GetContentFeatures.dlna.org': '1'})
  496. if headers:
  497. if 'Content-Type' in headers:
  498. media_info['mime_type'] = headers['Content-Type']
  499. if 'ContentFeatures.dlna.org' in headers:
  500. media_info['dlna_features'] = headers['contentFeatures.dlna.org']
  501. except Exception: # pylint: disable=broad-except
  502. pass
  503. # build DIDL-Lite item + resource
  504. protocol_info = "http-get:*:{mime_type}:{dlna_features}".format(**media_info)
  505. resource = didl_lite.Resource(uri=media_url, protocol_info=protocol_info)
  506. didl_item_type = didl_lite.type_by_upnp_class(upnp_class)
  507. item = didl_item_type(id="0", parent_id="0", title=media_title,
  508. restricted="1", resources=[resource])
  509. return didl_lite.to_xml_string(item).decode('utf-8')
  510. # endregion
  511. # region AVT/Media info
  512. @property
  513. def media_title(self):
  514. """Title of current playing media."""
  515. state_var = self._state_variable('AVT', 'CurrentTrackMetaData')
  516. if state_var is None:
  517. return None
  518. xml = state_var.value
  519. if not xml or xml == 'NOT_IMPLEMENTED':
  520. return None
  521. items = didl_lite.from_xml_string(xml)
  522. if not items:
  523. return None
  524. item = items[0]
  525. return item.title
  526. @property
  527. def media_image_url(self):
  528. """Image url of current playing media."""
  529. state_var = self._state_variable('AVT', 'CurrentTrackMetaData')
  530. if state_var is None:
  531. return None
  532. xml = state_var.value
  533. if not xml or xml == 'NOT_IMPLEMENTED':
  534. return None
  535. items = didl_lite.from_xml_string(xml)
  536. if not items:
  537. return None
  538. for item in items:
  539. # Some players use Item.albumArtURI,
  540. # though not found in the UPnP-av-ConnectionManager-v1-Service spec.
  541. if hasattr(item, 'album_art_uri'):
  542. return _absolute_url(self._device, item.album_art_uri)
  543. for res in item.resources:
  544. protocol_info = res.protocol_info
  545. if protocol_info.startswith('http-get:*:image/'):
  546. return _absolute_url(self._device, res.url)
  547. return None
  548. @property
  549. def media_duration(self):
  550. """Duration of current playing media in seconds."""
  551. state_var = self._state_variable('AVT', 'CurrentTrackDuration')
  552. if state_var is None or \
  553. state_var.value is None or \
  554. state_var.value == 'NOT_IMPLEMENTED':
  555. return None
  556. time = _str_to_time(state_var.value)
  557. if time is None:
  558. return None
  559. return time.seconds
  560. @property
  561. def media_position(self):
  562. """Position of current playing media in seconds."""
  563. state_var = self._state_variable('AVT', 'RelativeTimePosition')
  564. if state_var is None or \
  565. state_var.value is None or \
  566. state_var.value == 'NOT_IMPLEMENTED':
  567. return None
  568. time = _str_to_time(state_var.value)
  569. if time is None:
  570. return None
  571. return time.seconds
  572. @property
  573. def media_position_updated_at(self):
  574. """
  575. When was the position of the current playing media valid.
  576. Returns value from homeassistant.util.dt.utcnow().
  577. """
  578. state_var = self._state_variable('AVT', 'RelativeTimePosition')
  579. if state_var is None:
  580. return None
  581. return state_var.updated_at
  582. # endregion