From a579fcf2482da094e3261f07c40328f948b06c03 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 13:34:12 -0800 Subject: [PATCH] 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 --- homeassistant/components/vizio/__init__.py | 19 +- homeassistant/components/vizio/config_flow.py | 149 +++++++++---- homeassistant/components/vizio/const.py | 37 ++++ homeassistant/components/vizio/manifest.json | 2 +- .../components/vizio/media_player.py | 120 ++++++++++- homeassistant/components/vizio/strings.json | 18 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vizio/conftest.py | 22 ++ tests/components/vizio/const.py | 96 ++++++++- tests/components/vizio/test_config_flow.py | 137 +++++++++++- tests/components/vizio/test_media_player.py | 198 +++++++++++++++++- 12 files changed, 729 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 88d600abce6b21..a52b395c5c9ad0 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -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, ) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 993bb09b14fd12..1eb58c966701ba 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -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, @@ -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 = {} @@ -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} ) @@ -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.""" @@ -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) @@ -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: @@ -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, ) @@ -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) @@ -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( @@ -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 @@ -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() + ), + } + ), + ) diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index e3ac66e05c3228..795f12266fb440 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -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, @@ -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 @@ -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), + ), } diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 08d442b803eb25..f1931f6fdb174a 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -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, diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index edbe4171f0a391..639187374112f8 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -1,16 +1,23 @@ """Vizio SmartCast Device support.""" from datetime import timedelta import logging -from typing import Callable, List +from typing import Any, Callable, Dict, List, Optional 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.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_NAME, STATE_OFF, STATE_ON, @@ -25,6 +32,8 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import ( + CONF_ADDITIONAL_CONFIGS, + CONF_APPS, CONF_VOLUME_STEP, DEFAULT_TIMEOUT, DEFAULT_VOLUME_STEP, @@ -51,6 +60,7 @@ async def async_setup_entry( token = config_entry.data.get(CONF_ACCESS_TOKEN) name = config_entry.data[CONF_NAME] 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 volume_step = config_entry.options.get( @@ -83,7 +93,9 @@ async def async_setup_entry( _LOGGER.warning("Failed to connect to %s", host) 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) @@ -98,6 +110,7 @@ def __init__( name: str, volume_step: int, device_class: str, + conf_apps: Dict[str, List[Any]], ) -> None: """Initialize Vizio device.""" self._config_entry = config_entry @@ -109,7 +122,11 @@ def __init__( self._volume_step = volume_step self._is_muted = 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._supported_commands = SUPPORTED_COMMANDS[device_class] self._device = device @@ -119,6 +136,30 @@ def __init__( self._model = 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: """Retrieve latest state of the device.""" if not self._model: @@ -149,6 +190,7 @@ async def async_update(self) -> None: self._is_muted = None self._current_input = None self._available_inputs = None + self._available_apps = None return self._state = STATE_ON @@ -165,8 +207,33 @@ async def async_update(self) -> None: self._current_input = input_ 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 async def _async_send_update_options_signal( @@ -237,13 +304,39 @@ def is_volume_muted(self): @property def source(self) -> str: """Return current input of the device.""" + if self._current_input in INPUT_APPS: + return self._current_app + return self._current_input @property - def source_list(self) -> List: + def source_list(self) -> List[str]: """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 + @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 def supported_features(self) -> int: """Flag device features that are supported.""" @@ -297,7 +390,18 @@ async def async_media_next_track(self) -> None: async def async_select_source(self, source: str) -> None: """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: """Increase volume of the device.""" diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 08414b7fe5375b..4142f809fba44a 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "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": { "name": "Name", "host": ":", @@ -19,13 +19,17 @@ "pin": "PIN" } }, - "pairing_complete": { - "title": "Pairing Complete", - "description": "Your Vizio SmartCast device is now connected to Home Assistant." - }, "pairing_complete_import": { "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": { @@ -36,7 +40,7 @@ }, "abort": { "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": { diff --git a/requirements_all.txt b/requirements_all.txt index ce04d4c5fb4ac4..1220ed3096115e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1717,7 +1717,7 @@ pyversasense==0.0.6 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.26 +pyvizio==0.1.35 # homeassistant.components.velux pyvlx==0.2.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66cf4d0798e8bb..420eefafb8b9ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -602,7 +602,7 @@ pyvera==0.3.7 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.26 +pyvizio==0.1.35 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 5c0500fe1a6d83..ab581bdf3c695c 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -5,9 +5,12 @@ from .const import ( ACCESS_TOKEN, + APP_LIST, CH_TYPE, + CURRENT_APP, CURRENT_INPUT, INPUT_LIST, + INPUT_LIST_WITH_APPS, MODEL, RESPONSE_TOKEN, UNIQUE_ID, @@ -154,3 +157,22 @@ def vizio_update_fixture(): return_value=VERSION, ): 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 diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index d3b6089a023120..2cb9103c4d9a4a 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -6,11 +6,23 @@ DEVICE_CLASS_TV, 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 ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_NAME, CONF_PIN, CONF_PORT, @@ -52,6 +64,22 @@ def __init__(self, auth_token: str) -> None: 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_USER_VALID_TV_CONFIG = { @@ -73,6 +101,58 @@ def __init__(self, auth_token: str) -> None: 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 = { CONF_NAME: NAME, CONF_HOST: HOST, @@ -85,6 +165,15 @@ def __init__(self, auth_token: str) -> None: 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." ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST = HOST.split(":")[0] @@ -97,8 +186,3 @@ def __init__(self, auth_token: str) -> None: CONF_PORT: ZEROCONF_PORT, "properties": {"name": "SB4031-D5"}, } - -CURRENT_INPUT = "HDMI" -INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"] - -ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}" diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index c65c0eb46c2004..e773035447af2b 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -6,7 +6,12 @@ from homeassistant import data_entry_flow 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 ( + CONF_APPS, + CONF_APPS_TO_INCLUDE_OR_EXCLUDE, + CONF_INCLUDE, + CONF_INCLUDE_OR_EXCLUDE, CONF_VOLUME_STEP, DEFAULT_NAME, DEFAULT_VOLUME_STEP, @@ -25,12 +30,16 @@ from .const import ( ACCESS_TOKEN, + CURRENT_APP, HOST, HOST2, MOCK_IMPORT_VALID_TV_CONFIG, + MOCK_INCLUDE_APPS, + MOCK_INCLUDE_NO_APPS, MOCK_PIN_CONFIG, MOCK_SPEAKER_CONFIG, MOCK_TV_CONFIG_NO_TOKEN, + MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_USER_VALID_TV_CONFIG, MOCK_ZEROCONF_SERVICE_INFO, NAME, @@ -86,12 +95,48 @@ async def test_user_flow_all_fields( 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["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] + + +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: @@ -218,13 +263,13 @@ async def test_user_error_on_could_not_connect( assert result["errors"] == {"base": "cant_connect"} -async def test_user_tv_pairing( +async def test_user_tv_pairing_no_apps( 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 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( 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["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["title"] == NAME assert result["data"][CONF_NAME] == NAME assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + assert CONF_APPS not in result["data"] 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, vizio_connect: pytest.fixture, vizio_bypass_update: pytest.fixture, ) -> 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( DOMAIN, 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[CONF_NAME] = NAME2 + updated_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} result = await hass.config_entries.flow.async_init( DOMAIN, 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["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_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( @@ -452,6 +534,49 @@ async def test_import_needs_pairing( 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( hass: HomeAssistantType, vizio_connect: pytest.fixture, diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index d13fe8ecf53d55..19696af73a2ff6 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -1,14 +1,21 @@ """Tests for Vizio config flow.""" from datetime import timedelta +import logging +from typing import Any, Dict from unittest.mock import call from asynctest import patch import pytest +from pytest import raises +from pyvizio._api.apps import AppConfig from pyvizio.const import ( DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, + INPUT_APPS, MAX_VOLUME, + UNKNOWN_APP, ) +import voluptuous as vol from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -27,16 +34,41 @@ SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, ) -from homeassistant.components.vizio.const import CONF_VOLUME_STEP, DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.components.vizio import validate_apps +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.util import dt as dt_util from .const import ( + ADDITIONAL_APP_CONFIG, + APP_LIST, + CURRENT_APP, CURRENT_INPUT, + CUSTOM_APP_NAME, + CUSTOM_CONFIG, ENTITY_ID, INPUT_LIST, + INPUT_LIST_WITH_APPS, + MOCK_SPEAKER_APPS_FAILURE, 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, NAME, UNIQUE_ID, @@ -45,6 +77,8 @@ from tests.common import MockConfigEntry, async_fire_time_changed +_LOGGER = logging.getLogger(__name__) + async def _test_setup( 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: vizio_device_class = VIZIO_DEVICE_CLASS_SPEAKER 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: vizio_device_class = VIZIO_DEVICE_CLASS_TV 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( @@ -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: """Test generic Vizio entity setup failure.""" with patch( @@ -311,3 +415,89 @@ async def test_update_available_to_unavailable( ) -> None: """Test device becomes unavailable after being available.""" 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)