core/homeassistant/components/cast/media_player.py

1280 lines
44 KiB
Python
Raw Normal View History

"""Provide functionality to interact with Cast devices on the network."""
2018-06-25 19:59:05 +00:00
import asyncio
import logging
import threading
from typing import Optional, Tuple
import attr
2018-09-09 12:26:06 +00:00
import voluptuous as vol
2016-09-04 02:14:28 +00:00
2019-07-31 19:25:30 +00:00
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
from homeassistant.components.media_player.const import (
2019-07-31 19:25:30 +00:00
MEDIA_TYPE_MOVIE,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_TVSHOW,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
SUPPORT_PLAY_MEDIA,
SUPPORT_PREVIOUS_TRACK,
SUPPORT_SEEK,
SUPPORT_STOP,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
)
2016-02-19 05:27:50 +00:00
from homeassistant.const import (
2019-07-31 19:25:30 +00:00
CONF_HOST,
EVENT_HOMEASSISTANT_STOP,
STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
)
2018-09-09 12:26:06 +00:00
from homeassistant.core import callback
from homeassistant.exceptions import PlatformNotReady
2016-09-04 02:14:28 +00:00
import homeassistant.helpers.config_validation as cv
2019-07-31 19:25:30 +00:00
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
2018-09-09 12:26:06 +00:00
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
import homeassistant.util.dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
from . import DOMAIN as CAST_DOMAIN
2019-07-31 19:25:30 +00:00
DEPENDENCIES = ("cast",)
2016-09-04 02:14:28 +00:00
_LOGGER = logging.getLogger(__name__)
2019-07-31 19:25:30 +00:00
CONF_IGNORE_CEC = "ignore_cec"
CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png"
2016-09-04 02:14:28 +00:00
DEFAULT_PORT = 8009
2019-07-31 19:25:30 +00:00
SUPPORT_CAST = (
SUPPORT_PAUSE
| SUPPORT_PLAY
| SUPPORT_PLAY_MEDIA
| SUPPORT_STOP
| SUPPORT_TURN_OFF
| SUPPORT_TURN_ON
| SUPPORT_VOLUME_MUTE
| SUPPORT_VOLUME_SET
)
2016-09-04 02:14:28 +00:00
# Stores a threading.Lock that is held by the internal pychromecast discovery.
2019-07-31 19:25:30 +00:00
INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running"
# Stores all ChromecastInfo we encountered through discovery or config as a set
# If we find a chromecast with a new host, the old one will be removed again.
2019-07-31 19:25:30 +00:00
KNOWN_CHROMECAST_INFO_KEY = "cast_known_chromecasts"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
2019-07-31 19:25:30 +00:00
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
2019-07-31 19:25:30 +00:00
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
# Chromecast or receive it through configuration
2019-07-31 19:25:30 +00:00
SIGNAL_CAST_DISCOVERED = "cast_discovered"
2015-05-31 07:38:14 +00:00
# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is
# removed
2019-07-31 19:25:30 +00:00
SIGNAL_CAST_REMOVED = "cast_removed"
2019-07-31 19:25:30 +00:00
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_HOST): cv.string,
vol.Optional(CONF_IGNORE_CEC, default=[]): vol.All(cv.ensure_list, [cv.string]),
}
)
@attr.s(slots=True, frozen=True)
class ChromecastInfo:
"""Class to hold all data about a chromecast for creating connections.
This also has the same attributes as the mDNS fields by zeroconf.
"""
host = attr.ib(type=str)
port = attr.ib(type=int)
service = attr.ib(type=Optional[str], default=None)
2019-07-31 19:25:30 +00:00
uuid = attr.ib(
type=Optional[str], converter=attr.converters.optional(str), default=None
) # always convert UUID to string if not None
manufacturer = attr.ib(type=str, default="")
model_name = attr.ib(type=str, default="")
friendly_name = attr.ib(type=Optional[str], default=None)
is_dynamic_group = attr.ib(type=Optional[bool], default=None)
@property
def is_audio_group(self) -> bool:
"""Return if this is an audio group."""
return self.port != DEFAULT_PORT
@property
def is_information_complete(self) -> bool:
"""Return if all information is filled out."""
want_dynamic_group = self.is_audio_group
have_dynamic_group = self.is_dynamic_group is not None
have_all_except_dynamic_group = all(
2019-07-31 19:25:30 +00:00
attr.astuple(
self,
filter=attr.filters.exclude(
attr.fields(ChromecastInfo).is_dynamic_group
),
)
)
return have_all_except_dynamic_group and (
not want_dynamic_group or have_dynamic_group
)
@property
def host_port(self) -> Tuple[str, int]:
"""Return the host+port tuple."""
return self.host, self.port
2019-07-31 19:25:30 +00:00
def _is_matching_dynamic_group(
our_info: ChromecastInfo, new_info: ChromecastInfo
) -> bool:
return (
our_info.is_audio_group
and new_info.is_dynamic_group
and our_info.friendly_name == new_info.friendly_name
)
def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo:
"""Fill out missing attributes of ChromecastInfo using blocking HTTP."""
if info.is_information_complete:
# We have all information, no need to check HTTP API. Or this is an
# audio group, so checking via HTTP won't give us any new information.
return info
# Fill out missing information via HTTP dial.
from pychromecast import dial
if info.is_audio_group:
is_dynamic_group = False
http_group_status = None
dynamic_groups = []
if info.uuid:
http_group_status = dial.get_multizone_status(
2019-07-31 19:25:30 +00:00
info.host,
services=[info.service],
zconf=ChromeCastZeroconf.get_zeroconf(),
)
if http_group_status is not None:
2019-07-31 19:25:30 +00:00
dynamic_groups = [str(g.uuid) for g in http_group_status.dynamic_groups]
is_dynamic_group = info.uuid in dynamic_groups
return ChromecastInfo(
2019-07-31 19:25:30 +00:00
service=info.service,
host=info.host,
port=info.port,
uuid=info.uuid,
friendly_name=info.friendly_name,
manufacturer=info.manufacturer,
model_name=info.model_name,
2019-07-31 19:25:30 +00:00
is_dynamic_group=is_dynamic_group,
)
http_device_status = dial.get_device_status(
2019-07-31 19:25:30 +00:00
info.host, services=[info.service], zconf=ChromeCastZeroconf.get_zeroconf()
)
if http_device_status is None:
# HTTP dial didn't give us any new information.
return info
return ChromecastInfo(
2019-07-31 19:25:30 +00:00
service=info.service,
host=info.host,
port=info.port,
uuid=(info.uuid or http_device_status.uuid),
friendly_name=(info.friendly_name or http_device_status.friendly_name),
2018-08-29 13:46:09 +00:00
manufacturer=(info.manufacturer or http_device_status.manufacturer),
2019-07-31 19:25:30 +00:00
model_name=(info.model_name or http_device_status.model_name),
)
def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo):
if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]:
_LOGGER.debug("Discovered previous chromecast %s", info)
# Either discovered completely new chromecast or a "moved" one.
info = _fill_out_missing_chromecast_info(info)
_LOGGER.debug("Discovered chromecast %s", info)
if info.uuid is not None:
# Remove previous cast infos with same uuid from known chromecasts.
2019-07-31 19:25:30 +00:00
same_uuid = set(
x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] if info.uuid == x.uuid
)
hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid
hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info)
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
def _remove_chromecast(hass: HomeAssistantType, info: ChromecastInfo):
# Removed chromecast
_LOGGER.debug("Removed chromecast %s", info)
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
class ChromeCastZeroconf:
"""Class to hold a zeroconf instance."""
__zconf = None
@classmethod
def set_zeroconf(cls, zconf):
"""Set zeroconf."""
cls.__zconf = zconf
@classmethod
def get_zeroconf(cls):
"""Get zeroconf."""
return cls.__zconf
def _setup_internal_discovery(hass: HomeAssistantType) -> None:
"""Set up the pychromecast internal discovery."""
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False):
# Internal discovery is already running
return
import pychromecast
def internal_add_callback(name):
"""Handle zeroconf discovery of a new chromecast."""
mdns = listener.services[name]
2019-07-31 19:25:30 +00:00
_discover_chromecast(
hass,
ChromecastInfo(
service=name,
host=mdns[0],
port=mdns[1],
uuid=mdns[2],
model_name=mdns[3],
friendly_name=mdns[4],
),
)
def internal_remove_callback(name, mdns):
"""Handle zeroconf discovery of a removed chromecast."""
2019-07-31 19:25:30 +00:00
_remove_chromecast(
hass,
ChromecastInfo(
service=name,
host=mdns[0],
port=mdns[1],
uuid=mdns[2],
model_name=mdns[3],
friendly_name=mdns[4],
),
)
_LOGGER.debug("Starting internal pychromecast discovery.")
2019-07-31 19:25:30 +00:00
listener, browser = pychromecast.start_discovery(
internal_add_callback, internal_remove_callback
)
ChromeCastZeroconf.set_zeroconf(browser.zc)
def stop_discovery(event):
"""Stop discovery of new chromecasts."""
_LOGGER.debug("Stopping internal pychromecast discovery.")
pychromecast.stop_discovery(browser)
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery)
@callback
2019-07-31 19:25:30 +00:00
def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo):
"""Create a CastDevice Entity from the chromecast object.
Returns None if the cast device has already been added.
"""
_LOGGER.debug("_async_create_cast_device: %s", info)
if info.uuid is None:
# Found a cast without UUID, we don't store it because we won't be able
# to update it anyway.
return CastDevice(info)
# Found a cast with UUID
if info.is_dynamic_group:
# This is a dynamic group, do not add it.
return None
added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
if info.uuid in added_casts:
# Already added this one, the entity will take care of moved hosts
# itself
return None
# -> New cast device
added_casts.add(info.uuid)
return CastDevice(info)
2019-07-31 19:25:30 +00:00
async def async_setup_platform(
hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None
):
"""Set up thet Cast platform.
Deprecated.
"""
_LOGGER.warning(
2019-07-31 19:25:30 +00:00
"Setting configuration for Cast via platform is deprecated. "
"Configure via Cast integration instead."
)
await _async_setup_platform(hass, config, async_add_entities, discovery_info)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Cast from a config entry."""
2019-07-31 19:25:30 +00:00
config = hass.data[CAST_DOMAIN].get("media_player", {})
2018-06-25 19:59:05 +00:00
if not isinstance(config, list):
config = [config]
# no pending task
2019-07-31 19:25:30 +00:00
done, _ = await asyncio.wait(
[_async_setup_platform(hass, cfg, async_add_entities, None) for cfg in config]
)
if any([task.exception() for task in done]):
exceptions = [task.exception() for task in done]
for exception in exceptions:
_LOGGER.debug("Failed to setup chromecast", exc_info=exception)
raise PlatformNotReady
2019-07-31 19:25:30 +00:00
async def _async_setup_platform(
hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info
):
"""Set up the cast platform."""
import pychromecast
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, [])
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, set())
info = None
if discovery_info is not None:
2019-07-31 19:25:30 +00:00
info = ChromecastInfo(host=discovery_info["host"], port=discovery_info["port"])
elif CONF_HOST in config:
2019-07-31 19:25:30 +00:00
info = ChromecastInfo(host=config[CONF_HOST], port=DEFAULT_PORT)
@callback
def async_cast_discovered(discover: ChromecastInfo) -> None:
"""Handle discovery of a new chromecast."""
if info is not None and info.host_port != discover.host_port:
# Not our requested cast device.
return
cast_device = _async_create_cast_device(hass, discover)
if cast_device is not None:
async_add_entities([cast_device])
2019-07-31 19:25:30 +00:00
async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
# Re-play the callback for all past chromecasts, store the objects in
# a list to avoid concurrent modification resulting in exception.
for chromecast in list(hass.data[KNOWN_CHROMECAST_INFO_KEY]):
async_cast_discovered(chromecast)
if info is None or info.is_audio_group:
# If we were a) explicitly told to enable discovery or
# b) have an audio group cast device, we need internal discovery.
hass.async_add_job(_setup_internal_discovery, hass)
else:
2019-07-31 19:25:30 +00:00
info = await hass.async_add_job(_fill_out_missing_chromecast_info, info)
if info.friendly_name is None:
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"Cannot retrieve detail information for chromecast"
" %s, the device may not be online",
info,
)
hass.async_add_job(_discover_chromecast, hass, info)
class CastStatusListener:
"""Helper class to handle pychromecast status callbacks.
Necessary because a CastDevice entity can create a new socket client
and therefore callbacks from multiple chromecast connections can
potentially arrive. This class allows invalidating past chromecast objects.
"""
def __init__(self, cast_device, chromecast, mz_mgr):
"""Initialize the status listener."""
self._cast_device = cast_device
self._uuid = chromecast.uuid
self._valid = True
self._mz_mgr = mz_mgr
chromecast.register_status_listener(self)
2019-07-31 19:25:30 +00:00
chromecast.socket_client.media_controller.register_status_listener(self)
chromecast.register_connection_listener(self)
# pylint: disable=protected-access
if cast_device._cast_info.is_audio_group:
self._mz_mgr.add_multizone(chromecast)
else:
self._mz_mgr.register_listener(chromecast.uuid, self)
def new_cast_status(self, cast_status):
"""Handle reception of a new CastStatus."""
if self._valid:
self._cast_device.new_cast_status(cast_status)
def new_media_status(self, media_status):
"""Handle reception of a new MediaStatus."""
if self._valid:
self._cast_device.new_media_status(media_status)
def new_connection_status(self, connection_status):
"""Handle reception of a new ConnectionStatus."""
if self._valid:
self._cast_device.new_connection_status(connection_status)
@staticmethod
def added_to_multizone(group_uuid):
"""Handle the cast added to a group."""
pass
def removed_from_multizone(self, group_uuid):
"""Handle the cast removed from a group."""
if self._valid:
self._cast_device.multizone_new_media_status(group_uuid, None)
def multizone_new_cast_status(self, group_uuid, cast_status):
2019-04-07 03:31:21 +00:00
"""Handle reception of a new CastStatus for a group."""
pass
def multizone_new_media_status(self, group_uuid, media_status):
"""Handle reception of a new MediaStatus for a group."""
if self._valid:
2019-07-31 19:25:30 +00:00
self._cast_device.multizone_new_media_status(group_uuid, media_status)
def invalidate(self):
"""Invalidate this status listener.
All following callbacks won't be forwarded.
"""
# pylint: disable=protected-access
if self._cast_device._cast_info.is_audio_group:
self._mz_mgr.remove_multizone(self._uuid)
else:
self._mz_mgr.deregister_listener(self._uuid, self)
self._valid = False
class DynamicGroupCastStatusListener:
"""Helper class to handle pychromecast status callbacks.
Necessary because a CastDevice entity can create a new socket client
and therefore callbacks from multiple chromecast connections can
potentially arrive. This class allows invalidating past chromecast objects.
"""
def __init__(self, cast_device, chromecast, mz_mgr):
"""Initialize the status listener."""
self._cast_device = cast_device
self._uuid = chromecast.uuid
self._valid = True
self._mz_mgr = mz_mgr
chromecast.register_status_listener(self)
2019-07-31 19:25:30 +00:00
chromecast.socket_client.media_controller.register_status_listener(self)
chromecast.register_connection_listener(self)
self._mz_mgr.add_multizone(chromecast)
def new_cast_status(self, cast_status):
"""Handle reception of a new CastStatus."""
2019-04-07 03:31:21 +00:00
pass
def new_media_status(self, media_status):
"""Handle reception of a new MediaStatus."""
if self._valid:
self._cast_device.new_dynamic_group_media_status(media_status)
def new_connection_status(self, connection_status):
"""Handle reception of a new ConnectionStatus."""
if self._valid:
2019-07-31 19:25:30 +00:00
self._cast_device.new_dynamic_group_connection_status(connection_status)
def invalidate(self):
"""Invalidate this status listener.
All following callbacks won't be forwarded.
"""
self._mz_mgr.remove_multizone(self._uuid)
self._valid = False
class CastDevice(MediaPlayerDevice):
"""Representation of a Cast device on the network.
This class is the holder of the pychromecast.Chromecast object and its
socket client. It therefore handles all reconnects and audio group changing
"elected leader" itself.
"""
def __init__(self, cast_info: ChromecastInfo):
"""Initialize the cast device."""
import pychromecast
2019-07-31 19:25:30 +00:00
self._cast_info = cast_info
self.services = None
if cast_info.service:
self.services = set()
self.services.add(cast_info.service)
self._chromecast: Optional[pychromecast.Chromecast] = None
self.cast_status = None
self.media_status = None
self.media_status_received = None
self._dynamic_group_cast_info: ChromecastInfo = None
self._dynamic_group_cast: Optional[pychromecast.Chromecast] = None
self.dynamic_group_media_status = None
self.dynamic_group_media_status_received = None
self.mz_media_status = {}
self.mz_media_status_received = {}
self.mz_mgr = None
self._available = False
self._dynamic_group_available = False
self._status_listener: Optional[CastStatusListener] = None
self._dynamic_group_status_listener: Optional[
DynamicGroupCastStatusListener
] = None
self._add_remove_handler = None
self._del_remove_handler = None
async def async_added_to_hass(self):
"""Create chromecast object when added to hass."""
2019-07-31 19:25:30 +00:00
@callback
def async_cast_discovered(discover: ChromecastInfo):
"""Handle discovery of new Chromecast."""
if self._cast_info.uuid is None:
# We can't handle empty UUIDs
return
if _is_matching_dynamic_group(self._cast_info, discover):
2019-07-31 19:25:30 +00:00
_LOGGER.debug("Discovered matching dynamic group: %s", discover)
self.hass.async_create_task(
async_create_catching_coro(self.async_set_dynamic_group(discover))
)
return
if self._cast_info.uuid != discover.uuid:
# Discovered is not our device.
return
if self.services is None:
_LOGGER.warning(
"[%s %s (%s:%s)] Received update for manually added Cast",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
)
return
_LOGGER.debug("Discovered chromecast with same UUID: %s", discover)
2019-07-31 19:25:30 +00:00
self.hass.async_create_task(
async_create_catching_coro(self.async_set_cast_info(discover))
)
def async_cast_removed(discover: ChromecastInfo):
"""Handle removal of Chromecast."""
if self._cast_info.uuid is None:
# We can't handle empty UUIDs
return
2019-07-31 19:25:30 +00:00
if (
self._dynamic_group_cast_info is not None
and self._dynamic_group_cast_info.uuid == discover.uuid
):
_LOGGER.debug("Removed matching dynamic group: %s", discover)
2019-07-31 19:25:30 +00:00
self.hass.async_create_task(
async_create_catching_coro(self.async_del_dynamic_group())
)
return
if self._cast_info.uuid != discover.uuid:
# Removed is not our device.
return
_LOGGER.debug("Removed chromecast with same UUID: %s", discover)
2019-07-31 19:25:30 +00:00
self.hass.async_create_task(
async_create_catching_coro(self.async_del_cast_info(discover))
)
async def async_stop(event):
"""Disconnect socket on Home Assistant stop."""
await self._async_disconnect()
self._add_remove_handler = async_dispatcher_connect(
2019-07-31 19:25:30 +00:00
self.hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered
)
self._del_remove_handler = async_dispatcher_connect(
2019-07-31 19:25:30 +00:00
self.hass, SIGNAL_CAST_REMOVED, async_cast_removed
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop)
2019-07-31 19:25:30 +00:00
self.hass.async_create_task(
async_create_catching_coro(self.async_set_cast_info(self._cast_info))
)
for info in self.hass.data[KNOWN_CHROMECAST_INFO_KEY]:
if _is_matching_dynamic_group(self._cast_info, info):
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"[%s %s (%s:%s)] Found dynamic group: %s",
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
info,
)
self.hass.async_create_task(
async_create_catching_coro(self.async_set_dynamic_group(info))
)
break
async def async_will_remove_from_hass(self) -> None:
"""Disconnect Chromecast object when removed."""
await self._async_disconnect()
if self._cast_info.uuid is not None:
# Remove the entity from the added casts so that it can dynamically
# be re-added again.
self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
if self._add_remove_handler:
self._add_remove_handler()
if self._del_remove_handler:
self._del_remove_handler()
async def async_set_cast_info(self, cast_info):
"""Set the cast information and set up the chromecast object."""
import pychromecast
2019-07-31 19:25:30 +00:00
self._cast_info = cast_info
if self.services is not None:
if cast_info.service not in self.services:
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"[%s %s (%s:%s)] Got new service: %s (%s)",
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
cast_info.service,
self.services,
)
self.services.add(cast_info.service)
if self._chromecast is not None:
# Only setup the chromecast once, added elements to services
# will automatically be picked up.
return
# pylint: disable=protected-access
if self.services is None:
_LOGGER.debug(
"[%s %s (%s:%s)] Connecting to cast device by host %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
cast_info,
)
chromecast = await self.hass.async_add_job(
2019-07-31 19:25:30 +00:00
pychromecast._get_chromecast_from_host,
(
cast_info.host,
cast_info.port,
cast_info.uuid,
cast_info.model_name,
cast_info.friendly_name,
),
)
else:
_LOGGER.debug(
"[%s %s (%s:%s)] Connecting to cast device by service %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
self.services,
)
chromecast = await self.hass.async_add_job(
2019-07-31 19:25:30 +00:00
pychromecast._get_chromecast_from_service,
(
self.services,
ChromeCastZeroconf.get_zeroconf(),
cast_info.uuid,
cast_info.model_name,
cast_info.friendly_name,
),
)
self._chromecast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
from pychromecast.controllers.multizone import MultizoneManager
2019-07-31 19:25:30 +00:00
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
2019-07-31 19:25:30 +00:00
self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr)
self._available = False
self.cast_status = chromecast.status
self.media_status = chromecast.media_controller.status
self._chromecast.start()
self.async_schedule_update_ha_state()
async def async_del_cast_info(self, cast_info):
"""Remove the service."""
self.services.discard(cast_info.service)
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"[%s %s (%s:%s)] Remove service: %s (%s)",
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
cast_info.service,
self.services,
)
async def async_set_dynamic_group(self, cast_info):
"""Set the cast information and set up the chromecast object."""
import pychromecast
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"[%s %s (%s:%s)] Connecting to dynamic group by host %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
cast_info,
)
2019-04-28 19:09:06 +00:00
await self.async_del_dynamic_group()
self._dynamic_group_cast_info = cast_info
# pylint: disable=protected-access
chromecast = await self.hass.async_add_executor_job(
2019-07-31 19:25:30 +00:00
pychromecast._get_chromecast_from_host,
(
cast_info.host,
cast_info.port,
cast_info.uuid,
cast_info.model_name,
cast_info.friendly_name,
),
)
self._dynamic_group_cast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
from pychromecast.controllers.multizone import MultizoneManager
2019-07-31 19:25:30 +00:00
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
self._dynamic_group_status_listener = DynamicGroupCastStatusListener(
2019-07-31 19:25:30 +00:00
self, chromecast, mz_mgr
)
self._dynamic_group_available = False
self.dynamic_group_media_status = chromecast.media_controller.status
self._dynamic_group_cast.start()
self.async_schedule_update_ha_state()
async def async_del_dynamic_group(self):
"""Remove the dynamic group."""
cast_info = self._dynamic_group_cast_info
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"[%s %s (%s:%s)] Remove dynamic group: %s",
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
cast_info.service if cast_info else None,
)
self._dynamic_group_available = False
self._dynamic_group_cast_info = None
if self._dynamic_group_cast is not None:
2019-07-31 19:25:30 +00:00
await self.hass.async_add_executor_job(self._dynamic_group_cast.disconnect)
self._dynamic_group_invalidate()
self.async_schedule_update_ha_state()
async def _async_disconnect(self):
"""Disconnect Chromecast object if it is set."""
if self._chromecast is None:
# Can't disconnect if not connected.
return
2019-07-31 19:25:30 +00:00
_LOGGER.debug(
"[%s %s (%s:%s)] Disconnecting from chromecast socket.",
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
)
self._available = False
self.async_schedule_update_ha_state()
await self.hass.async_add_executor_job(self._chromecast.disconnect)
if self._dynamic_group_cast is not None:
2019-07-31 19:25:30 +00:00
await self.hass.async_add_executor_job(self._dynamic_group_cast.disconnect)
self._invalidate()
self.async_schedule_update_ha_state()
def _invalidate(self):
"""Invalidate some attributes."""
self._chromecast = None
self.cast_status = None
self.media_status = None
self.media_status_received = None
self.mz_media_status = {}
self.mz_media_status_received = {}
self.mz_mgr = None
if self._status_listener is not None:
self._status_listener.invalidate()
self._status_listener = None
def _dynamic_group_invalidate(self):
"""Invalidate some attributes."""
self._dynamic_group_cast = None
self.dynamic_group_media_status = None
self.dynamic_group_media_status_received = None
if self._dynamic_group_status_listener is not None:
self._dynamic_group_status_listener.invalidate()
self._dynamic_group_status_listener = None
# ========== Callbacks ==========
def new_cast_status(self, cast_status):
"""Handle updates of the cast status."""
self.cast_status = cast_status
self.schedule_update_ha_state()
def new_media_status(self, media_status):
"""Handle updates of the media status."""
self.media_status = media_status
self.media_status_received = dt_util.utcnow()
self.schedule_update_ha_state()
def new_connection_status(self, connection_status):
"""Handle updates of connection status."""
2019-07-31 19:25:30 +00:00
from pychromecast.socket_client import (
CONNECTION_STATUS_CONNECTED,
CONNECTION_STATUS_DISCONNECTED,
)
_LOGGER.debug(
"[%s %s (%s:%s)] Received cast device connection status: %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
connection_status.status,
)
if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
self._available = False
self._invalidate()
self.schedule_update_ha_state()
return
new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
if new_available != self._available:
# Connection status callbacks happen often when disconnected.
# Only update state when availability changed to put less pressure
# on state machine.
_LOGGER.debug(
"[%s %s (%s:%s)] Cast device availability changed: %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
connection_status.status,
)
info = self._cast_info
if info.friendly_name is None and not info.is_audio_group:
# We couldn't find friendly_name when the cast was added, retry
self._cast_info = _fill_out_missing_chromecast_info(info)
self._available = new_available
self.schedule_update_ha_state()
def new_dynamic_group_media_status(self, media_status):
"""Handle updates of the media status."""
self.dynamic_group_media_status = media_status
self.dynamic_group_media_status_received = dt_util.utcnow()
self.schedule_update_ha_state()
def new_dynamic_group_connection_status(self, connection_status):
"""Handle updates of connection status."""
2019-07-31 19:25:30 +00:00
from pychromecast.socket_client import (
CONNECTION_STATUS_CONNECTED,
CONNECTION_STATUS_DISCONNECTED,
)
_LOGGER.debug(
"[%s %s (%s:%s)] Received dynamic group connection status: %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
connection_status.status,
)
if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
self._dynamic_group_available = False
self._dynamic_group_invalidate()
self.schedule_update_ha_state()
return
new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
if new_available != self._dynamic_group_available:
# Connection status callbacks happen often when disconnected.
# Only update state when availability changed to put less pressure
# on state machine.
_LOGGER.debug(
"[%s %s (%s:%s)] Dynamic group availability changed: %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
connection_status.status,
)
self._dynamic_group_available = new_available
self.schedule_update_ha_state()
def multizone_new_media_status(self, group_uuid, media_status):
"""Handle updates of audio group media status."""
_LOGGER.debug(
"[%s %s (%s:%s)] Multizone %s media status: %s",
2019-07-31 19:25:30 +00:00
self.entity_id,
self._cast_info.friendly_name,
self._cast_info.host,
self._cast_info.port,
group_uuid,
media_status,
)
self.mz_media_status[group_uuid] = media_status
self.mz_media_status_received[group_uuid] = dt_util.utcnow()
self.schedule_update_ha_state()
# ========== Service Calls ==========
def _media_controller(self):
"""
Return media status.
First try from our own cast, then dynamic groups and finally
groups which our cast is a member in.
"""
media_status = self.media_status
media_controller = self._chromecast.media_controller
2019-07-31 19:25:30 +00:00
if (
media_status is None or media_status.player_state == "UNKNOWN"
) and self._dynamic_group_cast is not None:
media_status = self.dynamic_group_media_status
2019-07-31 19:25:30 +00:00
media_controller = self._dynamic_group_cast.media_controller
if media_status is None or media_status.player_state == "UNKNOWN":
groups = self.mz_media_status
for k, val in groups.items():
if val and val.player_state != "UNKNOWN":
2019-07-31 19:25:30 +00:00
media_controller = self.mz_mgr.get_multizone_mediacontroller(k)
break
return media_controller
def turn_on(self):
"""Turn on the cast device."""
import pychromecast
if not self._chromecast.is_idle:
# Already turned on
return
if self._chromecast.app_id is not None:
# Quit the previous app before starting splash screen
self._chromecast.quit_app()
# The only way we can turn the Chromecast is on is by launching an app
2019-07-31 19:25:30 +00:00
self._chromecast.play_media(CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED)
def turn_off(self):
"""Turn off the cast device."""
self._chromecast.quit_app()
def mute_volume(self, mute):
"""Mute the volume."""
self._chromecast.set_volume_muted(mute)
def set_volume_level(self, volume):
"""Set volume level, range 0..1."""
self._chromecast.set_volume(volume)
def media_play(self):
"""Send play command."""
media_controller = self._media_controller()
media_controller.play()
def media_pause(self):
"""Send pause command."""
media_controller = self._media_controller()
media_controller.pause()
def media_stop(self):
"""Send stop command."""
media_controller = self._media_controller()
media_controller.stop()
def media_previous_track(self):
"""Send previous track command."""
media_controller = self._media_controller()
media_controller.queue_prev()
def media_next_track(self):
"""Send next track command."""
media_controller = self._media_controller()
media_controller.queue_next()
def media_seek(self, position):
"""Seek the media to a specific location."""
media_controller = self._media_controller()
media_controller.seek(position)
def play_media(self, media_type, media_id, **kwargs):
"""Play media from a URL."""
# We do not want this to be forwarded to a group / dynamic group
self._chromecast.media_controller.play_media(media_id, media_type)
# ========== Properties ==========
@property
def should_poll(self):
"""No polling needed."""
return False
@property
def name(self):
2016-03-08 09:34:33 +00:00
"""Return the name of the device."""
return self._cast_info.friendly_name
2018-08-29 13:46:09 +00:00
@property
def device_info(self):
"""Return information about the device."""
cast_info = self._cast_info
if cast_info.model_name == "Google Cast Group":
return None
return {
2019-07-31 19:25:30 +00:00
"name": cast_info.friendly_name,
"identifiers": {(CAST_DOMAIN, cast_info.uuid.replace("-", ""))},
"model": cast_info.model_name,
"manufacturer": cast_info.manufacturer,
2018-08-29 13:46:09 +00:00
}
def _media_status(self):
"""
Return media status.
First try from our own cast, then dynamic groups and finally
groups which our cast is a member in.
"""
media_status = self.media_status
media_status_received = self.media_status_received
2019-07-31 19:25:30 +00:00
if (
media_status is None or media_status.player_state == "UNKNOWN"
) and self._dynamic_group_cast is not None:
media_status = self.dynamic_group_media_status
media_status_received = self.dynamic_group_media_status_received
if media_status is None or media_status.player_state == "UNKNOWN":
groups = self.mz_media_status
for k, val in groups.items():
if val and val.player_state != "UNKNOWN":
media_status = val
media_status_received = self.mz_media_status_received[k]
break
return (media_status, media_status_received)
2015-05-30 05:36:40 +00:00
@property
def state(self):
2016-03-08 09:34:33 +00:00
"""Return the state of the player."""
media_status, _ = self._media_status()
if media_status is None:
return None
if media_status.player_is_playing:
return STATE_PLAYING
if media_status.player_is_paused:
return STATE_PAUSED
if media_status.player_is_idle:
return STATE_IDLE
if self._chromecast is not None and self._chromecast.is_idle:
return STATE_OFF
return None
@property
def available(self):
"""Return True if the cast device is connected."""
return self._available
@property
def volume_level(self):
2016-03-08 09:34:33 +00:00
"""Volume level of the media player (0..1)."""
2015-06-09 05:49:32 +00:00
return self.cast_status.volume_level if self.cast_status else None
@property
def is_volume_muted(self):
2016-03-08 09:34:33 +00:00
"""Boolean if volume is currently muted."""
2015-06-09 05:49:32 +00:00
return self.cast_status.volume_muted if self.cast_status else None
@property
def media_content_id(self):
2016-03-08 09:34:33 +00:00
"""Content ID of current playing media."""
media_status, _ = self._media_status()
return media_status.content_id if media_status else None
2015-05-29 20:19:42 +00:00
@property
def media_content_type(self):
2016-03-08 09:34:33 +00:00
"""Content type of current playing media."""
media_status, _ = self._media_status()
if media_status is None:
2015-06-09 05:49:32 +00:00
return None
if media_status.media_is_tvshow:
2015-06-09 05:49:32 +00:00
return MEDIA_TYPE_TVSHOW
if media_status.media_is_movie:
return MEDIA_TYPE_MOVIE
if media_status.media_is_musictrack:
2015-06-09 05:49:32 +00:00
return MEDIA_TYPE_MUSIC
return None
@property
def media_duration(self):
2016-03-08 09:34:33 +00:00
"""Duration of current playing media in seconds."""
media_status, _ = self._media_status()
return media_status.duration if media_status else None
@property
def media_image_url(self):
2016-03-08 09:34:33 +00:00
"""Image url of current playing media."""
media_status, _ = self._media_status()
if media_status is None:
2015-06-09 05:49:32 +00:00
return None
images = media_status.images
2015-06-09 05:49:32 +00:00
return images[0].url if images and images[0].url else None
@property
def media_image_remotely_accessible(self) -> bool:
"""If the image url is remotely accessible."""
return True
@property
def media_title(self):
2016-03-08 09:34:33 +00:00
"""Title of current playing media."""
media_status, _ = self._media_status()
return media_status.title if media_status else None
@property
def media_artist(self):
2016-03-08 09:34:33 +00:00
"""Artist of current playing media (Music track only)."""
media_status, _ = self._media_status()
return media_status.artist if media_status else None
@property
def media_album_name(self):
2016-03-08 09:34:33 +00:00
"""Album of current playing media (Music track only)."""
media_status, _ = self._media_status()
return media_status.album_name if media_status else None
2015-06-09 05:49:32 +00:00
@property
def media_album_artist(self):
"""Album artist of current playing media (Music track only)."""
media_status, _ = self._media_status()
return media_status.album_artist if media_status else None
@property
def media_track(self):
2016-03-08 09:34:33 +00:00
"""Track number of current playing media (Music track only)."""
media_status, _ = self._media_status()
return media_status.track if media_status else None
@property
def media_series_title(self):
"""Return the title of the series of current playing media."""
media_status, _ = self._media_status()
return media_status.series_title if media_status else None
@property
def media_season(self):
2016-03-08 09:34:33 +00:00
"""Season of current playing media (TV Show only)."""
media_status, _ = self._media_status()
return media_status.season if media_status else None
@property
def media_episode(self):
2016-03-08 09:34:33 +00:00
"""Episode of current playing media (TV Show only)."""
media_status, _ = self._media_status()
return media_status.episode if media_status else None
@property
def app_id(self):
2016-03-08 09:34:33 +00:00
"""Return the ID of the current running app."""
return self._chromecast.app_id if self._chromecast else None
@property
def app_name(self):
2016-03-08 09:34:33 +00:00
"""Name of the current running app."""
return self._chromecast.app_display_name if self._chromecast else None
@property
def supported_features(self):
"""Flag media player features that are supported."""
support = SUPPORT_CAST
media_status, _ = self._media_status()
if media_status:
if media_status.supports_queue_next:
support |= SUPPORT_PREVIOUS_TRACK
if media_status.supports_queue_next:
support |= SUPPORT_NEXT_TRACK
if media_status.supports_seek:
support |= SUPPORT_SEEK
return support
@property
def media_position(self):
"""Position of current playing media in seconds."""
media_status, _ = self._media_status()
2019-07-31 19:25:30 +00:00
if media_status is None or not (
media_status.player_is_playing
or media_status.player_is_paused
or media_status.player_is_idle
):
return None
return media_status.current_time
@property
def media_position_updated_at(self):
"""When was the position of the current playing media valid.
Returns value from homeassistant.util.dt.utcnow().
"""
_, media_status_recevied = self._media_status()
return media_status_recevied
@property
def unique_id(self) -> Optional[str]:
2018-03-03 18:23:55 +00:00
"""Return a unique ID."""
return self._cast_info.uuid