Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions homeassistant/components/cast/__init__.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,42 @@
"""Component to embed Google Cast."""
import logging

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.helpers import config_validation as cv

from . import home_assistant_cast
from .const import DOMAIN
from .media_player import ENTITY_SCHEMA

# Deprecated from 2021.4, remove in 2021.6
CONFIG_SCHEMA = cv.deprecated(DOMAIN)
Copy link
Copy Markdown
Member

@balloob balloob Mar 22, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, we had no config schema to begin with and just passed that to import? 🤔


_LOGGER = logging.getLogger(__name__)


async def async_setup(hass, config):
"""Set up the Cast component."""
conf = config.get(DOMAIN)

hass.data[DOMAIN] = conf or {}

if conf is not None:
media_player_config_validated = []
media_player_config = conf.get("media_player", {})
if not isinstance(media_player_config, list):
media_player_config = [media_player_config]
for cfg in media_player_config:
try:
cfg = ENTITY_SCHEMA(cfg)
media_player_config_validated.append(cfg)
except vol.Error as ex:
_LOGGER.warning("Invalid config '%s': %s", cfg, ex)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we accept a partial valid config to be more backwards compatible? The alternative would be to extend the config schema instead which would fail the whole config on invalid result.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the intention is to accept a partial valid config by simply skipping invalid media players instead of rejecting the entire config.


hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=media_player_config_validated,
)
)

Expand Down
88 changes: 66 additions & 22 deletions homeassistant/components/cast/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
from homeassistant import config_entries
from homeassistant.helpers import config_validation as cv

from .const import CONF_KNOWN_HOSTS, DOMAIN
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, CONF_UUID, DOMAIN

IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))


class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
Expand All @@ -17,7 +19,9 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN):

def __init__(self):
"""Initialize flow."""
self._known_hosts = None
self._ignore_cec = set()
self._known_hosts = set()
self._wanted_uuid = set()

@staticmethod
def async_get_options_flow(config_entry):
Expand All @@ -28,7 +32,15 @@ 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}

media_player_config = import_data or []
for cfg in media_player_config:
if CONF_IGNORE_CEC in cfg:
self._ignore_cec.update(set(cfg[CONF_IGNORE_CEC]))
if CONF_UUID in cfg:
self._wanted_uuid.add(cfg[CONF_UUID])

data = self._get_data()
return self.async_create_entry(title="Google Cast", data=data)

async def async_step_user(self, user_input=None):
Expand Down Expand Up @@ -62,7 +74,8 @@ async def async_step_config(self, user_input=None):
errors["base"] = "invalid_known_hosts"
bad_hosts = True
else:
data[CONF_KNOWN_HOSTS] = known_hosts
self._known_hosts = known_hosts
data = self._get_data()
if not bad_hosts:
return self.async_create_entry(title="Google Cast", data=data)

Expand All @@ -76,13 +89,20 @@ async def async_step_config(self, user_input=None):
async def async_step_confirm(self, user_input=None):
"""Confirm the setup."""

data = {CONF_KNOWN_HOSTS: self._known_hosts}
data = self._get_data()

if user_input is not None:
return self.async_create_entry(title="Google Cast", data=data)

return self.async_show_form(step_id="confirm")

def _get_data(self):
return {
CONF_IGNORE_CEC: list(self._ignore_cec),
CONF_KNOWN_HOSTS: list(self._known_hosts),
CONF_UUID: list(self._wanted_uuid),
}


class CastOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle Google Cast options."""
Expand All @@ -102,35 +122,59 @@ async def async_step_options(self, user_input=None):
errors = {}
current_config = self.config_entry.data
if user_input is not None:
bad_hosts = False
bad_cec, ignore_cec = _string_to_list(
user_input.get(CONF_IGNORE_CEC, ""), IGNORE_CEC_SCHEMA
)
bad_hosts, known_hosts = _string_to_list(
user_input.get(CONF_KNOWN_HOSTS, ""), KNOWN_HOSTS_SCHEMA
)
bad_uuid, wanted_uuid = _string_to_list(
user_input.get(CONF_UUID, ""), WANTED_UUID_SCHEMA
)

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:
if not bad_cec and not bad_hosts and not bad_uuid:
updated_config = {}
updated_config[CONF_IGNORE_CEC] = ignore_cec
updated_config[CONF_KNOWN_HOSTS] = known_hosts
updated_config[CONF_UUID] = wanted_uuid
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
suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS))
_add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value)
if self.show_advanced_options:
suggested_value = _list_to_string(current_config.get(CONF_UUID))
_add_with_suggestion(fields, CONF_UUID, suggested_value)
suggested_value = _list_to_string(current_config.get(CONF_IGNORE_CEC))
_add_with_suggestion(fields, CONF_IGNORE_CEC, suggested_value)

return self.async_show_form(
step_id="options",
data_schema=vol.Schema(fields),
errors=errors,
)


def _list_to_string(items):
comma_separated_string = ""
if items:
comma_separated_string = ",".join(items)
return comma_separated_string


def _string_to_list(string, schema):
invalid = False
items = [x.strip() for x in string.split(",") if x.strip()]
try:
items = schema(items)
except vol.Invalid:
invalid = True

return invalid, items


def _add_with_suggestion(fields, key, suggested_value):
fields[vol.Optional(key, description={"suggested_value": suggested_value})] = str
5 changes: 2 additions & 3 deletions homeassistant/components/cast/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@

# 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"
Expand All @@ -27,4 +24,6 @@
# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view.
SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view"

CONF_IGNORE_CEC = "ignore_cec"
CONF_KNOWN_HOSTS = "known_hosts"
CONF_UUID = "uuid"
7 changes: 1 addition & 6 deletions homeassistant/components/cast/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
CONF_KNOWN_HOSTS,
DEFAULT_PORT,
INTERNAL_DISCOVERY_RUNNING_KEY,
KNOWN_CHROMECAST_INFO_KEY,
SIGNAL_CAST_DISCOVERED,
SIGNAL_CAST_REMOVED,
)
Expand All @@ -38,12 +37,8 @@ def discover_chromecast(hass: HomeAssistant, device_info):
return

info = info.fill_out_missing_chromecast_info()
if info.uuid in hass.data[KNOWN_CHROMECAST_INFO_KEY]:
_LOGGER.debug("Discovered update for known chromecast %s", info)
else:
_LOGGER.debug("Discovered chromecast %s", info)
_LOGGER.debug("Discovered new or updated chromecast %s", info)

hass.data[KNOWN_CHROMECAST_INFO_KEY][info.uuid] = info
dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)


Expand Down
55 changes: 11 additions & 44 deletions homeassistant/components/cast/media_player.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Provide functionality to interact with Cast devices on the network."""
from __future__ import annotations

import asyncio
from contextlib import suppress
from datetime import timedelta
import functools as ft
Expand Down Expand Up @@ -52,19 +51,19 @@
STATE_PLAYING,
)
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
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from homeassistant.helpers.typing import HomeAssistantType
import homeassistant.util.dt as dt_util
from homeassistant.util.logging import async_create_catching_coro

from .const import (
ADDED_CAST_DEVICES_KEY,
CAST_MULTIZONE_MANAGER_KEY,
CONF_IGNORE_CEC,
CONF_UUID,
DOMAIN as CAST_DOMAIN,
KNOWN_CHROMECAST_INFO_KEY,
SIGNAL_CAST_DISCOVERED,
SIGNAL_CAST_REMOVED,
SIGNAL_HASS_CAST_SHOW_VIEW,
Expand All @@ -74,8 +73,6 @@

_LOGGER = logging.getLogger(__name__)

CONF_IGNORE_CEC = "ignore_cec"
CONF_UUID = "uuid"
CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png"

SUPPORT_CAST = (
Expand Down Expand Up @@ -129,57 +126,27 @@ def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo):

async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Cast from a config entry."""
config = hass.data[CAST_DOMAIN].get("media_player") or {}
if not isinstance(config, list):
config = [config]

# no pending task
done, _ = await asyncio.wait(
[
_async_setup_platform(
hass, ENTITY_SCHEMA(cfg), async_add_entities, config_entry
)
for cfg in config
]
)
if any(task.exception() for task in done):
exceptions = [task.exception() for task in done]
for exception in exceptions:
_LOGGER.debug("Failed to setup chromecast", exc_info=exception)
raise PlatformNotReady


async def _async_setup_platform(
hass: HomeAssistantType, config: ConfigType, async_add_entities, config_entry
):
"""Set up the cast platform."""
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, [])
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, {})

wanted_uuid = None
if CONF_UUID in config:
wanted_uuid = config[CONF_UUID]
# Import CEC IGNORE attributes
pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC) or []
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit weird that we're modifying a constant.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but that's how the lib is designed. It could be refactored to a more sane API, with a setter function, but not in this PR.


wanted_uuids = config_entry.data.get(CONF_UUID) or None

@callback
def async_cast_discovered(discover: ChromecastInfo) -> None:
"""Handle discovery of a new chromecast."""
# If wanted_uuid is set, we're handling a specific cast device identified by UUID
if wanted_uuid is not None and wanted_uuid != discover.uuid:
# UUID not matching, this is not it.
# If wanted_uuids is set, we're only accepting specific cast devices identified
# by UUID
if wanted_uuids is not None and discover.uuid not in wanted_uuids:
# UUID not matching, ignore.
return

cast_device = _async_create_cast_device(hass, discover)
if cast_device is not None:
async_add_entities([cast_device])

async_dispatcher_connect(hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
# Re-play the callback for all past chromecasts, store the objects in
# a list to avoid concurrent modification resulting in exception.
for chromecast in hass.data[KNOWN_CHROMECAST_INFO_KEY].values():
async_cast_discovered(chromecast)

ChromeCastZeroconf.set_zeroconf(await zeroconf.async_get_instance(hass))
hass.async_add_executor_job(setup_internal_discovery, hass, config_entry)

Expand Down
4 changes: 3 additions & 1 deletion homeassistant/components/cast/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
"options": {
"description": "Please enter the Google Cast configuration.",
"data": {
"known_hosts": "Optional list of known hosts if mDNS discovery is not working."
"ignore_cec": "Optional list which will be passed to pychromecast.IGNORE_CEC.",
"known_hosts": "Optional list of known hosts if mDNS discovery is not working.",
"uuid": "Optional list of UUIDs. Casts not listed will not be added."
}
}
},
Expand Down
4 changes: 3 additions & 1 deletion homeassistant/components/cast/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@
"step": {
"options": {
"data": {
"known_hosts": "Optional list of known hosts if mDNS discovery is not working."
"ignore_cec": "Optional list which will be passed to pychromecast.IGNORE_CEC.",
"known_hosts": "Optional list of known hosts if mDNS discovery is not working.",
"uuid": "Optional list of UUIDs. Casts not listed will not be added."
},
"description": "Please enter the Google Cast configuration."
}
Expand Down
1 change: 1 addition & 0 deletions tests/components/cast/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def mz_mock():
def pycast_mock(castbrowser_mock, castbrowser_constructor_mock):
"""Mock pychromecast."""
pycast_mock = MagicMock()
pycast_mock.IGNORE_CEC = []
pycast_mock.discovery.CastBrowser = castbrowser_constructor_mock
pycast_mock.discovery.CastBrowser.return_value = castbrowser_mock
pycast_mock.discovery.AbstractCastListener = (
Expand Down
6 changes: 1 addition & 5 deletions tests/components/cast/test_home_assistant_cast.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,12 +102,8 @@ async def test_remove_entry(hass, mock_zeroconf):
entry.add_to_hass(hass)

with patch(
"homeassistant.components.cast.media_player._async_setup_platform"
), patch(
"pychromecast.discovery.discover_chromecasts", return_value=(True, None)
), patch(
"pychromecast.discovery.stop_discovery"
):
), patch("pychromecast.discovery.stop_discovery"):
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert "cast" in hass.config.components
Expand Down
Loading