Skip to content

Commit

Permalink
Add app support for TVs to Vizio integration (home-assistant#32432)
Browse files Browse the repository at this point in the history
* add app support

* code cleanup, add additional test, add CONF_APPS storage logic for import

* simplify schema defaults logic

* remove unnecessary lower() and fix docstring

* remove default return for popping CONF_APPS during import update because we know entry data has CONF_APPS due to if statement

* further simplification

* even more simplification

* fix type hints

* move app configuration to separate step, fix tests, and only make app updates if device_type == tv

* remove errors variable from tv_apps and move tv_apps schema out of ConfigFlow for consistency

* slight refactor

* remove unused error from strings.json

* set unique id as early as possible

* correct which dictionary to use to set unique id in pair_tv step
  • Loading branch information
balloob authored Mar 5, 2020
1 parent 873bf88 commit a579fcf
Show file tree
Hide file tree
Showing 12 changed files with 729 additions and 73 deletions.
19 changes: 17 additions & 2 deletions homeassistant/components/vizio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,29 @@

import voluptuous as vol

from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType

from .const import DOMAIN, VIZIO_SCHEMA
from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA


def validate_apps(config: ConfigType) -> ConfigType:
"""Validate CONF_APPS is only used when CONF_DEVICE_CLASS == DEVICE_CLASS_TV."""
if (
config.get(CONF_APPS) is not None
and config[CONF_DEVICE_CLASS] != DEVICE_CLASS_TV
):
raise vol.Invalid(
f"'{CONF_APPS}' can only be used if {CONF_DEVICE_CLASS}' is '{DEVICE_CLASS_TV}'"
)

return config


CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [vol.Schema(VIZIO_SCHEMA)])},
{DOMAIN: vol.All(cv.ensure_list, [vol.All(VIZIO_SCHEMA, validate_apps)])},
extra=vol.ALLOW_EXTRA,
)

Expand Down
149 changes: 112 additions & 37 deletions homeassistant/components/vizio/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,22 @@
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST,
CONF_INCLUDE,
CONF_NAME,
CONF_PIN,
CONF_PORT,
CONF_TYPE,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import (
CONF_APPS,
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
CONF_INCLUDE_OR_EXCLUDE,
CONF_VOLUME_STEP,
DEFAULT_DEVICE_CLASS,
DEFAULT_NAME,
Expand All @@ -34,7 +40,11 @@


def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
"""Return schema defaults for config data based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema."""
"""
Return schema defaults for init step based on user input/config dict.
Retain info already provided for future form views by setting them as defaults in schema.
"""
if input_dict is None:
input_dict = {}

Expand All @@ -57,13 +67,16 @@ def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:


def _get_pairing_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
"""Return schema defaults for pairing data based on user input. Retain info already provided for future form views by setting them as defaults in schema."""
"""
Return schema defaults for pairing data based on user input.
Retain info already provided for future form views by setting them as defaults in schema.
"""
if input_dict is None:
input_dict = {}

return vol.Schema(
{vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str},
extra=vol.ALLOW_EXTRA,
{vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}
)


Expand All @@ -73,7 +86,7 @@ def _host_is_same(host1: str, host2: str) -> bool:


class VizioOptionsConfigFlow(config_entries.OptionsFlow):
"""Handle Transmission client options."""
"""Handle Vizio options."""

def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize vizio options flow."""
Expand Down Expand Up @@ -117,22 +130,18 @@ def __init__(self) -> None:
self._ch_type = None
self._pairing_token = None
self._data = None
self._apps = {}

async def _create_entry_if_unique(
self, input_dict: Dict[str, Any]
) -> Dict[str, Any]:
"""Check if unique_id doesn't already exist. If it does, abort. If it doesn't, create entry."""
unique_id = await VizioAsync.get_unique_id(
input_dict[CONF_HOST],
input_dict.get(CONF_ACCESS_TOKEN),
input_dict[CONF_DEVICE_CLASS],
session=async_get_clientsession(self.hass, False),
)
# Remove extra keys that will not be used by entry setup
input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None)
input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None)

# Set unique ID and abort if unique ID is already configured on an entry or a flow
# with the unique ID is already in progress
await self.async_set_unique_id(unique_id=unique_id, raise_on_progress=True)
self._abort_if_unique_id_configured()
if self._apps:
input_dict[CONF_APPS] = self._apps

return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict)

Expand Down Expand Up @@ -172,6 +181,27 @@ async def async_step_user(
errors["base"] = "cant_connect"

if not errors:
unique_id = await VizioAsync.get_unique_id(
user_input[CONF_HOST],
user_input.get(CONF_ACCESS_TOKEN),
user_input[CONF_DEVICE_CLASS],
session=async_get_clientsession(self.hass, False),
)

# Set unique ID and abort if unique ID is already configured on an entry or a flow
# with the unique ID is already in progress
await self.async_set_unique_id(
unique_id=unique_id, raise_on_progress=True
)
self._abort_if_unique_id_configured()

# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
if (
user_input[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
and self.context["source"] != SOURCE_IMPORT
):
self._data = copy.deepcopy(user_input)
return await self.async_step_tv_apps()
return await self._create_entry_if_unique(user_input)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
elif self._must_show_form and self.context["source"] == SOURCE_IMPORT:
Expand All @@ -180,11 +210,9 @@ async def async_step_user(
# their configuration.yaml or to proceed with config flow pairing. We
# will also provide contextual message to user explaining why
_LOGGER.warning(
"Couldn't complete configuration.yaml import: '%s' key is missing. To "
"complete setup, '%s' can be obtained by going through pairing process "
"via frontend Integrations menu; to avoid re-pairing your device in the "
"future, once you have finished pairing, it is recommended to add "
"obtained value to your config ",
"Couldn't complete configuration.yaml import: '%s' key is "
"missing. Either provide '%s' key in configuration.yaml or "
"finish setup by completing configuration via frontend.",
CONF_ACCESS_TOKEN,
CONF_ACCESS_TOKEN,
)
Expand All @@ -210,20 +238,32 @@ async def async_step_import(self, import_config: Dict[str, Any]) -> Dict[str, An
for entry in self.hass.config_entries.async_entries(DOMAIN):
if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]):
updated_options = {}
updated_name = {}
updated_data = {}
remove_apps = False

if entry.data[CONF_NAME] != import_config[CONF_NAME]:
updated_name[CONF_NAME] = import_config[CONF_NAME]
updated_data[CONF_NAME] = import_config[CONF_NAME]

# Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and
# pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified
if entry.data.get(CONF_APPS) != import_config.get(CONF_APPS):
if not import_config.get(CONF_APPS):
remove_apps = True
else:
updated_data[CONF_APPS] = import_config[CONF_APPS]

if entry.data.get(CONF_VOLUME_STEP) != import_config[CONF_VOLUME_STEP]:
updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP]

if updated_options or updated_name:
if updated_options or updated_data or remove_apps:
new_data = entry.data.copy()
new_options = entry.options.copy()

if updated_name:
new_data.update(updated_name)
if remove_apps:
new_data.pop(CONF_APPS)

if updated_data:
new_data.update(updated_data)

if updated_options:
new_data.update(updated_options)
Expand All @@ -237,6 +277,10 @@ async def async_step_import(self, import_config: Dict[str, Any]) -> Dict[str, An
return self.async_abort(reason="already_setup")

self._must_show_form = True
# Store config key/value pairs that are not configurable in user step so they
# don't get lost on user step
if import_config.get(CONF_APPS):
self._apps = copy.deepcopy(import_config[CONF_APPS])
return await self.async_step_user(user_input=import_config)

async def async_step_zeroconf(
Expand Down Expand Up @@ -319,12 +363,26 @@ async def async_step_pair_tv(
self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token
self._must_show_form = True

unique_id = await VizioAsync.get_unique_id(
self._data[CONF_HOST],
self._data[CONF_ACCESS_TOKEN],
self._data[CONF_DEVICE_CLASS],
session=async_get_clientsession(self.hass, False),
)

# Set unique ID and abort if unique ID is already configured on an entry or a flow
# with the unique ID is already in progress
await self.async_set_unique_id(
unique_id=unique_id, raise_on_progress=True
)
self._abort_if_unique_id_configured()

# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
if self.context["source"] == SOURCE_IMPORT:
# If user is pairing via config import, show different message
return await self.async_step_pairing_complete_import()

return await self.async_step_pairing_complete()
return await self.async_step_tv_apps()

# If no data was retrieved, it's assumed that the pairing attempt was not
# successful
Expand All @@ -336,26 +394,43 @@ async def async_step_pair_tv(
errors=errors,
)

async def _pairing_complete(self, step_id: str) -> Dict[str, Any]:
"""Handle config flow completion."""
async def async_step_pairing_complete_import(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
"""Complete import config flow by displaying final message to show user access token and give further instructions."""
if not self._must_show_form:
return await self._create_entry_if_unique(self._data)

self._must_show_form = False
return self.async_show_form(
step_id=step_id,
step_id="pairing_complete_import",
data_schema=vol.Schema({}),
description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]},
)

async def async_step_pairing_complete(
async def async_step_tv_apps(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
"""Complete non-import config flow by displaying final message to confirm pairing."""
return await self._pairing_complete("pairing_complete")
"""Handle app configuration to complete TV configuration."""
if user_input is not None:
if user_input.get(CONF_APPS_TO_INCLUDE_OR_EXCLUDE):
# Update stored apps with user entry config keys
self._apps[user_input[CONF_INCLUDE_OR_EXCLUDE].lower()] = user_input[
CONF_APPS_TO_INCLUDE_OR_EXCLUDE
].copy()

async def async_step_pairing_complete_import(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
"""Complete import config flow by displaying final message to show user access token and give further instructions."""
return await self._pairing_complete("pairing_complete_import")
return await self._create_entry_if_unique(self._data)

return self.async_show_form(
step_id="tv_apps",
data_schema=vol.Schema(
{
vol.Optional(
CONF_INCLUDE_OR_EXCLUDE, default=CONF_INCLUDE.title(),
): vol.In([CONF_INCLUDE.title(), CONF_EXCLUDE.title()]),
vol.Optional(CONF_APPS_TO_INCLUDE_OR_EXCLUDE): cv.multi_select(
VizioAsync.get_apps_list()
),
}
),
)
37 changes: 37 additions & 0 deletions homeassistant/components/vizio/const.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Constants used by vizio component."""
from pyvizio import VizioAsync
from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
Expand All @@ -19,11 +20,21 @@
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST,
CONF_INCLUDE,
CONF_NAME,
)
import homeassistant.helpers.config_validation as cv

CONF_ADDITIONAL_CONFIGS = "additional_configs"
CONF_APP_ID = "APP_ID"
CONF_APPS = "apps"
CONF_APPS_TO_INCLUDE_OR_EXCLUDE = "apps_to_include_or_exclude"
CONF_CONFIG = "config"
CONF_INCLUDE_OR_EXCLUDE = "include_or_exclude"
CONF_NAME_SPACE = "NAME_SPACE"
CONF_MESSAGE = "MESSAGE"
CONF_VOLUME_STEP = "volume_step"

DEFAULT_DEVICE_CLASS = DEVICE_CLASS_TV
Expand Down Expand Up @@ -69,4 +80,30 @@
vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All(
vol.Coerce(int), vol.Range(min=1, max=10)
),
vol.Optional(CONF_APPS): vol.All(
{
vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All(
cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
),
vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All(
cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
),
vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All(
cv.ensure_list,
[
{
vol.Required(CONF_NAME): cv.string,
vol.Required(CONF_CONFIG): {
vol.Required(CONF_APP_ID): cv.string,
vol.Required(CONF_NAME_SPACE): vol.Coerce(int),
vol.Optional(CONF_MESSAGE, default=None): vol.Or(
cv.string, None
),
},
},
],
),
},
cv.has_at_least_one_key(CONF_INCLUDE, CONF_EXCLUDE, CONF_ADDITIONAL_CONFIGS),
),
}
2 changes: 1 addition & 1 deletion homeassistant/components/vizio/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"domain": "vizio",
"name": "Vizio SmartCast",
"documentation": "https://www.home-assistant.io/integrations/vizio",
"requirements": ["pyvizio==0.1.26"],
"requirements": ["pyvizio==0.1.35"],
"dependencies": [],
"codeowners": ["@raman325"],
"config_flow": true,
Expand Down
Loading

0 comments on commit a579fcf

Please sign in to comment.