Support dynamic Google Cast groups (#44484)

* Re-add support for dynamic groups

* Add tests

* Add support for manufacturer

* Refactor support for dynamic groups

* Bump pychromecast to 7.7.0

* Bump pychromecast to 7.7.1

* Tweak tests

* Apply review suggestion
This commit is contained in:
Erik Montnemery 2021-01-06 09:23:18 +01:00 committed by GitHub
parent f18880686c
commit 02bfc68842
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 489 additions and 59 deletions

View file

@ -25,6 +25,7 @@ def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo):
_LOGGER.error("Discovered chromecast without uuid %s", info) _LOGGER.error("Discovered chromecast without uuid %s", info)
return return
info = info.fill_out_missing_chromecast_info()
if info.uuid in hass.data[KNOWN_CHROMECAST_INFO_KEY]: if info.uuid in hass.data[KNOWN_CHROMECAST_INFO_KEY]:
_LOGGER.debug("Discovered update for known chromecast %s", info) _LOGGER.debug("Discovered update for known chromecast %s", info)
else: else:

View file

@ -1,7 +1,8 @@
"""Helpers to deal with Cast devices.""" """Helpers to deal with Cast devices."""
from typing import Optional, Tuple from typing import Optional
import attr import attr
from pychromecast import dial
from pychromecast.const import CAST_MANUFACTURERS from pychromecast.const import CAST_MANUFACTURERS
from .const import DEFAULT_PORT from .const import DEFAULT_PORT
@ -20,8 +21,10 @@ class ChromecastInfo:
uuid: Optional[str] = attr.ib( uuid: Optional[str] = attr.ib(
converter=attr.converters.optional(str), default=None converter=attr.converters.optional(str), default=None
) # always convert UUID to string if not None ) # always convert UUID to string if not None
_manufacturer = attr.ib(type=Optional[str], default=None)
model_name: str = attr.ib(default="") model_name: str = attr.ib(default="")
friendly_name: Optional[str] = attr.ib(default=None) friendly_name: Optional[str] = attr.ib(default=None)
is_dynamic_group = attr.ib(type=Optional[bool], default=None)
@property @property
def is_audio_group(self) -> bool: def is_audio_group(self) -> bool:
@ -29,17 +32,84 @@ class ChromecastInfo:
return self.port != DEFAULT_PORT return self.port != DEFAULT_PORT
@property @property
def host_port(self) -> Tuple[str, int]: def is_information_complete(self) -> bool:
"""Return the host+port tuple.""" """Return if all information is filled out."""
return self.host, self.port 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 @property
def manufacturer(self) -> str: def manufacturer(self) -> str:
"""Return the manufacturer.""" """Return the manufacturer."""
if self._manufacturer:
return self._manufacturer
if not self.model_name: if not self.model_name:
return None return None
return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.") return CAST_MANUFACTURERS.get(self.model_name.lower(), "Google Inc.")
def fill_out_missing_chromecast_info(self) -> "ChromecastInfo":
"""Return a new ChromecastInfo object with missing attributes filled in.
Uses blocking HTTP / HTTPS.
"""
if self.is_information_complete:
# We have all information, no need to check HTTP API.
return self
# Fill out missing group information via HTTP API.
if self.is_audio_group:
is_dynamic_group = False
http_group_status = None
if self.uuid:
http_group_status = dial.get_multizone_status(
self.host,
services=self.services,
zconf=ChromeCastZeroconf.get_zeroconf(),
)
if http_group_status is not None:
is_dynamic_group = any(
str(g.uuid) == self.uuid
for g in http_group_status.dynamic_groups
)
return ChromecastInfo(
services=self.services,
host=self.host,
port=self.port,
uuid=self.uuid,
friendly_name=self.friendly_name,
model_name=self.model_name,
is_dynamic_group=is_dynamic_group,
)
# Fill out some missing information (friendly_name, uuid) via HTTP dial.
http_device_status = dial.get_device_status(
self.host, services=self.services, zconf=ChromeCastZeroconf.get_zeroconf()
)
if http_device_status is None:
# HTTP dial didn't give us any new information.
return self
return ChromecastInfo(
services=self.services,
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),
)
class ChromeCastZeroconf: class ChromeCastZeroconf:
"""Class to hold a zeroconf instance.""" """Class to hold a zeroconf instance."""
@ -65,19 +135,22 @@ class CastStatusListener:
potentially arrive. This class allows invalidating past chromecast objects. potentially arrive. This class allows invalidating past chromecast objects.
""" """
def __init__(self, cast_device, chromecast, mz_mgr): def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False):
"""Initialize the status listener.""" """Initialize the status listener."""
self._cast_device = cast_device self._cast_device = cast_device
self._uuid = chromecast.uuid self._uuid = chromecast.uuid
self._valid = True self._valid = True
self._mz_mgr = mz_mgr self._mz_mgr = mz_mgr
if cast_device._cast_info.is_audio_group:
self._mz_mgr.add_multizone(chromecast)
if mz_only:
return
chromecast.register_status_listener(self) chromecast.register_status_listener(self)
chromecast.socket_client.media_controller.register_status_listener(self) chromecast.socket_client.media_controller.register_status_listener(self)
chromecast.register_connection_listener(self) chromecast.register_connection_listener(self)
if cast_device._cast_info.is_audio_group: if not cast_device._cast_info.is_audio_group:
self._mz_mgr.add_multizone(chromecast)
else:
self._mz_mgr.register_listener(chromecast.uuid, self) self._mz_mgr.register_listener(chromecast.uuid, self)
def new_cast_status(self, cast_status): def new_cast_status(self, cast_status):

View file

@ -3,7 +3,7 @@
"name": "Google Cast", "name": "Google Cast",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/cast", "documentation": "https://www.home-assistant.io/integrations/cast",
"requirements": ["pychromecast==7.6.0"], "requirements": ["pychromecast==7.7.1"],
"after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"], "after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"],
"zeroconf": ["_googlecast._tcp.local."], "zeroconf": ["_googlecast._tcp.local."],
"codeowners": ["@emontnemery"] "codeowners": ["@emontnemery"]

View file

@ -63,6 +63,7 @@ from .const import (
DOMAIN as CAST_DOMAIN, DOMAIN as CAST_DOMAIN,
KNOWN_CHROMECAST_INFO_KEY, KNOWN_CHROMECAST_INFO_KEY,
SIGNAL_CAST_DISCOVERED, SIGNAL_CAST_DISCOVERED,
SIGNAL_CAST_REMOVED,
SIGNAL_HASS_CAST_SHOW_VIEW, SIGNAL_HASS_CAST_SHOW_VIEW,
) )
from .discovery import setup_internal_discovery from .discovery import setup_internal_discovery
@ -115,6 +116,13 @@ def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo):
return None return None
# -> New cast device # -> New cast device
added_casts.add(info.uuid) added_casts.add(info.uuid)
if info.is_dynamic_group:
# This is a dynamic group, do not add it but connect to the service.
group = DynamicCastGroup(hass, info)
group.async_setup()
return None
return CastDevice(info) return CastDevice(info)
@ -206,8 +214,9 @@ class CastDevice(MediaPlayerEntity):
self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered
) )
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop)
self.async_set_cast_info(self._cast_info)
self.hass.async_create_task( self.hass.async_create_task(
async_create_catching_coro(self.async_set_cast_info(self._cast_info)) async_create_catching_coro(self.async_connect_to_chromecast())
) )
self._cast_view_remove_handler = async_dispatcher_connect( self._cast_view_remove_handler = async_dispatcher_connect(
@ -228,15 +237,13 @@ class CastDevice(MediaPlayerEntity):
self._cast_view_remove_handler() self._cast_view_remove_handler()
self._cast_view_remove_handler = None self._cast_view_remove_handler = None
async def async_set_cast_info(self, cast_info): def async_set_cast_info(self, cast_info):
"""Set the cast information and set up the chromecast object.""" """Set the cast information."""
self._cast_info = cast_info self._cast_info = cast_info
if self._chromecast is not None: async def async_connect_to_chromecast(self):
# Only setup the chromecast once, added elements to services """Set up the chromecast object."""
# will automatically be picked up.
return
_LOGGER.debug( _LOGGER.debug(
"[%s %s] Connecting to cast device by service %s", "[%s %s] Connecting to cast device by service %s",
@ -248,9 +255,9 @@ class CastDevice(MediaPlayerEntity):
pychromecast.get_chromecast_from_service, pychromecast.get_chromecast_from_service,
( (
self.services, self.services,
cast_info.uuid, self._cast_info.uuid,
cast_info.model_name, self._cast_info.model_name,
cast_info.friendly_name, self._cast_info.friendly_name,
None, None,
None, None,
), ),
@ -777,16 +784,12 @@ class CastDevice(MediaPlayerEntity):
async def _async_cast_discovered(self, discover: ChromecastInfo): async def _async_cast_discovered(self, discover: ChromecastInfo):
"""Handle discovery of new Chromecast.""" """Handle discovery of new Chromecast."""
if self._cast_info.uuid is None:
# We can't handle empty UUIDs
return
if self._cast_info.uuid != discover.uuid: if self._cast_info.uuid != discover.uuid:
# Discovered is not our device. # Discovered is not our device.
return return
_LOGGER.debug("Discovered chromecast with same UUID: %s", discover) _LOGGER.debug("Discovered chromecast with same UUID: %s", discover)
await self.async_set_cast_info(discover) self.async_set_cast_info(discover)
async def _async_stop(self, event): async def _async_stop(self, event):
"""Disconnect socket on Home Assistant stop.""" """Disconnect socket on Home Assistant stop."""
@ -808,3 +811,131 @@ class CastDevice(MediaPlayerEntity):
self._chromecast.register_handler(controller) self._chromecast.register_handler(controller)
self._hass_cast_controller.show_lovelace_view(view_path, url_path) self._hass_cast_controller.show_lovelace_view(view_path, url_path)
class DynamicCastGroup:
"""Representation of a Cast device on the network - for dynamic cast groups."""
def __init__(self, hass, cast_info: ChromecastInfo):
"""Initialize the cast device."""
self.hass = hass
self._cast_info = cast_info
self.services = cast_info.services
self._chromecast: Optional[pychromecast.Chromecast] = None
self.mz_mgr = None
self._status_listener: Optional[CastStatusListener] = None
self._add_remove_handler = None
self._del_remove_handler = None
def async_setup(self):
"""Create chromecast object."""
self._add_remove_handler = async_dispatcher_connect(
self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered
)
self._del_remove_handler = async_dispatcher_connect(
self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed
)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop)
self.async_set_cast_info(self._cast_info)
self.hass.async_create_task(
async_create_catching_coro(self.async_connect_to_chromecast())
)
async def async_tear_down(self) -> None:
"""Disconnect Chromecast object."""
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()
self._add_remove_handler = None
if self._del_remove_handler:
self._del_remove_handler()
self._del_remove_handler = None
def async_set_cast_info(self, cast_info):
"""Set the cast information and set up the chromecast object."""
self._cast_info = cast_info
async def async_connect_to_chromecast(self):
"""Set the cast information and set up the chromecast object."""
_LOGGER.debug(
"[%s %s] Connecting to cast device by service %s",
"Dynamic group",
self._cast_info.friendly_name,
self.services,
)
chromecast = await self.hass.async_add_executor_job(
pychromecast.get_chromecast_from_service,
(
self.services,
self._cast_info.uuid,
self._cast_info.model_name,
self._cast_info.friendly_name,
None,
None,
),
ChromeCastZeroconf.get_zeroconf(),
)
self._chromecast = chromecast
if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
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, True)
self._chromecast.start()
async def _async_disconnect(self):
"""Disconnect Chromecast object if it is set."""
if self._chromecast is None:
# Can't disconnect if not connected.
return
_LOGGER.debug(
"[%s %s] Disconnecting from chromecast socket",
"Dynamic group",
self._cast_info.friendly_name,
)
await self.hass.async_add_executor_job(self._chromecast.disconnect)
self._invalidate()
def _invalidate(self):
"""Invalidate some attributes."""
self._chromecast = None
self.mz_mgr = None
if self._status_listener is not None:
self._status_listener.invalidate()
self._status_listener = None
async def _async_cast_discovered(self, discover: ChromecastInfo):
"""Handle discovery of new Chromecast."""
if self._cast_info.uuid != discover.uuid:
# Discovered is not our device.
return
_LOGGER.debug("Discovered dynamic group with same UUID: %s", discover)
self.async_set_cast_info(discover)
async def _async_cast_removed(self, discover: ChromecastInfo):
"""Handle removal of Chromecast."""
if self._cast_info.uuid != discover.uuid:
# Removed is not our device.
return
if not discover.services:
# Clean up the dynamic group
_LOGGER.debug("Clean up dynamic group: %s", discover)
await self.async_tear_down()
async def _async_stop(self, event):
"""Disconnect socket on Home Assistant stop."""
await self._async_disconnect()

View file

@ -1304,7 +1304,7 @@ pycfdns==1.2.1
pychannels==1.0.0 pychannels==1.0.0
# homeassistant.components.cast # homeassistant.components.cast
pychromecast==7.6.0 pychromecast==7.7.1
# homeassistant.components.pocketcasts # homeassistant.components.pocketcasts
pycketcasts==1.0.0 pycketcasts==1.0.0

View file

@ -664,7 +664,7 @@ pybotvac==0.0.19
pycfdns==1.2.1 pycfdns==1.2.1
# homeassistant.components.cast # homeassistant.components.cast
pychromecast==7.6.0 pychromecast==7.7.1
# homeassistant.components.coolmaster # homeassistant.components.coolmaster
pycoolmasternet-async==0.1.2 pycoolmasternet-async==0.1.2

View file

@ -14,6 +14,7 @@ from homeassistant.components.cast.media_player import ChromecastInfo
from homeassistant.config import async_process_ha_core_config from homeassistant.config import async_process_ha_core_config
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import PlatformNotReady from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -21,12 +22,32 @@ from tests.common import MockConfigEntry, assert_setup_component
from tests.components.media_player import common from tests.components.media_player import common
@pytest.fixture()
def dial_mock():
"""Mock pychromecast dial."""
dial_mock = MagicMock()
dial_mock.get_device_status.return_value.uuid = "fake_uuid"
dial_mock.get_device_status.return_value.manufacturer = "fake_manufacturer"
dial_mock.get_device_status.return_value.model_name = "fake_model_name"
dial_mock.get_device_status.return_value.friendly_name = "fake_friendly_name"
dial_mock.get_multizone_status.return_value.dynamic_groups = []
return dial_mock
@pytest.fixture() @pytest.fixture()
def mz_mock(): def mz_mock():
"""Mock pychromecast MultizoneManager.""" """Mock pychromecast MultizoneManager."""
return MagicMock() return MagicMock()
@pytest.fixture()
def pycast_mock():
"""Mock pychromecast."""
pycast_mock = MagicMock()
pycast_mock.start_discovery.return_value = (None, Mock())
return pycast_mock
@pytest.fixture() @pytest.fixture()
def quick_play_mock(): def quick_play_mock():
"""Mock pychromecast quick_play.""" """Mock pychromecast quick_play."""
@ -34,20 +55,14 @@ def quick_play_mock():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def cast_mock(mz_mock, quick_play_mock): def cast_mock(dial_mock, mz_mock, pycast_mock, quick_play_mock):
"""Mock pychromecast.""" """Mock pychromecast."""
pycast_mock = MagicMock()
pycast_mock.start_discovery.return_value = (None, Mock())
dial_mock = MagicMock(name="XXX")
dial_mock.get_device_status.return_value.uuid = "fake_uuid"
dial_mock.get_device_status.return_value.manufacturer = "fake_manufacturer"
dial_mock.get_device_status.return_value.model_name = "fake_model_name"
dial_mock.get_device_status.return_value.friendly_name = "fake_friendly_name"
with patch( with patch(
"homeassistant.components.cast.media_player.pychromecast", pycast_mock "homeassistant.components.cast.media_player.pychromecast", pycast_mock
), patch( ), patch(
"homeassistant.components.cast.discovery.pychromecast", pycast_mock "homeassistant.components.cast.discovery.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.helpers.dial", dial_mock
), patch( ), patch(
"homeassistant.components.cast.media_player.MultizoneManager", "homeassistant.components.cast.media_player.MultizoneManager",
return_value=mz_mock, return_value=mz_mock,
@ -130,6 +145,7 @@ async def async_setup_cast_internal_discovery(hass, config=None):
assert start_discovery.call_count == 1 assert start_discovery.call_count == 1
discovery_callback = cast_listener.call_args[0][0] discovery_callback = cast_listener.call_args[0][0]
remove_callback = cast_listener.call_args[0][1]
def discover_chromecast(service_name: str, info: ChromecastInfo) -> None: def discover_chromecast(service_name: str, info: ChromecastInfo) -> None:
"""Discover a chromecast device.""" """Discover a chromecast device."""
@ -141,7 +157,15 @@ async def async_setup_cast_internal_discovery(hass, config=None):
) )
discovery_callback(info.uuid, service_name) discovery_callback(info.uuid, service_name)
return discover_chromecast, add_entities def remove_chromecast(service_name: str, info: ChromecastInfo) -> None:
"""Remove a chromecast device."""
remove_callback(
info.uuid,
service_name,
(set(), info.uuid, info.model_name, info.friendly_name),
)
return discover_chromecast, remove_chromecast, add_entities
async def async_setup_media_player_cast(hass: HomeAssistantType, info: ChromecastInfo): async def async_setup_media_player_cast(hass: HomeAssistantType, info: ChromecastInfo):
@ -183,7 +207,18 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas
await hass.async_block_till_done() await hass.async_block_till_done()
await hass.async_block_till_done() await hass.async_block_till_done()
assert get_chromecast.call_count == 1 assert get_chromecast.call_count == 1
return chromecast
def discover_chromecast(service_name: str, info: ChromecastInfo) -> None:
"""Discover a chromecast device."""
listener.services[info.uuid] = (
{service_name},
info.uuid,
info.model_name,
info.friendly_name,
)
discovery_callback(info.uuid, service_name)
return chromecast, discover_chromecast
def get_status_callbacks(chromecast_mock, mz_mock=None): def get_status_callbacks(chromecast_mock, mz_mock=None):
@ -219,6 +254,123 @@ async def test_start_discovery_called_once(hass):
assert start_discovery.call_count == 1 assert start_discovery.call_count == 1
async def test_internal_discovery_callback_fill_out(hass):
"""Test internal discovery automatically filling out information."""
discover_cast, _, _ = await async_setup_cast_internal_discovery(hass)
info = get_fake_chromecast_info(host="host1")
zconf = get_fake_zconf(host="host1", port=8009)
full_info = attr.evolve(
info,
model_name="google home",
friendly_name="Speaker",
uuid=FakeUUID,
manufacturer="Nabu Casa",
)
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
), patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf,
):
signal = MagicMock()
async_dispatcher_connect(hass, "cast_discovered", signal)
discover_cast("the-service", info)
await hass.async_block_till_done()
# when called with incomplete info, it should use HTTP to get missing
discover = signal.mock_calls[0][1][0]
assert discover == full_info
async def test_internal_discovery_callback_fill_out_default_manufacturer(hass):
"""Test internal discovery automatically filling out information."""
discover_cast, _, _ = await async_setup_cast_internal_discovery(hass)
info = get_fake_chromecast_info(host="host1")
zconf = get_fake_zconf(host="host1", port=8009)
full_info = attr.evolve(
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
)
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
), patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf,
):
signal = MagicMock()
async_dispatcher_connect(hass, "cast_discovered", signal)
discover_cast("the-service", info)
await hass.async_block_till_done()
# when called with incomplete info, it should use HTTP to get missing
discover = signal.mock_calls[0][1][0]
assert discover == attr.evolve(full_info, manufacturer="Google Inc.")
async def test_internal_discovery_callback_fill_out_fail(hass):
"""Test internal discovery automatically filling out information."""
discover_cast, _, _ = await async_setup_cast_internal_discovery(hass)
info = get_fake_chromecast_info(host="host1")
zconf = get_fake_zconf(host="host1", port=8009)
full_info = (
info # attr.evolve(info, model_name="", friendly_name="Speaker", uuid=FakeUUID)
)
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=None,
), patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf,
):
signal = MagicMock()
async_dispatcher_connect(hass, "cast_discovered", signal)
discover_cast("the-service", info)
await hass.async_block_till_done()
# when called with incomplete info, it should use HTTP to get missing
discover = signal.mock_calls[0][1][0]
assert discover == full_info
# assert 1 == 2
async def test_internal_discovery_callback_fill_out_group(hass):
"""Test internal discovery automatically filling out information."""
discover_cast, _, _ = await async_setup_cast_internal_discovery(hass)
info = get_fake_chromecast_info(host="host1", port=12345)
zconf = get_fake_zconf(host="host1", port=12345)
full_info = attr.evolve(
info,
model_name="",
friendly_name="Speaker",
uuid=FakeUUID,
is_dynamic_group=False,
)
with patch(
"homeassistant.components.cast.helpers.dial.get_device_status",
return_value=full_info,
), patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf,
):
signal = MagicMock()
async_dispatcher_connect(hass, "cast_discovered", signal)
discover_cast("the-service", info)
await hass.async_block_till_done()
# when called with incomplete info, it should use HTTP to get missing
discover = signal.mock_calls[0][1][0]
assert discover == full_info
async def test_stop_discovery_called_on_stop(hass): async def test_stop_discovery_called_on_stop(hass):
"""Test pychromecast.stop_discovery called on shutdown.""" """Test pychromecast.stop_discovery called on shutdown."""
browser = MagicMock(zc={}) browser = MagicMock(zc={})
@ -272,7 +424,7 @@ async def test_replay_past_chromecasts(hass):
zconf_1 = get_fake_zconf(host="host1", port=8009) zconf_1 = get_fake_zconf(host="host1", port=8009)
zconf_2 = get_fake_zconf(host="host2", port=8009) zconf_2 = get_fake_zconf(host="host2", port=8009)
discover_cast, add_dev1 = await async_setup_cast_internal_discovery( discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery(
hass, config={"uuid": FakeUUID} hass, config={"uuid": FakeUUID}
) )
@ -308,7 +460,7 @@ async def test_manual_cast_chromecasts_uuid(hass):
zconf_2 = get_fake_zconf(host="host_2") zconf_2 = get_fake_zconf(host="host_2")
# Manual configuration of media player with host "configured_host" # Manual configuration of media player with host "configured_host"
discover_cast, add_dev1 = await async_setup_cast_internal_discovery( discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery(
hass, config={"uuid": FakeUUID} hass, config={"uuid": FakeUUID}
) )
with patch( with patch(
@ -338,7 +490,7 @@ async def test_auto_cast_chromecasts(hass):
zconf_2 = get_fake_zconf(host="other_host") zconf_2 = get_fake_zconf(host="other_host")
# Manual configuration of media player with host "configured_host" # Manual configuration of media player with host "configured_host"
discover_cast, add_dev1 = await async_setup_cast_internal_discovery(hass) discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery(hass)
with patch( with patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf_1, return_value=zconf_1,
@ -358,6 +510,79 @@ async def test_auto_cast_chromecasts(hass):
assert add_dev1.call_count == 2 assert add_dev1.call_count == 2
async def test_discover_dynamic_group(hass, dial_mock, pycast_mock, caplog):
"""Test dynamic group does not create device or entity."""
cast_1 = get_fake_chromecast_info(host="host_1", port=23456, uuid=FakeUUID)
cast_2 = get_fake_chromecast_info(host="host_2", port=34567, uuid=FakeUUID2)
zconf_1 = get_fake_zconf(host="host_1", port=23456)
zconf_2 = get_fake_zconf(host="host_2", port=34567)
reg = await hass.helpers.entity_registry.async_get_registry()
# Fake dynamic group info
tmp1 = MagicMock()
tmp1.uuid = FakeUUID
tmp2 = MagicMock()
tmp2.uuid = FakeUUID2
dial_mock.get_multizone_status.return_value.dynamic_groups = [tmp1, tmp2]
pycast_mock.get_chromecast_from_service.assert_not_called()
discover_cast, remove_cast, add_dev1 = await async_setup_cast_internal_discovery(
hass
)
# Discover cast service
with patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf_1,
):
discover_cast("service", cast_1)
await hass.async_block_till_done()
await hass.async_block_till_done() # having tasks that add jobs
pycast_mock.get_chromecast_from_service.assert_called()
pycast_mock.get_chromecast_from_service.reset_mock()
assert add_dev1.call_count == 0
assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None
# Discover other dynamic group cast service
with patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf_2,
):
discover_cast("service", cast_2)
await hass.async_block_till_done()
await hass.async_block_till_done() # having tasks that add jobs
pycast_mock.get_chromecast_from_service.assert_called()
pycast_mock.get_chromecast_from_service.reset_mock()
assert add_dev1.call_count == 0
assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None
# Get update for cast service
with patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf_1,
):
discover_cast("service", cast_1)
await hass.async_block_till_done()
await hass.async_block_till_done() # having tasks that add jobs
pycast_mock.get_chromecast_from_service.assert_not_called()
assert add_dev1.call_count == 0
assert reg.async_get_entity_id("media_player", "cast", cast_1.uuid) is None
# Remove cast service
assert "Disconnecting from chromecast" not in caplog.text
with patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
return_value=zconf_1,
):
remove_cast("service", cast_1)
await hass.async_block_till_done()
await hass.async_block_till_done() # having tasks that add jobs
assert "Disconnecting from chromecast" in caplog.text
async def test_update_cast_chromecasts(hass): async def test_update_cast_chromecasts(hass):
"""Test discovery of same UUID twice only adds one cast.""" """Test discovery of same UUID twice only adds one cast."""
cast_1 = get_fake_chromecast_info(host="old_host") cast_1 = get_fake_chromecast_info(host="old_host")
@ -366,7 +591,7 @@ async def test_update_cast_chromecasts(hass):
zconf_2 = get_fake_zconf(host="new_host") zconf_2 = get_fake_zconf(host="new_host")
# Manual configuration of media player with host "configured_host" # Manual configuration of media player with host "configured_host"
discover_cast, add_dev1 = await async_setup_cast_internal_discovery(hass) discover_cast, _, add_dev1 = await async_setup_cast_internal_discovery(hass)
with patch( with patch(
"homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf",
@ -392,7 +617,7 @@ async def test_entity_availability(hass: HomeAssistantType):
entity_id = "media_player.speaker" entity_id = "media_player.speaker"
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast) _, conn_status_cb, _ = get_status_callbacks(chromecast)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
@ -423,7 +648,7 @@ async def test_entity_cast_status(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
cast_status_cb, conn_status_cb, _ = get_status_callbacks(chromecast) cast_status_cb, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -466,7 +691,7 @@ async def test_entity_play_media(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast) _, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -495,7 +720,7 @@ async def test_entity_play_media_cast(hass: HomeAssistantType, quick_play_mock):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast) _, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -528,7 +753,7 @@ async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast) _, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -575,7 +800,7 @@ async def test_entity_play_media_sign_URL(hass: HomeAssistantType):
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, _ = get_status_callbacks(chromecast) _, conn_status_cb, _ = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -601,7 +826,7 @@ async def test_entity_media_content_type(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -655,7 +880,7 @@ async def test_entity_control(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -738,7 +963,7 @@ async def test_entity_media_states(hass: HomeAssistantType):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, media_status_cb = get_status_callbacks(chromecast) _, conn_status_cb, media_status_cb = get_status_callbacks(chromecast)
connection_status = MagicMock() connection_status = MagicMock()
@ -797,7 +1022,7 @@ async def test_group_media_states(hass, mz_mock):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks( _, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks(
chromecast, mz_mock chromecast, mz_mock
) )
@ -841,7 +1066,7 @@ async def test_group_media_states(hass, mz_mock):
async def test_group_media_control(hass, mz_mock): async def test_group_media_control(hass, mz_mock):
"""Test media states are read from group if entity has no state.""" """Test media controls are handled by group if entity has no state."""
entity_id = "media_player.speaker" entity_id = "media_player.speaker"
reg = await hass.helpers.entity_registry.async_get_registry() reg = await hass.helpers.entity_registry.async_get_registry()
@ -850,7 +1075,7 @@ async def test_group_media_control(hass, mz_mock):
info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
) )
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks( _, conn_status_cb, media_status_cb, group_media_status_cb = get_status_callbacks(
chromecast, mz_mock chromecast, mz_mock
@ -904,7 +1129,7 @@ async def test_group_media_control(hass, mz_mock):
async def test_failed_cast_on_idle(hass, caplog): async def test_failed_cast_on_idle(hass, caplog):
"""Test no warning when unless player went idle with reason "ERROR".""" """Test no warning when unless player went idle with reason "ERROR"."""
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, _, media_status_cb = get_status_callbacks(chromecast) _, _, media_status_cb = get_status_callbacks(chromecast)
media_status = MagicMock(images=None) media_status = MagicMock(images=None)
@ -939,7 +1164,7 @@ async def test_failed_cast_other_url(hass, caplog):
) )
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, _, media_status_cb = get_status_callbacks(chromecast) _, _, media_status_cb = get_status_callbacks(chromecast)
media_status = MagicMock(images=None) media_status = MagicMock(images=None)
@ -962,7 +1187,7 @@ async def test_failed_cast_internal_url(hass, caplog):
) )
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, _, media_status_cb = get_status_callbacks(chromecast) _, _, media_status_cb = get_status_callbacks(chromecast)
media_status = MagicMock(images=None) media_status = MagicMock(images=None)
@ -990,7 +1215,7 @@ async def test_failed_cast_external_url(hass, caplog):
) )
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, _, media_status_cb = get_status_callbacks(chromecast) _, _, media_status_cb = get_status_callbacks(chromecast)
media_status = MagicMock(images=None) media_status = MagicMock(images=None)
@ -1014,7 +1239,7 @@ async def test_failed_cast_tts_base_url(hass, caplog):
) )
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
_, _, media_status_cb = get_status_callbacks(chromecast) _, _, media_status_cb = get_status_callbacks(chromecast)
media_status = MagicMock(images=None) media_status = MagicMock(images=None)
@ -1032,7 +1257,7 @@ async def test_disconnect_on_stop(hass: HomeAssistantType):
"""Test cast device disconnects socket on stop.""" """Test cast device disconnects socket on stop."""
info = get_fake_chromecast_info() info = get_fake_chromecast_info()
chromecast = await async_setup_media_player_cast(hass, info) chromecast, _ = await async_setup_media_player_cast(hass, info)
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done() await hass.async_block_till_done()