730 lines
24 KiB
Python
730 lines
24 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
"""UPnP DLNA module."""
|
||
|
|
||
|
import asyncio
|
||
|
import logging
|
||
|
import re
|
||
|
|
||
|
from datetime import timedelta
|
||
|
|
||
|
from typing import Any
|
||
|
from typing import Dict
|
||
|
from typing import List
|
||
|
from typing import Mapping
|
||
|
from typing import Optional
|
||
|
|
||
|
from xml.sax import parseString
|
||
|
from xml.sax.handler import ContentHandler
|
||
|
from xml.sax.handler import ErrorHandler
|
||
|
|
||
|
from urllib.parse import quote_plus
|
||
|
from urllib.parse import urljoin
|
||
|
from urllib.parse import urlparse
|
||
|
from urllib.parse import urlunparse
|
||
|
|
||
|
from didl_lite import didl_lite
|
||
|
|
||
|
from async_upnp_client import UpnpDevice
|
||
|
from async_upnp_client import UpnpError
|
||
|
from async_upnp_client import UpnpService
|
||
|
from async_upnp_client import UpnpStateVariable
|
||
|
from async_upnp_client.profile import UpnpProfileDevice
|
||
|
|
||
|
|
||
|
_LOGGER = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
STATE_ON = 'ON'
|
||
|
STATE_PLAYING = 'PLAYING'
|
||
|
STATE_PAUSED = 'PAUSED'
|
||
|
STATE_IDLE = 'IDLE'
|
||
|
|
||
|
|
||
|
def _time_to_str(time: timedelta) -> str:
|
||
|
"""Convert timedelta to str/units."""
|
||
|
total_seconds = abs(time.total_seconds())
|
||
|
target = {
|
||
|
'sign': '-' if time.total_seconds() < 0 else '',
|
||
|
'hours': int(total_seconds // 3600),
|
||
|
'minutes': int(total_seconds // 60),
|
||
|
'seconds': int(total_seconds % 60),
|
||
|
}
|
||
|
return '{sign}{hours}:{minutes}:{seconds}'.format(**target)
|
||
|
|
||
|
|
||
|
def _str_to_time(string: str) -> timedelta:
|
||
|
"""Convert a string to timedelta."""
|
||
|
regexp = r"(?P<sign>[-+])?(?P<h>\d+):(?P<m>\d+):(?P<s>\d+)\.?(?P<ms>\d+)?"
|
||
|
match = re.match(regexp, string)
|
||
|
if not match:
|
||
|
return None
|
||
|
|
||
|
sign = -1 if match.group('sign') == '-' else 1
|
||
|
hours = int(match.group('h'))
|
||
|
minutes = int(match.group('m'))
|
||
|
seconds = int(match.group('s'))
|
||
|
if match.group('ms'):
|
||
|
msec = int(match.group('ms'))
|
||
|
else:
|
||
|
msec = 0
|
||
|
return sign * timedelta(hours=hours, minutes=minutes, seconds=seconds, milliseconds=msec)
|
||
|
|
||
|
|
||
|
def _absolute_url(device: UpnpDevice, url: str) -> str:
|
||
|
"""
|
||
|
Convert a relative URL to an absolute URL pointing at device.
|
||
|
|
||
|
If url is already an absolute url (i.e., starts with http:/https:),
|
||
|
then the url itself is returned.
|
||
|
"""
|
||
|
if url.startswith('http:') or \
|
||
|
url.startswith('https:'):
|
||
|
return url
|
||
|
|
||
|
return urljoin(device.device_url, url)
|
||
|
|
||
|
|
||
|
class DlnaDmrEventContentHandler(ContentHandler):
|
||
|
"""Content Handler to parse DLNA DMR Event data."""
|
||
|
|
||
|
def __init__(self):
|
||
|
"""Initializer."""
|
||
|
super().__init__()
|
||
|
self.changes = {}
|
||
|
self._current_instance = None
|
||
|
|
||
|
def startElement(self, name, attrs):
|
||
|
"""Handle startElement."""
|
||
|
if 'val' not in attrs:
|
||
|
return
|
||
|
|
||
|
if name == 'InstanceID':
|
||
|
self._current_instance = attrs.get('val', '0')
|
||
|
else:
|
||
|
current_instance = self._current_instance or "0" # safety
|
||
|
if current_instance not in self.changes:
|
||
|
self.changes[current_instance] = {}
|
||
|
|
||
|
self.changes[current_instance][name] = attrs.get('val')
|
||
|
|
||
|
def endElement(self, name):
|
||
|
"""Handle endElement."""
|
||
|
self._current_instance = None
|
||
|
|
||
|
|
||
|
class DlnaDmrEventErrorHandler(ErrorHandler):
|
||
|
"""Error handler which ignores errors."""
|
||
|
|
||
|
def error(self, exception):
|
||
|
"""Handle error."""
|
||
|
_LOGGER.debug("Error during parsing: %s", exception)
|
||
|
|
||
|
def fatalError(self, exception):
|
||
|
"""Handle error."""
|
||
|
_LOGGER.debug("Fatal error during parsing: %s", exception)
|
||
|
|
||
|
|
||
|
def _parse_last_change_event(text: str) -> Dict[str, Dict[str, str]]:
|
||
|
"""
|
||
|
Parse a LastChange event.
|
||
|
|
||
|
:param text Text to parse.
|
||
|
|
||
|
:return Dict per Instance, containing changed state variables with values.
|
||
|
"""
|
||
|
content_handler = DlnaDmrEventContentHandler()
|
||
|
error_handler = DlnaDmrEventErrorHandler()
|
||
|
parseString(text, content_handler, error_handler)
|
||
|
return content_handler.changes
|
||
|
|
||
|
|
||
|
def dlna_handle_notify_last_change(state_var: UpnpStateVariable):
|
||
|
"""
|
||
|
Handle changes to LastChange state variable.
|
||
|
|
||
|
This expands all changed state variables in the LastChange state variable.
|
||
|
Note that the callback is called twice:
|
||
|
- for the original event;
|
||
|
- for the expanded event, via this function.
|
||
|
"""
|
||
|
if state_var.name != 'LastChange':
|
||
|
raise UpnpError('Call this only on state variable LastChange')
|
||
|
|
||
|
service = state_var.service
|
||
|
event_data = state_var.value
|
||
|
changes = _parse_last_change_event(event_data)
|
||
|
if '0' not in changes:
|
||
|
_LOGGER.warning('Only InstanceID 0 is supported')
|
||
|
return
|
||
|
|
||
|
changes_0 = changes['0']
|
||
|
service.notify_changed_state_variables(changes_0)
|
||
|
|
||
|
|
||
|
class DmrDevice(UpnpProfileDevice):
|
||
|
"""Representation of a DLNA DMR device."""
|
||
|
|
||
|
# pylint: disable=too-many-public-methods
|
||
|
|
||
|
DEVICE_TYPES = [
|
||
|
'urn:schemas-upnp-org:device:MediaRenderer:1',
|
||
|
'urn:schemas-upnp-org:device:MediaRenderer:2',
|
||
|
'urn:schemas-upnp-org:device:MediaRenderer:3',
|
||
|
]
|
||
|
|
||
|
_SERVICE_TYPES = {
|
||
|
'RC': {
|
||
|
'urn:schemas-upnp-org:service:RenderingControl:3',
|
||
|
'urn:schemas-upnp-org:service:RenderingControl:2',
|
||
|
'urn:schemas-upnp-org:service:RenderingControl:1',
|
||
|
},
|
||
|
'AVT': {
|
||
|
'urn:schemas-upnp-org:service:AVTransport:3',
|
||
|
'urn:schemas-upnp-org:service:AVTransport:2',
|
||
|
'urn:schemas-upnp-org:service:AVTransport:1',
|
||
|
},
|
||
|
}
|
||
|
|
||
|
async def async_update(self):
|
||
|
"""Retrieve the latest data."""
|
||
|
# call GetTransportInfo/GetPositionInfo regularly
|
||
|
avt_service = self._service('AVT')
|
||
|
if avt_service:
|
||
|
await self._async_poll_transport_info()
|
||
|
|
||
|
if self.state == STATE_PLAYING or \
|
||
|
self.state == STATE_PAUSED:
|
||
|
# playing something, get position info
|
||
|
await self._async_poll_position_info()
|
||
|
else:
|
||
|
await self._device.async_ping()
|
||
|
|
||
|
async def _async_poll_transport_info(self):
|
||
|
"""Update transport info from device."""
|
||
|
action = self._action('AVT', 'GetTransportInfo')
|
||
|
result = await action.async_call(InstanceID=0)
|
||
|
|
||
|
# set/update state_variable 'TransportState'
|
||
|
changed = []
|
||
|
state_var = self._state_variable('AVT', 'TransportState')
|
||
|
if state_var.value != result['CurrentTransportState']:
|
||
|
state_var.value = result['CurrentTransportState']
|
||
|
changed.append(state_var)
|
||
|
|
||
|
service = action.service
|
||
|
self._on_event(service, changed)
|
||
|
|
||
|
async def _async_poll_position_info(self):
|
||
|
"""Update position info."""
|
||
|
action = self._action('AVT', 'GetPositionInfo')
|
||
|
result = await action.async_call(InstanceID=0)
|
||
|
|
||
|
changed = []
|
||
|
track_duration = self._state_variable('AVT', 'CurrentTrackDuration')
|
||
|
if track_duration.value != result['TrackDuration']:
|
||
|
track_duration.value = result['TrackDuration']
|
||
|
changed.append(track_duration)
|
||
|
|
||
|
time_position = self._state_variable('AVT', 'RelativeTimePosition')
|
||
|
if time_position.value != result['RelTime']:
|
||
|
time_position.value = result['RelTime']
|
||
|
changed.append(time_position)
|
||
|
|
||
|
service = action.service
|
||
|
self._on_event(service, changed)
|
||
|
|
||
|
def _on_event(self, service: UpnpService, state_variables: List[UpnpStateVariable]):
|
||
|
"""State variable(s) changed, let home-assistant know."""
|
||
|
# handle DLNA specific event
|
||
|
for state_variable in state_variables:
|
||
|
if state_variable.name == 'LastChange':
|
||
|
dlna_handle_notify_last_change(state_variable)
|
||
|
|
||
|
if self.on_event:
|
||
|
# pylint: disable=not-callable
|
||
|
self.on_event(service, state_variables)
|
||
|
|
||
|
@property
|
||
|
def state(self):
|
||
|
"""Get current state."""
|
||
|
state_var = self._state_variable('AVT', 'TransportState')
|
||
|
if not state_var:
|
||
|
return STATE_ON
|
||
|
|
||
|
state_value = (state_var.value or '').strip().lower()
|
||
|
if state_value == 'playing':
|
||
|
return STATE_PLAYING
|
||
|
if state_value == 'paused':
|
||
|
return STATE_PAUSED
|
||
|
|
||
|
return STATE_IDLE
|
||
|
|
||
|
@property
|
||
|
def _current_transport_actions(self):
|
||
|
state_var = self._state_variable('AVT', 'CurrentTransportActions')
|
||
|
transport_actions = (state_var.value or '').split(',')
|
||
|
return [a.lower().strip() for a in transport_actions]
|
||
|
|
||
|
def _supports(self, var_name: str) -> bool:
|
||
|
return self._state_variable('RC', var_name) is not None and \
|
||
|
self._action('RC', 'Set%s' % var_name) is not None
|
||
|
|
||
|
def _level(self, var_name: str) -> Optional[float]:
|
||
|
state_var = self._state_variable('RC', var_name)
|
||
|
value = state_var.value
|
||
|
if value is None:
|
||
|
_LOGGER.debug('Got no value for %s', var_name)
|
||
|
return None
|
||
|
|
||
|
max_value = state_var.max_value or 100
|
||
|
return min(value / max_value, 1.0)
|
||
|
|
||
|
async def _async_set_level(self, var_name: str, level: float, **kwargs: Dict[str, Any]) -> None:
|
||
|
action = self._action('RC', 'Set%s' % var_name)
|
||
|
argument = action.argument('Desired%s' % var_name)
|
||
|
state_variable = argument.related_state_variable
|
||
|
min_ = state_variable.min_value or 0
|
||
|
max_ = state_variable.max_value or 100
|
||
|
desired_level = int(min_ + level * (max_ - min_))
|
||
|
args = kwargs.copy()
|
||
|
args.update({'Desired%s' % var_name: desired_level})
|
||
|
|
||
|
await action.async_call(InstanceID=0, **args)
|
||
|
|
||
|
# region RC/Picture
|
||
|
@property
|
||
|
def has_brightness_level(self) -> bool:
|
||
|
"""Check if device has brightness level controls."""
|
||
|
return self._supports('Brightness')
|
||
|
|
||
|
@property
|
||
|
def brightness_level(self) -> Optional[float]:
|
||
|
"""Brightness level of the media player (0..1)."""
|
||
|
return self._level('Brightness')
|
||
|
|
||
|
async def async_set_brightness_level(self, brightness: float) -> None:
|
||
|
"""Set brightness level, range 0..1."""
|
||
|
await self._async_set_level('Brightness', brightness)
|
||
|
|
||
|
@property
|
||
|
def has_contrast_level(self) -> bool:
|
||
|
"""Check if device has contrast level controls."""
|
||
|
return self._supports('Contrast')
|
||
|
|
||
|
@property
|
||
|
def contrast_level(self) -> Optional[float]:
|
||
|
"""Contrast level of the media player (0..1)."""
|
||
|
return self._level('Contrast')
|
||
|
|
||
|
async def async_set_contrast_level(self, contrast: float) -> None:
|
||
|
"""Set contrast level, range 0..1."""
|
||
|
await self._async_set_level('Contrast', contrast)
|
||
|
|
||
|
@property
|
||
|
def has_sharpness_level(self) -> bool:
|
||
|
"""Check if device has sharpness level controls."""
|
||
|
return self._supports('Sharpness')
|
||
|
|
||
|
@property
|
||
|
def sharpness_level(self) -> Optional[float]:
|
||
|
"""Sharpness level of the media player (0..1)."""
|
||
|
return self._level('Sharpness')
|
||
|
|
||
|
async def async_set_sharpness_level(self, sharpness: float) -> None:
|
||
|
"""Set sharpness level, range 0..1."""
|
||
|
await self._async_set_level('Sharpness', sharpness)
|
||
|
|
||
|
@property
|
||
|
def has_color_temperature_level(self) -> bool:
|
||
|
"""Check if device has color temperature level controls."""
|
||
|
return self._supports('ColorTemperature')
|
||
|
|
||
|
@property
|
||
|
def color_temperature_level(self) -> Optional[float]:
|
||
|
"""Color temperature level of the media player (0..1)."""
|
||
|
return self._level('ColorTemperature')
|
||
|
|
||
|
async def async_set_color_temperature_level(self, color_temperature: float):
|
||
|
"""Set color temperature level, range 0..1."""
|
||
|
# pylint: disable=invalid-name
|
||
|
await self._async_set_level('ColorTemperature', color_temperature)
|
||
|
# endregion
|
||
|
|
||
|
# region RC/Volume
|
||
|
@property
|
||
|
def has_volume_level(self):
|
||
|
"""Check if device has Volume level controls."""
|
||
|
return self._supports('Volume')
|
||
|
|
||
|
@property
|
||
|
def volume_level(self):
|
||
|
"""Volume level of the media player (0..1)."""
|
||
|
return self._level('Volume')
|
||
|
|
||
|
async def async_set_volume_level(self, volume: float):
|
||
|
"""Set volume level, range 0..1."""
|
||
|
await self._async_set_level('Volume', volume, Channel='Master')
|
||
|
|
||
|
@property
|
||
|
def has_volume_mute(self):
|
||
|
"""Check if device has Volume mute controls."""
|
||
|
return self._supports('Mute')
|
||
|
|
||
|
@property
|
||
|
def is_volume_muted(self):
|
||
|
"""Boolean if volume is currently muted."""
|
||
|
state_var = self._state_variable('RC', 'Mute')
|
||
|
value = state_var.value
|
||
|
if value is None:
|
||
|
_LOGGER.debug('Got no value for Volume_mute')
|
||
|
return None
|
||
|
|
||
|
return value
|
||
|
|
||
|
async def async_mute_volume(self, mute):
|
||
|
"""Mute the volume."""
|
||
|
action = self._action('RC', 'SetMute')
|
||
|
desired_mute = bool(mute)
|
||
|
|
||
|
await action.async_call(InstanceID=0,
|
||
|
Channel='Master',
|
||
|
DesiredMute=desired_mute)
|
||
|
# endregion
|
||
|
|
||
|
# region AVT/Transport actions
|
||
|
@property
|
||
|
def has_pause(self):
|
||
|
"""Check if device has Pause controls."""
|
||
|
return self._action('AVT', 'Pause') is not None
|
||
|
|
||
|
@property
|
||
|
def can_pause(self):
|
||
|
"""Check if the device can currently Pause."""
|
||
|
return self.has_pause and \
|
||
|
'pause' in self._current_transport_actions
|
||
|
|
||
|
async def async_pause(self):
|
||
|
"""Send pause command."""
|
||
|
if 'pause' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Pause')
|
||
|
return
|
||
|
|
||
|
action = self._action('AVT', 'Pause')
|
||
|
await action.async_call(InstanceID=0)
|
||
|
|
||
|
@property
|
||
|
def has_play(self):
|
||
|
"""Check if device has Play controls."""
|
||
|
return self._action('AVT', 'Play') is not None
|
||
|
|
||
|
@property
|
||
|
def can_play(self):
|
||
|
"""Check if the device can currently play."""
|
||
|
return self.has_play and \
|
||
|
'play' in self._current_transport_actions
|
||
|
|
||
|
async def async_play(self):
|
||
|
"""Send play command."""
|
||
|
if 'play' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Play')
|
||
|
return
|
||
|
|
||
|
action = self._action('AVT', 'Play')
|
||
|
await action.async_call(InstanceID=0, Speed='1')
|
||
|
|
||
|
@property
|
||
|
def can_stop(self):
|
||
|
"""Check if the device can currently stop."""
|
||
|
return self.has_stop and \
|
||
|
'stop' in self._current_transport_actions
|
||
|
|
||
|
@property
|
||
|
def has_stop(self):
|
||
|
"""Check if device has Play controls."""
|
||
|
return self._action('AVT', 'Stop') is not None
|
||
|
|
||
|
async def async_stop(self):
|
||
|
"""Send stop command."""
|
||
|
if 'stop' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Stop')
|
||
|
return
|
||
|
|
||
|
action = self._action('AVT', 'Stop')
|
||
|
await action.async_call(InstanceID=0)
|
||
|
|
||
|
@property
|
||
|
def has_previous(self):
|
||
|
"""Check if device has Previous controls."""
|
||
|
return self._action('AVT', 'Previous')
|
||
|
|
||
|
@property
|
||
|
def can_previous(self):
|
||
|
"""Check if the device can currently Previous."""
|
||
|
return self.has_previous and \
|
||
|
'previous' in self._current_transport_actions
|
||
|
|
||
|
async def async_previous(self):
|
||
|
"""Send previous track command."""
|
||
|
if 'previous' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Previous')
|
||
|
return
|
||
|
|
||
|
action = self._action('AVT', 'Previous')
|
||
|
await action.async_call(InstanceID=0)
|
||
|
|
||
|
@property
|
||
|
def has_next(self):
|
||
|
"""Check if device has Next controls."""
|
||
|
return self._action('AVT', 'Next') is not None
|
||
|
|
||
|
@property
|
||
|
def can_next(self):
|
||
|
"""Check if the device can currently Next."""
|
||
|
return self.has_next and \
|
||
|
'next' in self._current_transport_actions
|
||
|
|
||
|
async def async_next(self):
|
||
|
"""Send next track command."""
|
||
|
if 'next' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Next')
|
||
|
return
|
||
|
|
||
|
action = self._action('AVT', 'Next')
|
||
|
await action.async_call(InstanceID=0)
|
||
|
|
||
|
def _has_seek_with_mode(self, mode: str):
|
||
|
"""Check if device has Seek mode."""
|
||
|
action = self._action('AVT', 'Seek')
|
||
|
state_var = self._state_variable('AVT', 'A_ARG_TYPE_SeekMode')
|
||
|
if action is None or state_var is None:
|
||
|
return False
|
||
|
|
||
|
seek_modes = [mode.lower().strip()
|
||
|
for mode in self._state_variable('AVT', 'A_ARG_TYPE_SeekMode').allowed_values]
|
||
|
return mode.lower() in seek_modes
|
||
|
|
||
|
@property
|
||
|
def has_seek_abs_time(self):
|
||
|
"""Check if device has Seek controls, by ABS_TIME."""
|
||
|
return self._has_seek_with_mode('ABS_TIME')
|
||
|
|
||
|
@property
|
||
|
def can_seek_abs_time(self):
|
||
|
"""Check if the device can currently Seek with ABS_TIME."""
|
||
|
return self.has_seek_abs_time and \
|
||
|
'seek' in self._current_transport_actions
|
||
|
|
||
|
async def async_seek_abs_time(self, time: timedelta):
|
||
|
"""Send seek command with ABS_TIME."""
|
||
|
if 'seek' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Seek by ABS_TIME')
|
||
|
return
|
||
|
|
||
|
target = _time_to_str(time)
|
||
|
action = self._action('AVT', 'Seek')
|
||
|
await action.async_call(InstanceID=0, Unit='ABS_TIME', Target=target)
|
||
|
|
||
|
@property
|
||
|
def has_seek_rel_time(self):
|
||
|
"""Check if device has Seek controls, by REL_TIME."""
|
||
|
return self._has_seek_with_mode('REL_TIME')
|
||
|
|
||
|
@property
|
||
|
def can_seek_rel_time(self):
|
||
|
"""Check if the device can currently Seek with REL_TIME."""
|
||
|
return self.has_seek_rel_time and \
|
||
|
'seek' in self._current_transport_actions
|
||
|
|
||
|
async def async_seek_rel_time(self, time: timedelta):
|
||
|
"""Send seek command with REL_TIME."""
|
||
|
if 'seek' not in self._current_transport_actions:
|
||
|
_LOGGER.debug('Cannot do Seek by REL_TIME')
|
||
|
return
|
||
|
|
||
|
target = _time_to_str(time)
|
||
|
action = self._action('AVT', 'Seek')
|
||
|
await action.async_call(InstanceID=0, Unit='REL_TIME', Target=target)
|
||
|
|
||
|
@property
|
||
|
def has_play_media(self):
|
||
|
"""Check if device has Play controls."""
|
||
|
return self._action('AVT', 'SetAVTransportURI') is not None
|
||
|
|
||
|
async def async_set_transport_uri(self, media_url, media_title, mime_type, upnp_class):
|
||
|
"""Play a piece of media."""
|
||
|
# escape media_url
|
||
|
_LOGGER.debug('Set transport uri: %s', media_url)
|
||
|
media_url_parts = urlparse(media_url)
|
||
|
media_url = urlunparse([
|
||
|
media_url_parts.scheme,
|
||
|
media_url_parts.netloc,
|
||
|
media_url_parts.path,
|
||
|
None,
|
||
|
quote_plus(media_url_parts.query),
|
||
|
None])
|
||
|
|
||
|
# queue media
|
||
|
meta_data = await self._construct_play_media_metadata(media_url,
|
||
|
media_title,
|
||
|
mime_type,
|
||
|
upnp_class)
|
||
|
action = self._action('AVT', 'SetAVTransportURI')
|
||
|
await action.async_call(InstanceID=0,
|
||
|
CurrentURI=media_url,
|
||
|
CurrentURIMetaData=meta_data)
|
||
|
|
||
|
async def async_wait_for_can_play(self, max_wait_time=5):
|
||
|
"""Wait for play command to be ready."""
|
||
|
loop_time = 0.25
|
||
|
count = int(max_wait_time / loop_time)
|
||
|
# wait for state variable AVT.AVTransportURI to change and
|
||
|
for _ in range(count):
|
||
|
if 'play' in self._current_transport_actions:
|
||
|
break
|
||
|
await asyncio.sleep(loop_time)
|
||
|
else:
|
||
|
_LOGGER.debug('break out of waiting game')
|
||
|
|
||
|
async def _fetch_headers(self, url: str, headers: Mapping):
|
||
|
"""Do a HEAD/GET to get resources headers."""
|
||
|
requester = self._device.requester
|
||
|
|
||
|
# try a HEAD first
|
||
|
status, headers, _ = await requester.async_http_request('HEAD',
|
||
|
url,
|
||
|
headers=headers,
|
||
|
body_type='ignore')
|
||
|
if 200 <= status < 300:
|
||
|
return headers
|
||
|
|
||
|
# then try a GET
|
||
|
status, headers, _ = await requester.async_http_request('GET',
|
||
|
url,
|
||
|
headers=headers,
|
||
|
body_type='ignore')
|
||
|
if 200 <= status < 300:
|
||
|
return headers
|
||
|
|
||
|
return None
|
||
|
|
||
|
async def _construct_play_media_metadata(self, media_url, media_title, mime_type, upnp_class):
|
||
|
"""Construct the metadata for play_media command."""
|
||
|
media_info = {
|
||
|
'mime_type': mime_type,
|
||
|
'dlna_features': 'DLNA.ORG_OP=01;DLNA.ORG_CI=0;'
|
||
|
'DLNA.ORG_FLAGS=00000000000000000000000000000000',
|
||
|
}
|
||
|
|
||
|
# do a HEAD/GET, to retrieve content-type/mime-type
|
||
|
try:
|
||
|
headers = await self._fetch_headers(media_url, {'GetContentFeatures.dlna.org': '1'})
|
||
|
if headers:
|
||
|
if 'Content-Type' in headers:
|
||
|
media_info['mime_type'] = headers['Content-Type']
|
||
|
|
||
|
if 'ContentFeatures.dlna.org' in headers:
|
||
|
media_info['dlna_features'] = headers['contentFeatures.dlna.org']
|
||
|
except Exception: # pylint: disable=broad-except
|
||
|
pass
|
||
|
|
||
|
# build DIDL-Lite item + resource
|
||
|
protocol_info = "http-get:*:{mime_type}:{dlna_features}".format(**media_info)
|
||
|
resource = didl_lite.Resource(uri=media_url, protocol_info=protocol_info)
|
||
|
didl_item_type = didl_lite.type_by_upnp_class(upnp_class)
|
||
|
item = didl_item_type(id="0", parent_id="0", title=media_title,
|
||
|
restricted="1", resources=[resource])
|
||
|
|
||
|
return didl_lite.to_xml_string(item).decode('utf-8')
|
||
|
# endregion
|
||
|
|
||
|
# region AVT/Media info
|
||
|
@property
|
||
|
def media_title(self):
|
||
|
"""Title of current playing media."""
|
||
|
state_var = self._state_variable('AVT', 'CurrentTrackMetaData')
|
||
|
if state_var is None:
|
||
|
return None
|
||
|
|
||
|
xml = state_var.value
|
||
|
if not xml or xml == 'NOT_IMPLEMENTED':
|
||
|
return None
|
||
|
|
||
|
items = didl_lite.from_xml_string(xml)
|
||
|
if not items:
|
||
|
return None
|
||
|
|
||
|
item = items[0]
|
||
|
return item.title
|
||
|
|
||
|
@property
|
||
|
def media_image_url(self):
|
||
|
"""Image url of current playing media."""
|
||
|
state_var = self._state_variable('AVT', 'CurrentTrackMetaData')
|
||
|
if state_var is None:
|
||
|
return None
|
||
|
|
||
|
xml = state_var.value
|
||
|
if not xml or xml == 'NOT_IMPLEMENTED':
|
||
|
return None
|
||
|
|
||
|
items = didl_lite.from_xml_string(xml)
|
||
|
if not items:
|
||
|
return None
|
||
|
|
||
|
for item in items:
|
||
|
# Some players use Item.albumArtURI,
|
||
|
# though not found in the UPnP-av-ConnectionManager-v1-Service spec.
|
||
|
if hasattr(item, 'album_art_uri'):
|
||
|
return _absolute_url(self._device, item.album_art_uri)
|
||
|
|
||
|
for res in item.resources:
|
||
|
protocol_info = res.protocol_info
|
||
|
if protocol_info.startswith('http-get:*:image/'):
|
||
|
return _absolute_url(self._device, res.url)
|
||
|
|
||
|
return None
|
||
|
|
||
|
@property
|
||
|
def media_duration(self):
|
||
|
"""Duration of current playing media in seconds."""
|
||
|
state_var = self._state_variable('AVT', 'CurrentTrackDuration')
|
||
|
if state_var is None or \
|
||
|
state_var.value is None or \
|
||
|
state_var.value == 'NOT_IMPLEMENTED':
|
||
|
return None
|
||
|
|
||
|
time = _str_to_time(state_var.value)
|
||
|
if time is None:
|
||
|
return None
|
||
|
|
||
|
return time.seconds
|
||
|
|
||
|
@property
|
||
|
def media_position(self):
|
||
|
"""Position of current playing media in seconds."""
|
||
|
state_var = self._state_variable('AVT', 'RelativeTimePosition')
|
||
|
if state_var is None or \
|
||
|
state_var.value is None or \
|
||
|
state_var.value == 'NOT_IMPLEMENTED':
|
||
|
return None
|
||
|
|
||
|
time = _str_to_time(state_var.value)
|
||
|
if time is None:
|
||
|
return None
|
||
|
|
||
|
return time.seconds
|
||
|
|
||
|
@property
|
||
|
def media_position_updated_at(self):
|
||
|
"""
|
||
|
When was the position of the current playing media valid.
|
||
|
|
||
|
Returns value from homeassistant.util.dt.utcnow().
|
||
|
"""
|
||
|
state_var = self._state_variable('AVT', 'RelativeTimePosition')
|
||
|
if state_var is None:
|
||
|
return None
|
||
|
|
||
|
return state_var.updated_at
|
||
|
# endregion
|