diff --git a/.coveragerc b/.coveragerc index f42bd5cd3db6e7..1e358cd779158d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -513,6 +513,7 @@ omit = homeassistant/components/media_player/denon.py homeassistant/components/media_player/denonavr.py homeassistant/components/media_player/directv.py + homeassistant/components/media_player/dlna_dmr.py homeassistant/components/media_player/dunehd.py homeassistant/components/media_player/emby.py homeassistant/components/media_player/epson.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 78b891bae922ae..8877f05f622587 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -85,6 +85,7 @@ 'volumio': ('media_player', 'volumio'), 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), 'freebox': ('device_tracker', 'freebox'), + 'DLNA': ('media_player', 'dlna_dmr') } OPTIONAL_SERVICE_HANDLERS = { diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py new file mode 100644 index 00000000000000..98cd865b703f2d --- /dev/null +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +""" +Support for DLNA DMR (Device Media Renderer). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.dlna_dmr/ +""" + +import asyncio +import functools +import logging +from datetime import datetime + +import aiohttp +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + MediaPlayerDevice, + PLATFORM_SCHEMA) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + CONF_URL, CONF_NAME, + STATE_OFF, STATE_ON, STATE_IDLE, STATE_PLAYING, STATE_PAUSED) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import get_local_ip + + +DLNA_DMR_DATA = 'dlna_dmr' + +REQUIREMENTS = [ + 'async-upnp-client==0.12.2', +] + +DEFAULT_NAME = 'DLNA Digital Media Renderer' +DEFAULT_LISTEN_PORT = 8301 + +CONF_LISTEN_IP = 'listen_ip' +CONF_LISTEN_PORT = 'listen_port' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +HOME_ASSISTANT_UPNP_CLASS_MAPPING = { + 'music': 'object.item.audioItem', + 'tvshow': 'object.item.videoItem', + 'video': 'object.item.videoItem', + 'episode': 'object.item.videoItem', + 'channel': 'object.item.videoItem', + 'playlist': 'object.item.playlist', +} +HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = { + 'music': 'audio/*', + 'tvshow': 'video/*', + 'video': 'video/*', + 'episode': 'video/*', + 'channel': 'video/*', + 'playlist': 'playlist/*', +} +UPNP_DEVICE_MEDIA_RENDERER = [ + 'urn:schemas-upnp-org:device:MediaRenderer:1', + 'urn:schemas-upnp-org:device:MediaRenderer:2', + 'urn:schemas-upnp-org:device:MediaRenderer:3', +] + +_LOGGER = logging.getLogger(__name__) + + +def catch_request_errors(): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + def call_wrapper(func): + """Call wrapper for decorator.""" + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + try: + return func(self, *args, **kwargs) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Error during call %s", func.__name__) + + return wrapper + + return call_wrapper + + +async def async_start_event_handler(hass, server_host, server_port, requester): + """Register notify view.""" + hass_data = hass.data[DLNA_DMR_DATA] + if 'event_handler' in hass_data: + return hass_data['event_handler'] + + # start event handler + from async_upnp_client.aiohttp import AiohttpNotifyServer + server = AiohttpNotifyServer(requester, + server_port, + server_host, + hass.loop) + await server.start_server() + _LOGGER.info('UPNP/DLNA event handler listening on: %s', + server.callback_url) + hass_data['notify_server'] = server + hass_data['event_handler'] = server.event_handler + + # register for graceful shutdown + async def async_stop_server(event): + """Stop server.""" + _LOGGER.debug('Stopping UPNP/DLNA event handler') + await server.stop_server() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) + + return hass_data['event_handler'] + + +async def async_setup_platform(hass: HomeAssistant, + config, + async_add_devices, + discovery_info=None): + """Set up DLNA DMR platform.""" + # ensure this is a DLNA DMR device, if found via discovery + if discovery_info and \ + 'upnp_device_type' in discovery_info and \ + discovery_info['upnp_device_type'] not in UPNP_DEVICE_MEDIA_RENDERER: + _LOGGER.debug('Device is not a MediaRenderer: %s, device_type: %s', + discovery_info.get('ssdp_description'), + discovery_info['upnp_device_type']) + return + + if config.get(CONF_URL) is not None: + url = config[CONF_URL] + name = config.get(CONF_NAME) + elif discovery_info is not None: + url = discovery_info['ssdp_description'] + name = discovery_info['name'] + + if DLNA_DMR_DATA not in hass.data: + hass.data[DLNA_DMR_DATA] = {} + + if 'lock' not in hass.data[DLNA_DMR_DATA]: + hass.data[DLNA_DMR_DATA]['lock'] = asyncio.Lock() + + # build upnp/aiohttp requester + from async_upnp_client.aiohttp import AiohttpSessionRequester + session = async_get_clientsession(hass) + requester = AiohttpSessionRequester(session, True) + + # ensure event handler has been started + with await hass.data[DLNA_DMR_DATA]['lock']: + server_host = config.get(CONF_LISTEN_IP) + if server_host is None: + server_host = get_local_ip() + server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) + event_handler = await async_start_event_handler(hass, + server_host, + server_port, + requester) + + # create upnp device + from async_upnp_client import UpnpFactory + factory = UpnpFactory(requester, disable_state_variable_validation=True) + try: + upnp_device = await factory.async_create_device(url) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() + + # wrap with DmrDevice + from async_upnp_client.dlna import DmrDevice + dlna_device = DmrDevice(upnp_device, event_handler) + + # create our own device + device = DlnaDmrDevice(dlna_device, name) + _LOGGER.debug("Adding device: %s", device) + async_add_devices([device], True) + + +class DlnaDmrDevice(MediaPlayerDevice): + """Representation of a DLNA DMR device.""" + + def __init__(self, dmr_device, name=None): + """Initializer.""" + self._device = dmr_device + self._name = name + + self._available = False + self._subscription_renew_time = None + + async def async_added_to_hass(self): + """Callback when added.""" + self._device.on_event = self._on_event + + # register unsubscribe on stop + bus = self.hass.bus + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + self._async_on_hass_stop) + + @property + def available(self): + """Device is available.""" + return self._available + + async def _async_on_hass_stop(self, event): + """Event handler on HASS stop.""" + with await self.hass.data[DLNA_DMR_DATA]['lock']: + await self._device.async_unsubscribe_services() + + async def async_update(self): + """Retrieve the latest data.""" + was_available = self._available + + try: + await self._device.async_update() + self._available = True + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Device unavailable") + return + + # do we need to (re-)subscribe? + now = datetime.now() + should_renew = self._subscription_renew_time and \ + now >= self._subscription_renew_time + if should_renew or \ + not was_available and self._available: + try: + timeout = await self._device.async_subscribe_services() + self._subscription_renew_time = datetime.now() + timeout / 2 + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Could not (re)subscribe") + + def _on_event(self, service, state_variables): + """State variable(s) changed, let home-assistant know.""" + self.schedule_update_ha_state() + + @property + def supported_features(self): + """Flag media player features that are supported.""" + supported_features = 0 + + if self._device.has_volume_level: + supported_features |= SUPPORT_VOLUME_SET + if self._device.has_volume_mute: + supported_features |= SUPPORT_VOLUME_MUTE + if self._device.has_play: + supported_features |= SUPPORT_PLAY + if self._device.has_pause: + supported_features |= SUPPORT_PAUSE + if self._device.has_stop: + supported_features |= SUPPORT_STOP + if self._device.has_previous: + supported_features |= SUPPORT_PREVIOUS_TRACK + if self._device.has_next: + supported_features |= SUPPORT_NEXT_TRACK + if self._device.has_play_media: + supported_features |= SUPPORT_PLAY_MEDIA + + return supported_features + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._device.volume_level + + @catch_request_errors() + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._device.async_set_volume_level(volume) + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._device.is_volume_muted + + @catch_request_errors() + async def async_mute_volume(self, mute): + """Mute the volume.""" + desired_mute = bool(mute) + await self._device.async_mute_volume(desired_mute) + + @catch_request_errors() + async def async_media_pause(self): + """Send pause command.""" + if not self._device.can_pause: + _LOGGER.debug('Cannot do Pause') + return + + await self._device.async_pause() + + @catch_request_errors() + async def async_media_play(self): + """Send play command.""" + if not self._device.can_play: + _LOGGER.debug('Cannot do Play') + return + + await self._device.async_play() + + @catch_request_errors() + async def async_media_stop(self): + """Send stop command.""" + if not self._device.can_stop: + _LOGGER.debug('Cannot do Stop') + return + + await self._device.async_stop() + + @catch_request_errors() + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + title = "Home Assistant" + mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING[media_type] + upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING[media_type] + + # stop current playing media + if self._device.can_stop: + await self.async_media_stop() + + # queue media + await self._device.async_set_transport_uri(media_id, + title, + mime_type, + upnp_class) + await self._device.async_wait_for_can_play() + + # if already playing, no need to call Play + from async_upnp_client import dlna + if self._device.state == dlna.STATE_PLAYING: + return + + # play it + await self.async_media_play() + + @catch_request_errors() + async def async_media_previous_track(self): + """Send previous track command.""" + if not self._device.can_previous: + _LOGGER.debug('Cannot do Previous') + return + + await self._device.async_previous() + + @catch_request_errors() + async def async_media_next_track(self): + """Send next track command.""" + if not self._device.can_next: + _LOGGER.debug('Cannot do Next') + return + + await self._device.async_next() + + @property + def media_title(self): + """Title of current playing media.""" + return self._device.media_title + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._device.media_image_url + + @property + def state(self): + """State of the player.""" + if not self._available: + return STATE_OFF + + from async_upnp_client import dlna + if self._device.state is None: + return STATE_ON + if self._device.state == dlna.STATE_PLAYING: + return STATE_PLAYING + if self._device.state == dlna.STATE_PAUSED: + return STATE_PAUSED + + return STATE_IDLE + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._device.media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._device.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._device.media_position_updated_at + + @property + def name(self) -> str: + """Return the name of the device.""" + if self._name: + return self._name + return self._device.name + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._device.udn diff --git a/requirements_all.txt b/requirements_all.txt index 0a7bd4f0961706..11f92408eb1174 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -135,6 +135,9 @@ apns2==0.3.0 # homeassistant.components.asterisk_mbox asterisk_mbox==0.4.0 +# homeassistant.components.media_player.dlna_dmr +async-upnp-client==0.12.2 + # homeassistant.components.light.avion # avion==0.7