Refactor Cast (#26550)

* Refactor Cast

* Fix tests & address comments

* Update reqs
This commit is contained in:
Paulus Schoutsen 2019-09-10 13:05:46 -07:00 committed by GitHub
parent a7830bc2d2
commit 7468cc21be
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 526 additions and 471 deletions

View file

@ -1,3 +1,23 @@
"""Consts for Cast integration."""
DOMAIN = "cast"
DEFAULT_PORT = 8009
# Stores a threading.Lock that is held by the internal pychromecast discovery.
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.
KNOWN_CHROMECAST_INFO_KEY = "cast_known_chromecasts"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
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
SIGNAL_CAST_DISCOVERED = "cast_discovered"
# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is
# removed
SIGNAL_CAST_REMOVED = "cast_removed"

View file

@ -0,0 +1,99 @@
"""Deal with Cast discovery."""
import logging
import threading
import pychromecast
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
KNOWN_CHROMECAST_INFO_KEY,
SIGNAL_CAST_DISCOVERED,
INTERNAL_DISCOVERY_RUNNING_KEY,
SIGNAL_CAST_REMOVED,
)
from .helpers import ChromecastInfo, ChromeCastZeroconf
_LOGGER = logging.getLogger(__name__)
def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo):
"""Discover a Chromecast."""
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 = info.fill_out_missing_chromecast_info()
_LOGGER.debug("Discovered chromecast %s", info)
if info.uuid is not None:
# Remove previous cast infos with same uuid from known chromecasts.
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: HomeAssistant, info: ChromecastInfo):
# Removed chromecast
_LOGGER.debug("Removed chromecast %s", info)
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
def setup_internal_discovery(hass: HomeAssistant) -> 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
def internal_add_callback(name):
"""Handle zeroconf discovery of a new chromecast."""
mdns = listener.services[name]
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."""
_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.")
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)

View file

@ -0,0 +1,246 @@
"""Helpers to deal with Cast devices."""
from typing import Optional, Tuple
import attr
from pychromecast import dial
from .const import DEFAULT_PORT
@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)
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(
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
def fill_out_missing_chromecast_info(self) -> "ChromecastInfo":
"""Return a new ChromecastInfo object with missing attributes filled in.
Uses blocking HTTP.
"""
if self.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 self
# Fill out missing information via HTTP dial.
if self.is_audio_group:
is_dynamic_group = False
http_group_status = None
dynamic_groups = []
if self.uuid:
http_group_status = dial.get_multizone_status(
self.host,
services=[self.service],
zconf=ChromeCastZeroconf.get_zeroconf(),
)
if http_group_status is not None:
dynamic_groups = [
str(g.uuid) for g in http_group_status.dynamic_groups
]
is_dynamic_group = self.uuid in dynamic_groups
return ChromecastInfo(
service=self.service,
host=self.host,
port=self.port,
uuid=self.uuid,
friendly_name=self.friendly_name,
manufacturer=self.manufacturer,
model_name=self.model_name,
is_dynamic_group=is_dynamic_group,
)
http_device_status = dial.get_device_status(
self.host, services=[self.service], zconf=ChromeCastZeroconf.get_zeroconf()
)
if http_device_status is None:
# HTTP dial didn't give us any new information.
return self
return ChromecastInfo(
service=self.service,
host=self.host,
port=self.port,
uuid=(self.uuid or http_device_status.uuid),
friendly_name=(self.friendly_name or http_device_status.friendly_name),
manufacturer=(self.manufacturer or http_device_status.manufacturer),
model_name=(self.model_name or http_device_status.model_name),
)
def same_dynamic_group(self, other: "ChromecastInfo") -> bool:
"""Test chromecast info is same dynamic group."""
return (
self.is_audio_group
and other.is_dynamic_group
and self.friendly_name == other.friendly_name
)
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
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)
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):
"""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:
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)
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."""
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:
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

View file

@ -1,10 +1,14 @@
"""Provide functionality to interact with Cast devices on the network."""
import asyncio
import logging
import threading
from typing import Optional, Tuple
from typing import Optional
import attr
import pychromecast
from pychromecast.socket_client import (
CONNECTION_STATUS_CONNECTED,
CONNECTION_STATUS_DISCONNECTED,
)
from pychromecast.controllers.multizone import MultizoneManager
import voluptuous as vol
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice
@ -35,22 +39,33 @@ from homeassistant.const import (
from homeassistant.core import callback
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
from homeassistant.helpers.dispatcher import async_dispatcher_connect
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
DEPENDENCIES = ("cast",)
from .const import (
DOMAIN as CAST_DOMAIN,
ADDED_CAST_DEVICES_KEY,
SIGNAL_CAST_DISCOVERED,
KNOWN_CHROMECAST_INFO_KEY,
CAST_MULTIZONE_MANAGER_KEY,
DEFAULT_PORT,
SIGNAL_CAST_REMOVED,
)
from .helpers import (
ChromecastInfo,
CastStatusListener,
DynamicGroupCastStatusListener,
ChromeCastZeroconf,
)
from .discovery import setup_internal_discovery, discover_chromecast
_LOGGER = logging.getLogger(__name__)
CONF_IGNORE_CEC = "ignore_cec"
CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png"
DEFAULT_PORT = 8009
SUPPORT_CAST = (
SUPPORT_PAUSE
| SUPPORT_PLAY
@ -62,24 +77,6 @@ SUPPORT_CAST = (
| SUPPORT_VOLUME_SET
)
# Stores a threading.Lock that is held by the internal pychromecast discovery.
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.
KNOWN_CHROMECAST_INFO_KEY = "cast_known_chromecasts"
# Stores UUIDs of cast devices that were added as entities. Doesn't store
# None UUIDs.
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
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
SIGNAL_CAST_DISCOVERED = "cast_discovered"
# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is
# removed
SIGNAL_CAST_REMOVED = "cast_removed"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
@ -89,212 +86,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
@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)
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(
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
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(
info.host,
services=[info.service],
zconf=ChromeCastZeroconf.get_zeroconf(),
)
if http_group_status is not None:
dynamic_groups = [str(g.uuid) for g in http_group_status.dynamic_groups]
is_dynamic_group = info.uuid in dynamic_groups
return ChromecastInfo(
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,
is_dynamic_group=is_dynamic_group,
)
http_device_status = dial.get_device_status(
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(
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),
manufacturer=(info.manufacturer or http_device_status.manufacturer),
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.
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]
_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."""
_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.")
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
def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo):
"""Create a CastDevice Entity from the chromecast object.
@ -357,8 +148,6 @@ 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())
@ -390,9 +179,9 @@ async def _async_setup_platform(
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)
hass.async_add_executor_job(setup_internal_discovery, hass)
else:
info = await hass.async_add_job(_fill_out_missing_chromecast_info, info)
info = await hass.async_add_executor_job(info.fill_out_missing_chromecast_info)
if info.friendly_name is None:
_LOGGER.debug(
"Cannot retrieve detail information for chromecast"
@ -400,121 +189,7 @@ async def _async_setup_platform(
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)
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):
"""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:
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)
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."""
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:
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
hass.async_add_executor_job(discover_chromecast, hass, info)
class CastDevice(MediaPlayerDevice):
@ -527,7 +202,6 @@ class CastDevice(MediaPlayerDevice):
def __init__(self, cast_info: ChromecastInfo):
"""Initialize the cast device."""
import pychromecast
self._cast_info = cast_info
self.services = None
@ -557,75 +231,18 @@ class CastDevice(MediaPlayerDevice):
async def async_added_to_hass(self):
"""Create chromecast object when added to hass."""
@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):
_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",
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)
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
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)
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)
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(
self.hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered
self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered
)
self._del_remove_handler = async_dispatcher_connect(
self.hass, SIGNAL_CAST_REMOVED, async_cast_removed
self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop)
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):
if self._cast_info.same_dynamic_group(info):
_LOGGER.debug(
"[%s %s (%s:%s)] Found dynamic group: %s",
self.entity_id,
@ -653,7 +270,6 @@ class CastDevice(MediaPlayerDevice):
async def async_set_cast_info(self, cast_info):
"""Set the cast information and set up the chromecast object."""
import pychromecast
self._cast_info = cast_info
@ -718,9 +334,8 @@ class CastDevice(MediaPlayerDevice):
self._chromecast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
from pychromecast.controllers.multizone import MultizoneManager
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr)
@ -745,7 +360,6 @@ class CastDevice(MediaPlayerDevice):
async def async_set_dynamic_group(self, cast_info):
"""Set the cast information and set up the chromecast object."""
import pychromecast
_LOGGER.debug(
"[%s %s (%s:%s)] Connecting to dynamic group by host %s",
@ -774,9 +388,8 @@ class CastDevice(MediaPlayerDevice):
self._dynamic_group_cast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
from pychromecast.controllers.multizone import MultizoneManager
self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
self._dynamic_group_status_listener = DynamicGroupCastStatusListener(
@ -867,11 +480,6 @@ class CastDevice(MediaPlayerDevice):
def new_connection_status(self, connection_status):
"""Handle updates of connection status."""
from pychromecast.socket_client import (
CONNECTION_STATUS_CONNECTED,
CONNECTION_STATUS_DISCONNECTED,
)
_LOGGER.debug(
"[%s %s (%s:%s)] Received cast device connection status: %s",
self.entity_id,
@ -902,7 +510,7 @@ class CastDevice(MediaPlayerDevice):
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._cast_info = info.fill_out_missing_chromecast_info()
self._available = new_available
self.schedule_update_ha_state()
@ -914,11 +522,6 @@ class CastDevice(MediaPlayerDevice):
def new_dynamic_group_connection_status(self, connection_status):
"""Handle updates of connection status."""
from pychromecast.socket_client import (
CONNECTION_STATUS_CONNECTED,
CONNECTION_STATUS_DISCONNECTED,
)
_LOGGER.debug(
"[%s %s (%s:%s)] Received dynamic group connection status: %s",
self.entity_id,
@ -992,7 +595,6 @@ class CastDevice(MediaPlayerDevice):
def turn_on(self):
"""Turn on the cast device."""
import pychromecast
if not self._chromecast.is_idle:
# Already turned on
@ -1277,3 +879,56 @@ class CastDevice(MediaPlayerDevice):
def unique_id(self) -> Optional[str]:
"""Return a unique ID."""
return self._cast_info.uuid
async def _async_cast_discovered(self, discover: ChromecastInfo):
"""Handle discovery of new Chromecast."""
if self._cast_info.uuid is None:
# We can't handle empty UUIDs
return
if self._cast_info.same_dynamic_group(discover):
_LOGGER.debug("Discovered matching dynamic group: %s", discover)
await 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",
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)
await self.async_set_cast_info(discover)
async def _async_cast_removed(self, discover: ChromecastInfo):
"""Handle removal of Chromecast."""
if self._cast_info.uuid is None:
# We can't handle empty UUIDs
return
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)
await 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)
await self.async_del_cast_info(discover)
async def _async_stop(self, event):
"""Disconnect socket on Home Assistant stop."""
await self._async_disconnect()

View file

@ -279,6 +279,9 @@ pyMetno==0.4.6
# homeassistant.components.blackbird
pyblackbird==0.5
# homeassistant.components.cast
pychromecast==3.2.2
# homeassistant.components.deconz
pydeconz==62

View file

@ -10,8 +10,8 @@ import sys
from script.hassfest.model import Integration
COMMENT_REQUIREMENTS = (
"Adafruit-DHT",
"Adafruit_BBIO",
"Adafruit-DHT",
"avion",
"beacontools",
"blinkt",
@ -26,7 +26,6 @@ COMMENT_REQUIREMENTS = (
"i2csense",
"opencv-python-headless",
"py_noaa",
"VL53L1X2",
"pybluez",
"pycups",
"PySwitchbot",
@ -39,11 +38,11 @@ COMMENT_REQUIREMENTS = (
"RPi.GPIO",
"smbus-cffi",
"tensorflow",
"VL53L1X2",
)
TEST_REQUIREMENTS = (
"adguardhome",
"ambiclimate",
"aio_geojson_geonetnz_quakes",
"aioambient",
"aioautomatic",
@ -52,14 +51,16 @@ TEST_REQUIREMENTS = (
"aiohttp_cors",
"aiohue",
"aionotion",
"aiounifi",
"aioswitcher",
"aiounifi",
"aiowwlln",
"ambiclimate",
"androidtv",
"apns2",
"aprslib",
"av",
"axis",
"bellows-homeassistant",
"caldav",
"coinmarketcap",
"defusedxml",
@ -99,7 +100,6 @@ TEST_REQUIREMENTS = (
"libpurecool",
"libsoundtouch",
"luftdaten",
"pyMetno",
"mbddns",
"mficlient",
"minio",
@ -115,44 +115,49 @@ TEST_REQUIREMENTS = (
"ptvsd",
"pushbullet.py",
"py-canary",
"py17track",
"pyblackbird",
"pychromecast",
"pydeconz",
"pydispatcher",
"pyheos",
"pyhomematic",
"pyHS100",
"pyiqvia",
"pylinky",
"pylitejet",
"pyMetno",
"pymfy",
"pymonoprice",
"PyNaCl",
"pynws",
"pynx584",
"pyopenuv",
"pyotp",
"pyps4-homeassistant",
"pyqwikswitch",
"PyRMVtransport",
"pysma",
"pysmartapp",
"pysmartthings",
"pysonos",
"pyqwikswitch",
"PyRMVtransport",
"PyTransportNSW",
"pyspcwebgw",
"python_awair",
"python-forecastio",
"python-nest",
"python_awair",
"python-velbus",
"pythonwhois",
"pytradfri[async]",
"PyTransportNSW",
"pyunifi",
"pyupnp-async",
"pyvesync",
"pywebpush",
"pyHS100",
"PyNaCl",
"regenmaschine",
"restrictedpython",
"rflink",
"ring_doorbell",
"ruamel.yaml",
"rxv",
"simplisafe-python",
"sleepyq",
@ -166,16 +171,12 @@ TEST_REQUIREMENTS = (
"twentemilieu",
"uvcclient",
"vsure",
"warrant",
"pythonwhois",
"wakeonlan",
"vultr",
"wakeonlan",
"warrant",
"YesssSMS",
"ruamel.yaml",
"zeroconf",
"zigpy-homeassistant",
"bellows-homeassistant",
"py17track",
)
IGNORE_PIN = ("colorlog>2.1,<3", "keyring>=9.3,<10.0", "urllib3")

View file

@ -22,12 +22,16 @@ from tests.common import MockConfigEntry, mock_coro
@pytest.fixture(autouse=True)
def cast_mock():
"""Mock pychromecast."""
with patch.dict(
"sys.modules",
{
"pychromecast": MagicMock(),
"pychromecast.controllers.multizone": MagicMock(),
},
pycast_mock = MagicMock()
with patch(
"homeassistant.components.cast.media_player.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.discovery.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.helpers.dial", MagicMock()
), patch(
"homeassistant.components.cast.media_player.MultizoneManager", MagicMock()
):
yield
@ -73,7 +77,8 @@ async def async_setup_cast_internal_discovery(hass, config=None, discovery_info=
browser = MagicMock(zc={})
with patch(
"pychromecast.start_discovery", return_value=(listener, browser)
"homeassistant.components.cast.discovery.pychromecast.start_discovery",
return_value=(listener, browser),
) as start_discovery:
add_entities = await async_setup_cast(hass, config, discovery_info)
await hass.async_block_till_done()
@ -104,7 +109,8 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas
cast.CastStatusListener = MagicMock()
with patch(
"pychromecast._get_chromecast_from_host", return_value=chromecast
"homeassistant.components.cast.discovery.pychromecast._get_chromecast_from_host",
return_value=chromecast,
) as get_chromecast:
await async_setup_component(
hass,
@ -122,7 +128,8 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas
def test_start_discovery_called_once(hass):
"""Test pychromecast.start_discovery called exactly once."""
with patch(
"pychromecast.start_discovery", return_value=(None, None)
"homeassistant.components.cast.discovery.pychromecast.start_discovery",
return_value=(None, None),
) as start_discovery:
yield from async_setup_cast(hass)
@ -138,14 +145,17 @@ def test_stop_discovery_called_on_stop(hass):
browser = MagicMock(zc={})
with patch(
"pychromecast.start_discovery", return_value=(None, browser)
"homeassistant.components.cast.discovery.pychromecast.start_discovery",
return_value=(None, browser),
) as start_discovery:
# start_discovery should be called with empty config
yield from async_setup_cast(hass, {})
assert start_discovery.call_count == 1
with patch("pychromecast.stop_discovery") as stop_discovery:
with patch(
"homeassistant.components.cast.discovery.pychromecast.stop_discovery"
) as stop_discovery:
# stop discovery should be called on shutdown
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
yield from hass.async_block_till_done()
@ -153,7 +163,8 @@ def test_stop_discovery_called_on_stop(hass):
stop_discovery.assert_called_once_with(browser)
with patch(
"pychromecast.start_discovery", return_value=(None, browser)
"homeassistant.components.cast.discovery.pychromecast.start_discovery",
return_value=(None, browser),
) as start_discovery:
# start_discovery should be called again on re-startup
yield from async_setup_cast(hass)
@ -173,7 +184,10 @@ async def test_internal_discovery_callback_fill_out(hass):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch("pychromecast.dial.get_device_status", return_value=full_info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
):
signal = MagicMock()
async_dispatcher_connect(hass, "cast_discovered", signal)
@ -210,7 +224,7 @@ async def test_normal_chromecast_not_starting_discovery(hass):
"""Test cast platform not starting discovery when not required."""
# pylint: disable=no-member
with patch(
"homeassistant.components.cast.media_player." "_setup_internal_discovery"
"homeassistant.components.cast.media_player.setup_internal_discovery"
) as setup_discovery:
# normal (non-group) chromecast shouldn't start discovery.
add_entities = await async_setup_cast(hass, {"host": "host1"})
@ -275,7 +289,10 @@ async def test_entity_media_states(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch("pychromecast.dial.get_device_status", return_value=full_info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
):
chromecast, entity = await async_setup_media_player_cast(hass, info)
entity._available = True
@ -330,7 +347,10 @@ async def test_group_media_states(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch("pychromecast.dial.get_device_status", return_value=full_info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
):
chromecast, entity = await async_setup_media_player_cast(hass, info)
entity._available = True
@ -377,7 +397,10 @@ async def test_dynamic_group_media_states(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch("pychromecast.dial.get_device_status", return_value=full_info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
):
chromecast, entity = await async_setup_media_player_cast(hass, info)
entity._available = True
@ -426,12 +449,14 @@ async def test_group_media_control(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch("pychromecast.dial.get_device_status", return_value=full_info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
):
chromecast, entity = await async_setup_media_player_cast(hass, info)
entity._available = True
entity.schedule_update_ha_state()
await hass.async_block_till_done()
entity.async_write_ha_state()
state = hass.states.get("media_player.speaker")
assert state is not None
@ -480,7 +505,10 @@ async def test_dynamic_group_media_control(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch("pychromecast.dial.get_device_status", return_value=full_info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
):
chromecast, entity = await async_setup_media_player_cast(hass, info)
entity._available = True
@ -529,7 +557,10 @@ async def test_disconnect_on_stop(hass: HomeAssistantType):
"""Test cast device disconnects socket on stop."""
info = get_fake_chromecast_info()
with patch("pychromecast.dial.get_device_status", return_value=info):
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=info,
):
chromecast, _ = await async_setup_media_player_cast(hass, info)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)