Add support for a list of known hosts to Google Cast (#47232)

pull/46948/head^2
Erik Montnemery 2021-03-02 00:18:18 +01:00 committed by GitHub
parent dd9e926689
commit 96cc17b462
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 417 additions and 102 deletions

View File

@ -1,35 +1,136 @@
"""Config flow for Cast."""
import functools
from pychromecast.discovery import discover_chromecasts, stop_discovery
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components import zeroconf
from homeassistant.helpers import config_entry_flow
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .helpers import ChromeCastZeroconf
from .const import CONF_KNOWN_HOSTS, DOMAIN
KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
async def _async_has_devices(hass):
"""
Return if there are devices that can be discovered.
class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
This function will be called if no devices are already found through the zeroconf
integration.
"""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
zeroconf_instance = ChromeCastZeroconf.get_zeroconf()
if zeroconf_instance is None:
zeroconf_instance = await zeroconf.async_get_instance(hass)
def __init__(self):
"""Initialize flow."""
self._known_hosts = None
casts, browser = await hass.async_add_executor_job(
functools.partial(discover_chromecasts, zeroconf_instance=zeroconf_instance)
)
stop_discovery(browser)
return casts
@staticmethod
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return CastOptionsFlowHandler(config_entry)
async def async_step_import(self, import_data=None):
"""Import data."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
data = {CONF_KNOWN_HOSTS: self._known_hosts}
return self.async_create_entry(title="Google Cast", data=data)
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
return await self.async_step_config()
async def async_step_zeroconf(self, discovery_info):
"""Handle a flow initialized by zeroconf discovery."""
if self._async_in_progress() or self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
await self.async_set_unique_id(DOMAIN)
return await self.async_step_confirm()
async def async_step_config(self, user_input=None):
"""Confirm the setup."""
errors = {}
data = {CONF_KNOWN_HOSTS: self._known_hosts}
if user_input is not None:
bad_hosts = False
known_hosts = user_input[CONF_KNOWN_HOSTS]
known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()]
try:
known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts)
except vol.Invalid:
errors["base"] = "invalid_known_hosts"
bad_hosts = True
else:
data[CONF_KNOWN_HOSTS] = known_hosts
if not bad_hosts:
return self.async_create_entry(title="Google Cast", data=data)
fields = {}
fields[vol.Optional(CONF_KNOWN_HOSTS, default="")] = str
return self.async_show_form(
step_id="config", data_schema=vol.Schema(fields), errors=errors
)
async def async_step_confirm(self, user_input=None):
"""Confirm the setup."""
data = {CONF_KNOWN_HOSTS: self._known_hosts}
if user_input is not None:
return self.async_create_entry(title="Google Cast", data=data)
return self.async_show_form(step_id="confirm")
config_entry_flow.register_discovery_flow(
DOMAIN, "Google Cast", _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH
)
class CastOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Google Cast options."""
def __init__(self, config_entry):
"""Initialize MQTT options flow."""
self.config_entry = config_entry
self.broker_config = {}
self.options = dict(config_entry.options)
async def async_step_init(self, user_input=None):
"""Manage the Cast options."""
return await self.async_step_options()
async def async_step_options(self, user_input=None):
"""Manage the MQTT options."""
errors = {}
current_config = self.config_entry.data
if user_input is not None:
bad_hosts = False
known_hosts = user_input.get(CONF_KNOWN_HOSTS, "")
known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()]
try:
known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts)
except vol.Invalid:
errors["base"] = "invalid_known_hosts"
bad_hosts = True
if not bad_hosts:
updated_config = {}
updated_config[CONF_KNOWN_HOSTS] = known_hosts
self.hass.config_entries.async_update_entry(
self.config_entry, data=updated_config
)
return self.async_create_entry(title="", data=None)
fields = {}
known_hosts_string = ""
if current_config.get(CONF_KNOWN_HOSTS):
known_hosts_string = ",".join(current_config.get(CONF_KNOWN_HOSTS))
fields[
vol.Optional(
"known_hosts", description={"suggested_value": known_hosts_string}
)
] = str
return self.async_show_form(
step_id="options",
data_schema=vol.Schema(fields),
errors=errors,
)

View File

@ -13,6 +13,8 @@ KNOWN_CHROMECAST_INFO_KEY = "cast_known_chromecasts"
ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices"
# Stores an audio group manager.
CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager"
# Store a CastBrowser
CAST_BROWSER_KEY = "cast_browser"
# Dispatcher signal fired with a ChromecastInfo every time we discover a new
# Chromecast or receive it through configuration
@ -24,3 +26,5 @@ SIGNAL_CAST_REMOVED = "cast_removed"
# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view.
SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view"
CONF_KNOWN_HOSTS = "known_hosts"

View File

@ -9,6 +9,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
CAST_BROWSER_KEY,
CONF_KNOWN_HOSTS,
DEFAULT_PORT,
INTERNAL_DISCOVERY_RUNNING_KEY,
KNOWN_CHROMECAST_INFO_KEY,
@ -52,7 +54,7 @@ def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo):
dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
def setup_internal_discovery(hass: HomeAssistant) -> None:
def setup_internal_discovery(hass: HomeAssistant, config_entry) -> None:
"""Set up the pychromecast internal discovery."""
if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
@ -86,8 +88,11 @@ def setup_internal_discovery(hass: HomeAssistant) -> None:
_LOGGER.debug("Starting internal pychromecast discovery")
browser = pychromecast.discovery.CastBrowser(
CastListener(), ChromeCastZeroconf.get_zeroconf()
CastListener(),
ChromeCastZeroconf.get_zeroconf(),
config_entry.data.get(CONF_KNOWN_HOSTS),
)
hass.data[CAST_BROWSER_KEY] = browser
browser.start_discovery()
def stop_discovery(event):
@ -97,3 +102,11 @@ def setup_internal_discovery(hass: HomeAssistant) -> None:
hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery)
config_entry.add_update_listener(config_entry_updated)
async def config_entry_updated(hass, config_entry):
"""Handle config entry being updated."""
browser = hass.data[CAST_BROWSER_KEY]
browser.host_browser.update_hosts(config_entry.data.get(CONF_KNOWN_HOSTS))

View File

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

View File

@ -134,7 +134,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
# no pending task
done, _ = await asyncio.wait(
[
_async_setup_platform(hass, ENTITY_SCHEMA(cfg), async_add_entities)
_async_setup_platform(
hass, ENTITY_SCHEMA(cfg), async_add_entities, config_entry
)
for cfg in config
]
)
@ -146,7 +148,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async def _async_setup_platform(
hass: HomeAssistantType, config: ConfigType, async_add_entities
hass: HomeAssistantType, config: ConfigType, async_add_entities, config_entry
):
"""Set up the cast platform."""
# Import CEC IGNORE attributes
@ -177,7 +179,7 @@ async def _async_setup_platform(
async_cast_discovered(chromecast)
ChromeCastZeroconf.set_zeroconf(await zeroconf.async_get_instance(hass))
hass.async_add_executor_job(setup_internal_discovery, hass)
hass.async_add_executor_job(setup_internal_discovery, hass, config_entry)
class CastDevice(MediaPlayerEntity):

View File

@ -3,11 +3,33 @@
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"config": {
"title": "Google Cast",
"description": "Please enter the Google Cast configuration.",
"data": {
"known_hosts": "Optional list of known hosts if mDNS discovery is not working."
}
}
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"invalid_known_hosts": "Known hosts must be a comma separated list of hosts."
}
},
"options": {
"step": {
"options": {
"description": "Please enter the Google Cast configuration.",
"data": {
"known_hosts": "Optional list of known hosts if mDNS discovery is not working."
}
}
},
"error": {
"invalid_known_hosts": "Known hosts must be a comma separated list of hosts."
}
}
}

View File

@ -1,13 +1,35 @@
{
"config": {
"abort": {
"no_devices_found": "No devices found on the network",
"single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"invalid_known_hosts": "Known hosts must be a comma separated list of hosts."
},
"step": {
"config": {
"data": {
"known_hosts": "Optional list of known hosts if mDNS discovery is not working."
},
"description": "Please enter the Google Cast configuration.",
"title": "Google Cast"
},
"confirm": {
"description": "Do you want to start set up?"
}
}
},
"options": {
"error": {
"invalid_known_hosts": "Known hosts must be a comma separated list of hosts."
},
"step": {
"options": {
"data": {
"known_hosts": "Optional list of known hosts if mDNS discovery is not working."
},
"description": "Please enter the Google Cast configuration."
}
}
}
}

View File

@ -1302,7 +1302,7 @@ pycfdns==1.2.1
pychannels==1.0.0
# homeassistant.components.cast
pychromecast==9.0.0
pychromecast==9.1.1
# homeassistant.components.pocketcasts
pycketcasts==1.0.0

View File

@ -685,7 +685,7 @@ pybotvac==0.0.20
pycfdns==1.2.1
# homeassistant.components.cast
pychromecast==9.0.0
pychromecast==9.1.1
# homeassistant.components.climacell
pyclimacell==0.14.0

View File

@ -0,0 +1,76 @@
"""Test fixtures for the cast integration."""
# pylint: disable=protected-access
from unittest.mock import AsyncMock, MagicMock, patch
import pychromecast
import pytest
@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()
def castbrowser_mock():
"""Mock pychromecast CastBrowser."""
return MagicMock()
@pytest.fixture()
def castbrowser_constructor_mock():
"""Mock pychromecast CastBrowser constructor."""
return MagicMock()
@pytest.fixture()
def mz_mock():
"""Mock pychromecast MultizoneManager."""
return MagicMock()
@pytest.fixture()
def pycast_mock(castbrowser_mock, castbrowser_constructor_mock):
"""Mock pychromecast."""
pycast_mock = MagicMock()
pycast_mock.discovery.CastBrowser = castbrowser_constructor_mock
pycast_mock.discovery.CastBrowser.return_value = castbrowser_mock
pycast_mock.discovery.AbstractCastListener = (
pychromecast.discovery.AbstractCastListener
)
return pycast_mock
@pytest.fixture()
def quick_play_mock():
"""Mock pychromecast quick_play."""
return MagicMock()
@pytest.fixture(autouse=True)
def cast_mock(dial_mock, mz_mock, pycast_mock, quick_play_mock):
"""Mock pychromecast."""
with patch(
"homeassistant.components.cast.media_player.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.discovery.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.helpers.dial", dial_mock
), patch(
"homeassistant.components.cast.media_player.MultizoneManager",
return_value=mz_mock,
), patch(
"homeassistant.components.cast.media_player.zeroconf.async_get_instance",
AsyncMock(),
), patch(
"homeassistant.components.cast.media_player.quick_play",
quick_play_mock,
):
yield

View File

@ -1,11 +1,14 @@
"""Tests for the Cast config flow."""
from unittest.mock import ANY, patch
from unittest.mock import patch
import pytest
from homeassistant import config_entries, data_entry_flow
from homeassistant.components import cast
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
async def test_creating_entry_sets_up_media_player(hass):
"""Test setting up Cast loads the media player."""
@ -54,3 +57,138 @@ async def test_not_configuring_cast_not_creates_entry(hass):
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 0
@pytest.mark.parametrize("source", ["import", "user", "zeroconf"])
async def test_single_instance(hass, source):
"""Test we only allow a single config flow."""
MockConfigEntry(domain="cast").add_to_hass(hass)
await hass.async_block_till_done()
result = await hass.config_entries.flow.async_init(
"cast", context={"source": source}
)
assert result["type"] == "abort"
assert result["reason"] == "single_instance_allowed"
async def test_user_setup(hass, mqtt_mock):
"""Test we can finish a config flow."""
result = await hass.config_entries.flow.async_init(
"cast", context={"source": "user"}
)
assert result["type"] == "form"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
users = await hass.auth.async_get_users()
assert len(users) == 1
assert result["type"] == "create_entry"
assert result["result"].data == {
"known_hosts": [],
"user_id": users[0].id, # Home Assistant cast user
}
async def test_user_setup_options(hass, mqtt_mock):
"""Test we can finish a config flow."""
result = await hass.config_entries.flow.async_init(
"cast", context={"source": "user"}
)
assert result["type"] == "form"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"known_hosts": "192.168.0.1, , 192.168.0.2 "}
)
users = await hass.auth.async_get_users()
assert len(users) == 1
assert result["type"] == "create_entry"
assert result["result"].data == {
"known_hosts": ["192.168.0.1", "192.168.0.2"],
"user_id": users[0].id, # Home Assistant cast user
}
async def test_zeroconf_setup(hass):
"""Test we can finish a config flow through zeroconf."""
result = await hass.config_entries.flow.async_init(
"cast", context={"source": "zeroconf"}
)
assert result["type"] == "form"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
users = await hass.auth.async_get_users()
assert len(users) == 1
assert result["type"] == "create_entry"
assert result["result"].data == {
"known_hosts": None,
"user_id": users[0].id, # Home Assistant cast user
}
def get_suggested(schema, key):
"""Get suggested value for key in voluptuous schema."""
for k in schema.keys():
if k == key:
if k.description is None or "suggested_value" not in k.description:
return None
return k.description["suggested_value"]
async def test_option_flow(hass):
"""Test config flow options."""
config_entry = MockConfigEntry(
domain="cast", data={"known_hosts": ["192.168.0.10", "192.168.0.11"]}
)
config_entry.add_to_hass(hass)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "options"
data_schema = result["data_schema"].schema
assert get_suggested(data_schema, "known_hosts") == "192.168.0.10,192.168.0.11"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"known_hosts": "192.168.0.1, , 192.168.0.2 "},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] is None
assert config_entry.data == {"known_hosts": ["192.168.0.1", "192.168.0.2"]}
async def test_known_hosts(hass, castbrowser_mock, castbrowser_constructor_mock):
"""Test known hosts is passed to pychromecasts."""
result = await hass.config_entries.flow.async_init(
"cast", context={"source": "user"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"known_hosts": "192.168.0.1, 192.168.0.2"}
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
config_entry = hass.config_entries.async_entries("cast")[0]
assert castbrowser_mock.start_discovery.call_count == 1
castbrowser_constructor_mock.assert_called_once_with(
ANY, ANY, ["192.168.0.1", "192.168.0.2"]
)
castbrowser_mock.reset_mock()
castbrowser_constructor_mock.reset_mock()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"known_hosts": "192.168.0.11, 192.168.0.12"},
)
await hass.async_block_till_done()
castbrowser_mock.start_discovery.assert_not_called()
castbrowser_constructor_mock.assert_not_called()
castbrowser_mock.host_browser.update_hosts.assert_called_once_with(
["192.168.0.11", "192.168.0.12"]
)

View File

@ -2,7 +2,7 @@
# pylint: disable=protected-access
import json
from typing import Optional
from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch
from unittest.mock import ANY, MagicMock, Mock, patch
from uuid import UUID
import attr
@ -35,70 +35,6 @@ from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, assert_setup_component
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()
def castbrowser_mock():
"""Mock pychromecast CastBrowser."""
return MagicMock()
@pytest.fixture()
def mz_mock():
"""Mock pychromecast MultizoneManager."""
return MagicMock()
@pytest.fixture()
def pycast_mock(castbrowser_mock):
"""Mock pychromecast."""
pycast_mock = MagicMock()
pycast_mock.discovery.CastBrowser.return_value = castbrowser_mock
pycast_mock.discovery.AbstractCastListener = (
pychromecast.discovery.AbstractCastListener
)
return pycast_mock
@pytest.fixture()
def quick_play_mock():
"""Mock pychromecast quick_play."""
return MagicMock()
@pytest.fixture(autouse=True)
def cast_mock(dial_mock, mz_mock, pycast_mock, quick_play_mock):
"""Mock pychromecast."""
with patch(
"homeassistant.components.cast.media_player.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.discovery.pychromecast", pycast_mock
), patch(
"homeassistant.components.cast.helpers.dial", dial_mock
), patch(
"homeassistant.components.cast.media_player.MultizoneManager",
return_value=mz_mock,
), patch(
"homeassistant.components.cast.media_player.zeroconf.async_get_instance",
AsyncMock(),
), patch(
"homeassistant.components.cast.media_player.quick_play",
quick_play_mock,
):
yield
# pylint: disable=invalid-name
FakeUUID = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e2")
FakeUUID2 = UUID("57355bce-9364-4aa6-ac1e-eb849dccf9e4")
@ -482,7 +418,8 @@ async def test_replay_past_chromecasts(hass):
assert add_dev1.call_count == 1
add_dev2 = Mock()
await cast._async_setup_platform(hass, {"host": "host2"}, add_dev2)
entry = hass.config_entries.async_entries("cast")[0]
await cast._async_setup_platform(hass, {"host": "host2"}, add_dev2, entry)
await hass.async_block_till_done()
assert add_dev2.call_count == 1