Add app support for TVs to Vizio integration (#32432)

* 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
This commit is contained in:
Paulus Schoutsen 2020-03-05 13:34:12 -08:00 committed by GitHub
parent 873bf887a5
commit a579fcf248
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 729 additions and 73 deletions

View File

@ -3,14 +3,29 @@ import asyncio
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType 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( 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, extra=vol.ALLOW_EXTRA,
) )

View File

@ -12,16 +12,22 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigE
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST, CONF_HOST,
CONF_INCLUDE,
CONF_NAME, CONF_NAME,
CONF_PIN, CONF_PIN,
CONF_PORT, CONF_PORT,
CONF_TYPE, CONF_TYPE,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import ( from .const import (
CONF_APPS,
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
CONF_INCLUDE_OR_EXCLUDE,
CONF_VOLUME_STEP, CONF_VOLUME_STEP,
DEFAULT_DEVICE_CLASS, DEFAULT_DEVICE_CLASS,
DEFAULT_NAME, DEFAULT_NAME,
@ -34,7 +40,11 @@ _LOGGER = logging.getLogger(__name__)
def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: 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: if input_dict is None:
input_dict = {} input_dict = {}
@ -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: 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: if input_dict is None:
input_dict = {} input_dict = {}
return vol.Schema( return vol.Schema(
{vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}, {vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}
extra=vol.ALLOW_EXTRA,
) )
@ -73,7 +86,7 @@ def _host_is_same(host1: str, host2: str) -> bool:
class VizioOptionsConfigFlow(config_entries.OptionsFlow): class VizioOptionsConfigFlow(config_entries.OptionsFlow):
"""Handle Transmission client options.""" """Handle Vizio options."""
def __init__(self, config_entry: ConfigEntry) -> None: def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize vizio options flow.""" """Initialize vizio options flow."""
@ -117,22 +130,18 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._ch_type = None self._ch_type = None
self._pairing_token = None self._pairing_token = None
self._data = None self._data = None
self._apps = {}
async def _create_entry_if_unique( async def _create_entry_if_unique(
self, input_dict: Dict[str, Any] self, input_dict: Dict[str, Any]
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Check if unique_id doesn't already exist. If it does, abort. If it doesn't, create entry.""" """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( # Remove extra keys that will not be used by entry setup
input_dict[CONF_HOST], input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None)
input_dict.get(CONF_ACCESS_TOKEN), input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None)
input_dict[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 if self._apps:
# with the unique ID is already in progress input_dict[CONF_APPS] = self._apps
await self.async_set_unique_id(unique_id=unique_id, raise_on_progress=True)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict) return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict)
@ -172,6 +181,27 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "cant_connect" errors["base"] = "cant_connect"
if not errors: 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) return await self._create_entry_if_unique(user_input)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
elif self._must_show_form and self.context["source"] == SOURCE_IMPORT: elif self._must_show_form and self.context["source"] == SOURCE_IMPORT:
@ -180,11 +210,9 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
# their configuration.yaml or to proceed with config flow pairing. We # their configuration.yaml or to proceed with config flow pairing. We
# will also provide contextual message to user explaining why # will also provide contextual message to user explaining why
_LOGGER.warning( _LOGGER.warning(
"Couldn't complete configuration.yaml import: '%s' key is missing. To " "Couldn't complete configuration.yaml import: '%s' key is "
"complete setup, '%s' can be obtained by going through pairing process " "missing. Either provide '%s' key in configuration.yaml or "
"via frontend Integrations menu; to avoid re-pairing your device in the " "finish setup by completing configuration via frontend.",
"future, once you have finished pairing, it is recommended to add "
"obtained value to your config ",
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
) )
@ -210,20 +238,32 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
for entry in self.hass.config_entries.async_entries(DOMAIN): for entry in self.hass.config_entries.async_entries(DOMAIN):
if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]): if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]):
updated_options = {} updated_options = {}
updated_name = {} updated_data = {}
remove_apps = False
if entry.data[CONF_NAME] != import_config[CONF_NAME]: 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]: if entry.data.get(CONF_VOLUME_STEP) != import_config[CONF_VOLUME_STEP]:
updated_options[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_data = entry.data.copy()
new_options = entry.options.copy() new_options = entry.options.copy()
if updated_name: if remove_apps:
new_data.update(updated_name) new_data.pop(CONF_APPS)
if updated_data:
new_data.update(updated_data)
if updated_options: if updated_options:
new_data.update(updated_options) new_data.update(updated_options)
@ -237,6 +277,10 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_setup") return self.async_abort(reason="already_setup")
self._must_show_form = True 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) return await self.async_step_user(user_input=import_config)
async def async_step_zeroconf( async def async_step_zeroconf(
@ -319,12 +363,26 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token
self._must_show_form = True 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 # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
if self.context["source"] == SOURCE_IMPORT: if self.context["source"] == SOURCE_IMPORT:
# If user is pairing via config import, show different message # 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_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 # If no data was retrieved, it's assumed that the pairing attempt was not
# successful # successful
@ -336,26 +394,43 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors=errors, errors=errors,
) )
async def _pairing_complete(self, step_id: str) -> Dict[str, Any]: async def async_step_pairing_complete_import(
"""Handle config flow completion.""" 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: if not self._must_show_form:
return await self._create_entry_if_unique(self._data) return await self._create_entry_if_unique(self._data)
self._must_show_form = False self._must_show_form = False
return self.async_show_form( return self.async_show_form(
step_id=step_id, step_id="pairing_complete_import",
data_schema=vol.Schema({}), data_schema=vol.Schema({}),
description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]}, 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 self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Complete non-import config flow by displaying final message to confirm pairing.""" """Handle app configuration to complete TV configuration."""
return await self._pairing_complete("pairing_complete") 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( return await self._create_entry_if_unique(self._data)
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]: return self.async_show_form(
"""Complete import config flow by displaying final message to show user access token and give further instructions.""" step_id="tv_apps",
return await self._pairing_complete("pairing_complete_import") 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()
),
}
),
)

View File

@ -1,4 +1,5 @@
"""Constants used by vizio component.""" """Constants used by vizio component."""
from pyvizio import VizioAsync
from pyvizio.const import ( from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
@ -19,11 +20,21 @@ from homeassistant.components.media_player.const import (
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST, CONF_HOST,
CONF_INCLUDE,
CONF_NAME, CONF_NAME,
) )
import homeassistant.helpers.config_validation as cv 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" CONF_VOLUME_STEP = "volume_step"
DEFAULT_DEVICE_CLASS = DEVICE_CLASS_TV DEFAULT_DEVICE_CLASS = DEVICE_CLASS_TV
@ -69,4 +80,30 @@ VIZIO_SCHEMA = {
vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All( vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All(
vol.Coerce(int), vol.Range(min=1, max=10) 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),
),
} }

View File

@ -2,7 +2,7 @@
"domain": "vizio", "domain": "vizio",
"name": "Vizio SmartCast", "name": "Vizio SmartCast",
"documentation": "https://www.home-assistant.io/integrations/vizio", "documentation": "https://www.home-assistant.io/integrations/vizio",
"requirements": ["pyvizio==0.1.26"], "requirements": ["pyvizio==0.1.35"],
"dependencies": [], "dependencies": [],
"codeowners": ["@raman325"], "codeowners": ["@raman325"],
"config_flow": true, "config_flow": true,

View File

@ -1,16 +1,23 @@
"""Vizio SmartCast Device support.""" """Vizio SmartCast Device support."""
from datetime import timedelta from datetime import timedelta
import logging import logging
from typing import Callable, List from typing import Any, Callable, Dict, List, Optional
from pyvizio import VizioAsync from pyvizio import VizioAsync
from pyvizio.const import INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
from pyvizio.helpers import find_app_name
from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player import (
DEVICE_CLASS_SPEAKER,
MediaPlayerDevice,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST, CONF_HOST,
CONF_INCLUDE,
CONF_NAME, CONF_NAME,
STATE_OFF, STATE_OFF,
STATE_ON, STATE_ON,
@ -25,6 +32,8 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from .const import ( from .const import (
CONF_ADDITIONAL_CONFIGS,
CONF_APPS,
CONF_VOLUME_STEP, CONF_VOLUME_STEP,
DEFAULT_TIMEOUT, DEFAULT_TIMEOUT,
DEFAULT_VOLUME_STEP, DEFAULT_VOLUME_STEP,
@ -51,6 +60,7 @@ async def async_setup_entry(
token = config_entry.data.get(CONF_ACCESS_TOKEN) token = config_entry.data.get(CONF_ACCESS_TOKEN)
name = config_entry.data[CONF_NAME] name = config_entry.data[CONF_NAME]
device_class = config_entry.data[CONF_DEVICE_CLASS] device_class = config_entry.data[CONF_DEVICE_CLASS]
conf_apps = config_entry.data.get(CONF_APPS, {})
# If config entry options not set up, set them up, otherwise assign values managed in options # If config entry options not set up, set them up, otherwise assign values managed in options
volume_step = config_entry.options.get( volume_step = config_entry.options.get(
@ -83,7 +93,9 @@ async def async_setup_entry(
_LOGGER.warning("Failed to connect to %s", host) _LOGGER.warning("Failed to connect to %s", host)
raise PlatformNotReady raise PlatformNotReady
entity = VizioDevice(config_entry, device, name, volume_step, device_class) entity = VizioDevice(
config_entry, device, name, volume_step, device_class, conf_apps,
)
async_add_entities([entity], update_before_add=True) async_add_entities([entity], update_before_add=True)
@ -98,6 +110,7 @@ class VizioDevice(MediaPlayerDevice):
name: str, name: str,
volume_step: int, volume_step: int,
device_class: str, device_class: str,
conf_apps: Dict[str, List[Any]],
) -> None: ) -> None:
"""Initialize Vizio device.""" """Initialize Vizio device."""
self._config_entry = config_entry self._config_entry = config_entry
@ -109,7 +122,11 @@ class VizioDevice(MediaPlayerDevice):
self._volume_step = volume_step self._volume_step = volume_step
self._is_muted = None self._is_muted = None
self._current_input = None self._current_input = None
self._available_inputs = None self._current_app = None
self._available_inputs = []
self._available_apps = []
self._conf_apps = conf_apps
self._additional_app_configs = self._conf_apps.get(CONF_ADDITIONAL_CONFIGS, [])
self._device_class = device_class self._device_class = device_class
self._supported_commands = SUPPORTED_COMMANDS[device_class] self._supported_commands = SUPPORTED_COMMANDS[device_class]
self._device = device self._device = device
@ -119,6 +136,30 @@ class VizioDevice(MediaPlayerDevice):
self._model = None self._model = None
self._sw_version = None self._sw_version = None
def _apps_list(self, apps: List[str]) -> List[str]:
"""Return process apps list based on configured filters."""
if self._conf_apps.get(CONF_INCLUDE):
return [app for app in apps if app in self._conf_apps[CONF_INCLUDE]]
if self._conf_apps.get(CONF_EXCLUDE):
return [app for app in apps if app not in self._conf_apps[CONF_EXCLUDE]]
return apps
async def _current_app_name(self) -> Optional[str]:
"""Return name of the currently running app by parsing pyvizio output."""
app = await self._device.get_current_app(log_api_exception=False)
if app in [None, NO_APP_RUNNING]:
return None
if app == UNKNOWN_APP and self._additional_app_configs:
return find_app_name(
await self._device.get_current_app_config(log_api_exception=False),
self._additional_app_configs,
)
return app
async def async_update(self) -> None: async def async_update(self) -> None:
"""Retrieve latest state of the device.""" """Retrieve latest state of the device."""
if not self._model: if not self._model:
@ -149,6 +190,7 @@ class VizioDevice(MediaPlayerDevice):
self._is_muted = None self._is_muted = None
self._current_input = None self._current_input = None
self._available_inputs = None self._available_inputs = None
self._available_apps = None
return return
self._state = STATE_ON self._state = STATE_ON
@ -165,8 +207,33 @@ class VizioDevice(MediaPlayerDevice):
self._current_input = input_ self._current_input = input_
inputs = await self._device.get_inputs_list(log_api_exception=False) inputs = await self._device.get_inputs_list(log_api_exception=False)
if inputs is not None:
self._available_inputs = [input_.name for input_ in inputs] # If no inputs returned, end update
if not inputs:
return
self._available_inputs = [input_.name for input_ in inputs]
# Return before setting app variables if INPUT_APPS isn't in available inputs
if self._device_class == DEVICE_CLASS_SPEAKER or not any(
app for app in INPUT_APPS if app in self._available_inputs
):
return
# Create list of available known apps from known app list after
# filtering by CONF_INCLUDE/CONF_EXCLUDE
if not self._available_apps:
self._available_apps = self._apps_list(self._device.get_apps_list())
# Attempt to get current app name. If app name is unknown, check list
# of additional apps specified in configuration
self._current_app = await self._current_app_name()
def _get_additional_app_names(self) -> List[Dict[str, Any]]:
"""Return list of additional apps that were included in configuration.yaml."""
return [
additional_app["name"] for additional_app in self._additional_app_configs
]
@staticmethod @staticmethod
async def _async_send_update_options_signal( async def _async_send_update_options_signal(
@ -237,13 +304,39 @@ class VizioDevice(MediaPlayerDevice):
@property @property
def source(self) -> str: def source(self) -> str:
"""Return current input of the device.""" """Return current input of the device."""
if self._current_input in INPUT_APPS:
return self._current_app
return self._current_input return self._current_input
@property @property
def source_list(self) -> List: def source_list(self) -> List[str]:
"""Return list of available inputs of the device.""" """Return list of available inputs of the device."""
# If Smartcast app is in input list, and the app list has been retrieved,
# show the combination with , otherwise just return inputs
if self._available_apps:
return [
*[
_input
for _input in self._available_inputs
if _input not in INPUT_APPS
],
*self._available_apps,
*self._get_additional_app_names(),
]
return self._available_inputs return self._available_inputs
@property
def app_id(self) -> Optional[str]:
"""Return the current app."""
return self._current_app
@property
def app_name(self) -> Optional[str]:
"""Return the friendly name of the current app."""
return self._current_app
@property @property
def supported_features(self) -> int: def supported_features(self) -> int:
"""Flag device features that are supported.""" """Flag device features that are supported."""
@ -297,7 +390,18 @@ class VizioDevice(MediaPlayerDevice):
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Select input source.""" """Select input source."""
await self._device.set_input(source) if source in self._available_inputs:
await self._device.set_input(source)
elif source in self._get_additional_app_names():
await self._device.launch_app_config(
**next(
app["config"]
for app in self._additional_app_configs
if app["name"] == source
)
)
elif source in self._available_apps:
await self._device.launch_app(source)
async def async_volume_up(self) -> None: async def async_volume_up(self) -> None:
"""Increase volume of the device.""" """Increase volume of the device."""

View File

@ -4,7 +4,7 @@
"step": { "step": {
"user": { "user": {
"title": "Setup Vizio SmartCast Device", "title": "Setup Vizio SmartCast Device",
"description": "All fields are required except Access Token. If you choose not to provide an Access Token, and your Device Type is 'tv', you will go through a pairing process with your device so an Access Token can be retrieved.\n\nTo go through the pairing process, before clicking Submit, ensure your TV is powered on and connected to the network. You also need to be able to see the screen.", "description": "An Access Token is only needed for TVs. If you are configuring a TV and do not have an Access Token yet, leave it blank to go through a pairing process.",
"data": { "data": {
"name": "Name", "name": "Name",
"host": "<Host/IP>:<Port>", "host": "<Host/IP>:<Port>",
@ -19,13 +19,17 @@
"pin": "PIN" "pin": "PIN"
} }
}, },
"pairing_complete": {
"title": "Pairing Complete",
"description": "Your Vizio SmartCast device is now connected to Home Assistant."
},
"pairing_complete_import": { "pairing_complete_import": {
"title": "Pairing Complete", "title": "Pairing Complete",
"description": "Your Vizio SmartCast device is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'." "description": "Your Vizio SmartCast TV is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'."
},
"user_tv": {
"title": "Configure Apps for Smart TV",
"description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list. You can skip this step for TVs that don't support apps.",
"data": {
"include_or_exclude": "Include or Exclude Apps?",
"apps_to_include_or_exclude": "Apps to Include or Exclude"
}
} }
}, },
"error": { "error": {
@ -36,7 +40,7 @@
}, },
"abort": { "abort": {
"already_setup": "This entry has already been setup.", "already_setup": "This entry has already been setup.",
"updated_entry": "This entry has already been setup but the name and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly." "updated_entry": "This entry has already been setup but the name, apps, and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly."
} }
}, },
"options": { "options": {

View File

@ -1717,7 +1717,7 @@ pyversasense==0.0.6
pyvesync==1.1.0 pyvesync==1.1.0
# homeassistant.components.vizio # homeassistant.components.vizio
pyvizio==0.1.26 pyvizio==0.1.35
# homeassistant.components.velux # homeassistant.components.velux
pyvlx==0.2.12 pyvlx==0.2.12

View File

@ -602,7 +602,7 @@ pyvera==0.3.7
pyvesync==1.1.0 pyvesync==1.1.0
# homeassistant.components.vizio # homeassistant.components.vizio
pyvizio==0.1.26 pyvizio==0.1.35
# homeassistant.components.html5 # homeassistant.components.html5
pywebpush==1.9.2 pywebpush==1.9.2

View File

@ -5,9 +5,12 @@ from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME
from .const import ( from .const import (
ACCESS_TOKEN, ACCESS_TOKEN,
APP_LIST,
CH_TYPE, CH_TYPE,
CURRENT_APP,
CURRENT_INPUT, CURRENT_INPUT,
INPUT_LIST, INPUT_LIST,
INPUT_LIST_WITH_APPS,
MODEL, MODEL,
RESPONSE_TOKEN, RESPONSE_TOKEN,
UNIQUE_ID, UNIQUE_ID,
@ -154,3 +157,22 @@ def vizio_update_fixture():
return_value=VERSION, return_value=VERSION,
): ):
yield yield
@pytest.fixture(name="vizio_update_with_apps")
def vizio_update_with_apps_fixture(vizio_update: pytest.fixture):
"""Mock valid updates to vizio device that supports apps."""
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
return_value=get_mock_inputs(INPUT_LIST_WITH_APPS),
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_apps_list",
return_value=APP_LIST,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
return_value="CAST",
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app",
return_value=CURRENT_APP,
):
yield

View File

@ -6,11 +6,23 @@ from homeassistant.components.media_player import (
DEVICE_CLASS_TV, DEVICE_CLASS_TV,
DOMAIN as MP_DOMAIN, DOMAIN as MP_DOMAIN,
) )
from homeassistant.components.vizio.const import CONF_VOLUME_STEP from homeassistant.components.vizio.const import (
CONF_ADDITIONAL_CONFIGS,
CONF_APP_ID,
CONF_APPS,
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
CONF_CONFIG,
CONF_INCLUDE_OR_EXCLUDE,
CONF_MESSAGE,
CONF_NAME_SPACE,
CONF_VOLUME_STEP,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_EXCLUDE,
CONF_HOST, CONF_HOST,
CONF_INCLUDE,
CONF_NAME, CONF_NAME,
CONF_PIN, CONF_PIN,
CONF_PORT, CONF_PORT,
@ -52,6 +64,22 @@ class MockCompletePairingResponse(object):
self.auth_token = auth_token self.auth_token = auth_token
CURRENT_INPUT = "HDMI"
INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
CURRENT_APP = "Hulu"
APP_LIST = ["Hulu", "Netflix"]
INPUT_LIST_WITH_APPS = INPUT_LIST + ["CAST"]
CUSTOM_APP_NAME = "APP3"
CUSTOM_CONFIG = {CONF_APP_ID: "test", CONF_MESSAGE: None, CONF_NAME_SPACE: 10}
ADDITIONAL_APP_CONFIG = {
"name": CUSTOM_APP_NAME,
CONF_CONFIG: CUSTOM_CONFIG,
}
ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}"
MOCK_PIN_CONFIG = {CONF_PIN: PIN} MOCK_PIN_CONFIG = {CONF_PIN: PIN}
MOCK_USER_VALID_TV_CONFIG = { MOCK_USER_VALID_TV_CONFIG = {
@ -73,6 +101,58 @@ MOCK_IMPORT_VALID_TV_CONFIG = {
CONF_VOLUME_STEP: VOLUME_STEP, CONF_VOLUME_STEP: VOLUME_STEP,
} }
MOCK_TV_WITH_INCLUDE_CONFIG = {
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
CONF_ACCESS_TOKEN: ACCESS_TOKEN,
CONF_VOLUME_STEP: VOLUME_STEP,
CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]},
}
MOCK_TV_WITH_EXCLUDE_CONFIG = {
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
CONF_ACCESS_TOKEN: ACCESS_TOKEN,
CONF_VOLUME_STEP: VOLUME_STEP,
CONF_APPS: {CONF_EXCLUDE: ["Netflix"]},
}
MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG = {
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
CONF_ACCESS_TOKEN: ACCESS_TOKEN,
CONF_VOLUME_STEP: VOLUME_STEP,
CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]},
}
MOCK_SPEAKER_APPS_FAILURE = {
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
CONF_ACCESS_TOKEN: ACCESS_TOKEN,
CONF_VOLUME_STEP: VOLUME_STEP,
CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]},
}
MOCK_TV_APPS_FAILURE = {
CONF_NAME: NAME,
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
CONF_ACCESS_TOKEN: ACCESS_TOKEN,
CONF_VOLUME_STEP: VOLUME_STEP,
CONF_APPS: None,
}
MOCK_TV_APPS_WITH_VALID_APPS_CONFIG = {
CONF_HOST: HOST,
CONF_DEVICE_CLASS: DEVICE_CLASS_TV,
CONF_ACCESS_TOKEN: ACCESS_TOKEN,
CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]},
}
MOCK_TV_CONFIG_NO_TOKEN = { MOCK_TV_CONFIG_NO_TOKEN = {
CONF_NAME: NAME, CONF_NAME: NAME,
CONF_HOST: HOST, CONF_HOST: HOST,
@ -85,6 +165,15 @@ MOCK_SPEAKER_CONFIG = {
CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER, CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER,
} }
MOCK_INCLUDE_APPS = {
CONF_INCLUDE_OR_EXCLUDE: CONF_INCLUDE.title(),
CONF_APPS_TO_INCLUDE_OR_EXCLUDE: [CURRENT_APP],
}
MOCK_INCLUDE_NO_APPS = {
CONF_INCLUDE_OR_EXCLUDE: CONF_INCLUDE.title(),
CONF_APPS_TO_INCLUDE_OR_EXCLUDE: [],
}
VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local." VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local."
ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}"
ZEROCONF_HOST = HOST.split(":")[0] ZEROCONF_HOST = HOST.split(":")[0]
@ -97,8 +186,3 @@ MOCK_ZEROCONF_SERVICE_INFO = {
CONF_PORT: ZEROCONF_PORT, CONF_PORT: ZEROCONF_PORT,
"properties": {"name": "SB4031-D5"}, "properties": {"name": "SB4031-D5"},
} }
CURRENT_INPUT = "HDMI"
INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}"

View File

@ -6,7 +6,12 @@ import voluptuous as vol
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV
from homeassistant.components.vizio.config_flow import _get_config_schema
from homeassistant.components.vizio.const import ( from homeassistant.components.vizio.const import (
CONF_APPS,
CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
CONF_INCLUDE,
CONF_INCLUDE_OR_EXCLUDE,
CONF_VOLUME_STEP, CONF_VOLUME_STEP,
DEFAULT_NAME, DEFAULT_NAME,
DEFAULT_VOLUME_STEP, DEFAULT_VOLUME_STEP,
@ -25,12 +30,16 @@ from homeassistant.helpers.typing import HomeAssistantType
from .const import ( from .const import (
ACCESS_TOKEN, ACCESS_TOKEN,
CURRENT_APP,
HOST, HOST,
HOST2, HOST2,
MOCK_IMPORT_VALID_TV_CONFIG, MOCK_IMPORT_VALID_TV_CONFIG,
MOCK_INCLUDE_APPS,
MOCK_INCLUDE_NO_APPS,
MOCK_PIN_CONFIG, MOCK_PIN_CONFIG,
MOCK_SPEAKER_CONFIG, MOCK_SPEAKER_CONFIG,
MOCK_TV_CONFIG_NO_TOKEN, MOCK_TV_CONFIG_NO_TOKEN,
MOCK_TV_WITH_EXCLUDE_CONFIG,
MOCK_USER_VALID_TV_CONFIG, MOCK_USER_VALID_TV_CONFIG,
MOCK_ZEROCONF_SERVICE_INFO, MOCK_ZEROCONF_SERVICE_INFO,
NAME, NAME,
@ -86,12 +95,48 @@ async def test_user_flow_all_fields(
result["flow_id"], user_input=MOCK_USER_VALID_TV_CONFIG result["flow_id"], user_input=MOCK_USER_VALID_TV_CONFIG
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "tv_apps"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_INCLUDE_APPS
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME assert result["title"] == NAME
assert result["data"][CONF_NAME] == NAME assert result["data"][CONF_NAME] == NAME
assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN
assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP]
async def test_user_apps_with_tv(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
) -> None:
"""Test TV can have selected apps during user setup."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_IMPORT_VALID_TV_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "tv_apps"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_INCLUDE_APPS
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"][CONF_NAME] == NAME
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN
assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP]
assert CONF_APPS_TO_INCLUDE_OR_EXCLUDE not in result["data"]
assert CONF_INCLUDE_OR_EXCLUDE not in result["data"]
async def test_options_flow(hass: HomeAssistantType) -> None: async def test_options_flow(hass: HomeAssistantType) -> None:
@ -218,13 +263,13 @@ async def test_user_error_on_could_not_connect(
assert result["errors"] == {"base": "cant_connect"} assert result["errors"] == {"base": "cant_connect"}
async def test_user_tv_pairing( async def test_user_tv_pairing_no_apps(
hass: HomeAssistantType, hass: HomeAssistantType,
vizio_connect: pytest.fixture, vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture, vizio_bypass_setup: pytest.fixture,
vizio_complete_pairing: pytest.fixture, vizio_complete_pairing: pytest.fixture,
) -> None: ) -> None:
"""Test pairing config flow when access token not provided for tv during user entry.""" """Test pairing config flow when access token not provided for tv during user entry and no apps configured."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
) )
@ -237,15 +282,18 @@ async def test_user_tv_pairing(
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pairing_complete" assert result["step_id"] == "tv_apps"
result = await hass.config_entries.flow.async_configure(result["flow_id"]) result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_INCLUDE_NO_APPS
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME assert result["title"] == NAME
assert result["data"][CONF_NAME] == NAME assert result["data"][CONF_NAME] == NAME
assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
assert CONF_APPS not in result["data"]
async def test_user_start_pairing_failure( async def test_user_start_pairing_failure(
@ -385,12 +433,12 @@ async def test_import_flow_update_options(
) )
async def test_import_flow_update_name( async def test_import_flow_update_name_and_apps(
hass: HomeAssistantType, hass: HomeAssistantType,
vizio_connect: pytest.fixture, vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture, vizio_bypass_update: pytest.fixture,
) -> None: ) -> None:
"""Test import config flow with updated name.""" """Test import config flow with updated name and apps."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, context={"source": SOURCE_IMPORT},
@ -404,6 +452,7 @@ async def test_import_flow_update_name(
updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy() updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy()
updated_config[CONF_NAME] = NAME2 updated_config[CONF_NAME] = NAME2
updated_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]}
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_IMPORT}, context={"source": SOURCE_IMPORT},
@ -413,6 +462,39 @@ async def test_import_flow_update_name(
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "updated_entry" assert result["reason"] == "updated_entry"
assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2 assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2
assert hass.config_entries.async_get_entry(entry_id).data[CONF_APPS] == {
CONF_INCLUDE: [CURRENT_APP]
}
async def test_import_flow_update_remove_apps(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_update: pytest.fixture,
) -> None:
"""Test import config flow with removed apps."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_EXCLUDE_CONFIG),
)
await hass.async_block_till_done()
assert result["result"].data[CONF_NAME] == NAME
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry_id = result["result"].entry_id
updated_config = MOCK_TV_WITH_EXCLUDE_CONFIG.copy()
updated_config.pop(CONF_APPS)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=vol.Schema(VIZIO_SCHEMA)(updated_config),
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "updated_entry"
assert hass.config_entries.async_get_entry(entry_id).data.get(CONF_APPS) is None
async def test_import_needs_pairing( async def test_import_needs_pairing(
@ -452,6 +534,49 @@ async def test_import_needs_pairing(
assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
async def test_import_with_apps_needs_pairing(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
vizio_complete_pairing: pytest.fixture,
) -> None:
"""Test pairing config flow when access token not provided for tv but apps are included during import."""
import_config = MOCK_TV_CONFIG_NO_TOKEN.copy()
import_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=import_config
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
# Mock inputting info without apps to make sure apps get stored
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=_get_config_schema(MOCK_TV_CONFIG_NO_TOKEN)(MOCK_TV_CONFIG_NO_TOKEN),
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pair_tv"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MOCK_PIN_CONFIG
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "pairing_complete_import"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
assert result["data"][CONF_NAME] == NAME
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP]
async def test_import_error( async def test_import_error(
hass: HomeAssistantType, hass: HomeAssistantType,
vizio_connect: pytest.fixture, vizio_connect: pytest.fixture,

View File

@ -1,14 +1,21 @@
"""Tests for Vizio config flow.""" """Tests for Vizio config flow."""
from datetime import timedelta from datetime import timedelta
import logging
from typing import Any, Dict
from unittest.mock import call from unittest.mock import call
from asynctest import patch from asynctest import patch
import pytest import pytest
from pytest import raises
from pyvizio._api.apps import AppConfig
from pyvizio.const import ( from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
INPUT_APPS,
MAX_VOLUME, MAX_VOLUME,
UNKNOWN_APP,
) )
import voluptuous as vol
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE,
@ -27,16 +34,41 @@ from homeassistant.components.media_player import (
SERVICE_VOLUME_SET, SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP, SERVICE_VOLUME_UP,
) )
from homeassistant.components.vizio.const import CONF_VOLUME_STEP, DOMAIN from homeassistant.components.vizio import validate_apps
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.components.vizio.const import (
CONF_ADDITIONAL_CONFIGS,
CONF_APPS,
CONF_VOLUME_STEP,
DOMAIN,
VIZIO_SCHEMA,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_EXCLUDE,
CONF_INCLUDE,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import ( from .const import (
ADDITIONAL_APP_CONFIG,
APP_LIST,
CURRENT_APP,
CURRENT_INPUT, CURRENT_INPUT,
CUSTOM_APP_NAME,
CUSTOM_CONFIG,
ENTITY_ID, ENTITY_ID,
INPUT_LIST, INPUT_LIST,
INPUT_LIST_WITH_APPS,
MOCK_SPEAKER_APPS_FAILURE,
MOCK_SPEAKER_CONFIG, MOCK_SPEAKER_CONFIG,
MOCK_TV_APPS_FAILURE,
MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG,
MOCK_TV_WITH_EXCLUDE_CONFIG,
MOCK_TV_WITH_INCLUDE_CONFIG,
MOCK_USER_VALID_TV_CONFIG, MOCK_USER_VALID_TV_CONFIG,
NAME, NAME,
UNIQUE_ID, UNIQUE_ID,
@ -45,6 +77,8 @@ from .const import (
from tests.common import MockConfigEntry, async_fire_time_changed from tests.common import MockConfigEntry, async_fire_time_changed
_LOGGER = logging.getLogger(__name__)
async def _test_setup( async def _test_setup(
hass: HomeAssistantType, ha_device_class: str, vizio_power_state: bool hass: HomeAssistantType, ha_device_class: str, vizio_power_state: bool
@ -60,12 +94,16 @@ async def _test_setup(
if ha_device_class == DEVICE_CLASS_SPEAKER: if ha_device_class == DEVICE_CLASS_SPEAKER:
vizio_device_class = VIZIO_DEVICE_CLASS_SPEAKER vizio_device_class = VIZIO_DEVICE_CLASS_SPEAKER
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID domain=DOMAIN,
data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG),
unique_id=UNIQUE_ID,
) )
else: else:
vizio_device_class = VIZIO_DEVICE_CLASS_TV vizio_device_class = VIZIO_DEVICE_CLASS_TV
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID domain=DOMAIN,
data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG),
unique_id=UNIQUE_ID,
) )
with patch( with patch(
@ -94,6 +132,72 @@ async def _test_setup(
) )
async def _test_setup_with_apps(
hass: HomeAssistantType, device_config: Dict[str, Any], app: str
) -> None:
"""Test Vizio Device with apps entity setup."""
config_entry = MockConfigEntry(
domain=DOMAIN, data=vol.Schema(VIZIO_SCHEMA)(device_config), unique_id=UNIQUE_ID
)
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings",
return_value={
"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2),
"mute": "Off",
},
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_power_state",
return_value=True,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app",
return_value=app,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
return_value=AppConfig(**ADDITIONAL_APP_CONFIG["config"]),
):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
attr = hass.states.get(ENTITY_ID).attributes
assert attr["friendly_name"] == NAME
assert attr["device_class"] == DEVICE_CLASS_TV
assert hass.states.get(ENTITY_ID).state == STATE_ON
if device_config.get(CONF_APPS, {}).get(CONF_INCLUDE) or device_config.get(
CONF_APPS, {}
).get(CONF_EXCLUDE):
list_to_test = list(INPUT_LIST_WITH_APPS + [CURRENT_APP])
elif device_config.get(CONF_APPS, {}).get(CONF_ADDITIONAL_CONFIGS):
list_to_test = list(
INPUT_LIST_WITH_APPS
+ APP_LIST
+ [
app["name"]
for app in device_config[CONF_APPS][CONF_ADDITIONAL_CONFIGS]
]
)
else:
list_to_test = list(INPUT_LIST_WITH_APPS + APP_LIST)
for app_to_remove in INPUT_APPS:
if app_to_remove in list_to_test:
list_to_test.remove(app_to_remove)
assert attr["source_list"] == list_to_test
assert app in attr["source_list"] or app == UNKNOWN_APP
if app == UNKNOWN_APP:
assert attr["source"] == ADDITIONAL_APP_CONFIG["name"]
else:
assert attr["source"] == app
assert (
attr["volume_level"]
== float(int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2))
/ MAX_VOLUME[VIZIO_DEVICE_CLASS_TV]
)
async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None: async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None:
"""Test generic Vizio entity setup failure.""" """Test generic Vizio entity setup failure."""
with patch( with patch(
@ -311,3 +415,89 @@ async def test_update_available_to_unavailable(
) -> None: ) -> None:
"""Test device becomes unavailable after being available.""" """Test device becomes unavailable after being available."""
await _test_update_availability_switch(hass, True, None, caplog) await _test_update_availability_switch(hass, True, None, caplog)
async def test_setup_with_apps(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_update_with_apps: pytest.fixture,
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps."""
await _test_setup_with_apps(hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP)
await _test_service(
hass,
"launch_app",
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: CURRENT_APP},
CURRENT_APP,
)
async def test_setup_with_apps_include(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_update_with_apps: pytest.fixture,
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps and apps["include"] in config."""
await _test_setup_with_apps(hass, MOCK_TV_WITH_INCLUDE_CONFIG, CURRENT_APP)
async def test_setup_with_apps_exclude(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_update_with_apps: pytest.fixture,
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps and apps["exclude"] in config."""
await _test_setup_with_apps(hass, MOCK_TV_WITH_EXCLUDE_CONFIG, CURRENT_APP)
async def test_setup_with_apps_additional_apps_config(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_update_with_apps: pytest.fixture,
caplog: pytest.fixture,
) -> None:
"""Test device setup with apps and apps["additional_configs"] in config."""
await _test_setup_with_apps(hass, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, UNKNOWN_APP)
await _test_service(
hass,
"launch_app",
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: CURRENT_APP},
CURRENT_APP,
)
await _test_service(
hass,
"launch_app_config",
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: CUSTOM_APP_NAME},
**CUSTOM_CONFIG,
)
# Test that invalid app does nothing
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.launch_app"
) as service_call1, patch(
"homeassistant.components.vizio.media_player.VizioAsync.launch_app_config"
) as service_call2:
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "_"},
blocking=True,
)
assert not service_call1.called
assert not service_call2.called
def test_invalid_apps_config(hass: HomeAssistantType):
"""Test that schema validation fails on certain conditions."""
with raises(vol.Invalid):
vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_TV_APPS_FAILURE)
with raises(vol.Invalid):
vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_SPEAKER_APPS_FAILURE)