core/homeassistant/components/dlna_dmr/media_player.py

446 lines
14 KiB
Python

"""Support for DLNA DMR (Device Media Renderer)."""
import asyncio
from datetime import timedelta
import functools
import logging
from typing import Optional
import aiohttp
import voluptuous as vol
from homeassistant.components.media_player import MediaPlayerDevice, PLATFORM_SCHEMA
from homeassistant.components.media_player.const import (
MEDIA_TYPE_CHANNEL,
MEDIA_TYPE_EPISODE,
MEDIA_TYPE_IMAGE,
MEDIA_TYPE_MOVIE,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_TVSHOW,
MEDIA_TYPE_VIDEO,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_SEEK,
SUPPORT_STOP,
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
)
from homeassistant.const import (
CONF_NAME,
CONF_URL,
EVENT_HOMEASSISTANT_STOP,
STATE_IDLE,
STATE_OFF,
STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import HomeAssistantType
import homeassistant.helpers.config_validation as cv
import homeassistant.util.dt as dt_util
from homeassistant.util import get_local_ip
_LOGGER = logging.getLogger(__name__)
DLNA_DMR_DATA = "dlna_dmr"
DEFAULT_NAME = "DLNA Digital Media Renderer"
DEFAULT_LISTEN_PORT = 8301
CONF_LISTEN_IP = "listen_ip"
CONF_LISTEN_PORT = "listen_port"
CONF_CALLBACK_URL_OVERRIDE = "callback_url_override"
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,
vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url,
}
)
HOME_ASSISTANT_UPNP_CLASS_MAPPING = {
MEDIA_TYPE_MUSIC: "object.item.audioItem",
MEDIA_TYPE_TVSHOW: "object.item.videoItem",
MEDIA_TYPE_MOVIE: "object.item.videoItem",
MEDIA_TYPE_VIDEO: "object.item.videoItem",
MEDIA_TYPE_EPISODE: "object.item.videoItem",
MEDIA_TYPE_CHANNEL: "object.item.videoItem",
MEDIA_TYPE_IMAGE: "object.item.imageItem",
MEDIA_TYPE_PLAYLIST: "object.item.playlist",
}
UPNP_CLASS_DEFAULT = "object.item"
HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = {
MEDIA_TYPE_MUSIC: "audio/*",
MEDIA_TYPE_TVSHOW: "video/*",
MEDIA_TYPE_MOVIE: "video/*",
MEDIA_TYPE_VIDEO: "video/*",
MEDIA_TYPE_EPISODE: "video/*",
MEDIA_TYPE_CHANNEL: "video/*",
MEDIA_TYPE_IMAGE: "image/*",
MEDIA_TYPE_PLAYLIST: "playlist/*",
}
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: HomeAssistantType,
server_host: str,
server_port: int,
requester,
callback_url_override: Optional[str] = None,
):
"""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,
listen_port=server_port,
listen_host=server_host,
callback_url=callback_url_override,
)
await server.start_server()
_LOGGER.info("UPNP/DLNA event handler listening, url: %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: HomeAssistantType, config, async_add_entities, discovery_info=None
):
"""Set up DLNA DMR platform."""
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.get("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)
callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE)
event_handler = await async_start_event_handler(
hass, server_host, server_port, requester, callback_url_override
)
# 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.profiles.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_entities([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):
"""Handle addition."""
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 = dt_util.utcnow()
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 = dt_util.utcnow() + 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
if self._device.has_seek_rel_time:
supported_features |= SUPPORT_SEEK
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_media_seek(self, position):
"""Send seek command."""
if not self._device.can_seek_rel_time:
_LOGGER.debug("Cannot do Seek/rel_time")
return
time = timedelta(seconds=position)
await self._device.async_seek_rel_time(time)
@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.get(media_type, media_type)
upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING.get(
media_type, UPNP_CLASS_DEFAULT
)
# 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.profiles.dlna import DeviceState
if self._device.state == DeviceState.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.profiles.dlna import DeviceState
if self._device.state is None:
return STATE_ON
if self._device.state == DeviceState.PLAYING:
return STATE_PLAYING
if self._device.state == DeviceState.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