Refactor Cast (#26550)
* Refactor Cast * Fix tests & address comments * Update reqs
This commit is contained in:
parent
a7830bc2d2
commit
7468cc21be
7 changed files with 526 additions and 471 deletions
|
@ -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"
|
||||
|
|
99
homeassistant/components/cast/discovery.py
Normal file
99
homeassistant/components/cast/discovery.py
Normal 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)
|
246
homeassistant/components/cast/helpers.py
Normal file
246
homeassistant/components/cast/helpers.py
Normal 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
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue