diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index aa6f724bc59..c890af2700d 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -1,7 +1,7 @@ """The vizio component.""" - import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, @@ -9,13 +9,14 @@ from homeassistant.const import ( CONF_NAME, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import ( CONF_VOLUME_STEP, DEFAULT_DEVICE_CLASS, DEFAULT_NAME, DEFAULT_VOLUME_STEP, + DOMAIN, ) @@ -42,3 +43,41 @@ VIZIO_SCHEMA = { vol.Coerce(int), vol.Range(min=1, max=10) ), } + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, [vol.All(vol.Schema(VIZIO_SCHEMA), validate_auth)] + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Component setup, run import config flow for each entry in config.""" + if DOMAIN in config: + for entry in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Load the saved entities.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_forward_entry_unload(entry, "media_player") + + return True diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py new file mode 100644 index 00000000000..5863d89c972 --- /dev/null +++ b/homeassistant/components/vizio/config_flow.py @@ -0,0 +1,171 @@ +"""Config flow for Vizio.""" +import logging +from typing import Any, Dict + +from pyvizio import VizioAsync +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_DEVICE_CLASS, + CONF_HOST, + CONF_NAME, +) +from homeassistant.core import callback + +from . import validate_auth +from .const import ( + CONF_VOLUME_STEP, + DEFAULT_DEVICE_CLASS, + DEFAULT_NAME, + DEFAULT_VOLUME_STEP, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +def update_schema_defaults(input_dict: Dict[str, Any]) -> vol.Schema: + """Update schema defaults based on user input/config dict. Retains info already provided for future form views.""" + return vol.Schema( + { + vol.Required( + CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str, + vol.Optional( + CONF_DEVICE_CLASS, + default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS), + ): vol.All(str, vol.Lower, vol.In(["tv", "soundbar"])), + vol.Optional( + CONF_ACCESS_TOKEN, default=input_dict.get(CONF_ACCESS_TOKEN, "") + ): str, + }, + extra=vol.REMOVE_EXTRA, + ) + + +class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Vizio config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return VizioOptionsConfigFlow(config_entry) + + def __init__(self) -> None: + """Initialize config flow.""" + self.import_schema = None + self.user_schema = None + + async def async_step_user( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + # Store current values in case setup fails and user needs to edit + self.user_schema = update_schema_defaults(user_input) + + # Check if new config entry matches any existing config entries + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == user_input[CONF_HOST]: + errors[CONF_HOST] = "host_exists" + break + + if entry.data[CONF_NAME] == user_input[CONF_NAME]: + errors[CONF_NAME] = "name_exists" + break + + if not errors: + try: + # Ensure schema passes custom validation, otherwise catch exception and add error + validate_auth(user_input) + + # Ensure config is valid for a device + if not await VizioAsync.validate_ha_config( + user_input[CONF_HOST], + user_input.get(CONF_ACCESS_TOKEN), + user_input[CONF_DEVICE_CLASS], + ): + errors["base"] = "cant_connect" + except vol.Invalid: + errors["base"] = "tv_needs_token" + + 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], + ) + + # Abort flow if existing component with same unique ID matches new config entry + if await self.async_set_unique_id( + unique_id=unique_id, raise_on_progress=True + ): + return self.async_abort(reason="already_setup") + + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + schema = self.user_schema or self.import_schema or update_schema_defaults({}) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_import(self, import_config: Dict[str, Any]) -> Dict[str, Any]: + """Import a config entry from configuration.yaml.""" + # Check if new config entry matches any existing config entries + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == import_config[CONF_HOST] and entry.data[ + CONF_NAME + ] == import_config.get(CONF_NAME): + if entry.data[CONF_VOLUME_STEP] != import_config[CONF_VOLUME_STEP]: + new_volume_step = { + CONF_VOLUME_STEP: import_config[CONF_VOLUME_STEP] + } + self.hass.config_entries.async_update_entry( + entry=entry, + data=entry.data.copy().update(new_volume_step), + options=entry.options.copy().update(new_volume_step), + ) + return self.async_abort(reason="updated_volume_step") + return self.async_abort(reason="already_setup") + + # Store import values in case setup fails so user can see error + self.import_schema = update_schema_defaults(import_config) + + return await self.async_step_user(user_input=import_config) + + +class VizioOptionsConfigFlow(config_entries.OptionsFlow): + """Handle Transmission client options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize vizio options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Manage the vizio options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Optional( + CONF_VOLUME_STEP, + default=self.config_entry.options.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ), + ): vol.All(vol.Coerce(int), vol.Range(min=1, max=10)) + } + + return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index c4c1ba3199b..b87e40d3b46 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -1,5 +1,4 @@ """Constants used by vizio component.""" - CONF_VOLUME_STEP = "volume_step" DEFAULT_NAME = "Vizio SmartCast" diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 5a4c0f4a4cc..0a44a638d44 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -2,7 +2,8 @@ "domain": "vizio", "name": "Vizio SmartCast TV", "documentation": "https://www.home-assistant.io/integrations/vizio", - "requirements": ["pyvizio==0.0.15"], + "requirements": ["pyvizio==0.0.20"], "dependencies": [], - "codeowners": ["@raman325"] + "codeowners": ["@raman325"], + "config_flow": true } diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 84b745baf92..44f44c0c48e 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -1,14 +1,12 @@ """Vizio SmartCast Device support.""" - from datetime import timedelta import logging -from typing import Any, Callable, Dict, List +from typing import Callable, List from pyvizio import VizioAsync -import voluptuous as vol from homeassistant import util -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice +from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, @@ -19,6 +17,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, @@ -27,18 +26,24 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.typing import HomeAssistantType -from . import VIZIO_SCHEMA, validate_auth -from .const import CONF_VOLUME_STEP, DEVICE_ID, ICON +from .const import CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP, DEVICE_ID, DOMAIN, ICON _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) +PARALLEL_UPDATES = 0 + COMMON_SUPPORTED_COMMANDS = ( SUPPORT_SELECT_SOURCE | SUPPORT_TURN_ON @@ -54,26 +59,35 @@ SUPPORTED_COMMANDS = { } -PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend(VIZIO_SCHEMA), validate_auth) - - -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistantType, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: Callable[[List[Entity], bool], None], - discovery_info: Dict[str, Any] = None, -): - """Set up the Vizio media player platform.""" +) -> bool: + """Set up a Vizio media player entry.""" + host = config_entry.data[CONF_HOST] + token = config_entry.data.get(CONF_ACCESS_TOKEN) + name = config_entry.data[CONF_NAME] + device_type = config_entry.data[CONF_DEVICE_CLASS] - host = config[CONF_HOST] - token = config.get(CONF_ACCESS_TOKEN) - name = config[CONF_NAME] - volume_step = config[CONF_VOLUME_STEP] - device_type = config[CONF_DEVICE_CLASS] + # If config entry options not set up, set them up, otherwise assign values managed in options + if CONF_VOLUME_STEP not in config_entry.options: + volume_step = config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP) + hass.config_entries.async_update_entry( + config_entry, options={CONF_VOLUME_STEP: volume_step} + ) + else: + volume_step = config_entry.options[CONF_VOLUME_STEP] device = VizioAsync( - DEVICE_ID, host, name, token, device_type, async_get_clientsession(hass, False) + DEVICE_ID, + host, + name, + token, + device_type, + session=async_get_clientsession(hass, False), ) + if not await device.can_connect(): fail_auth_msg = "" if token: @@ -83,18 +97,27 @@ async def async_setup_platform( "is valid and available, device type is correct%s", fail_auth_msg, ) - return + raise PlatformNotReady - async_add_entities([VizioDevice(device, name, volume_step, device_type)], True) + entity = VizioDevice(config_entry, device, name, volume_step, device_type) + + async_add_entities([entity], True) class VizioDevice(MediaPlayerDevice): """Media Player implementation which performs REST requests to device.""" def __init__( - self, device: VizioAsync, name: str, volume_step: int, device_type: str + self, + config_entry: ConfigEntry, + device: VizioAsync, + name: str, + volume_step: int, + device_type: str, ) -> None: """Initialize Vizio device.""" + self._config_entry = config_entry + self._async_unsub_listeners = [] self._name = name self._state = None @@ -106,104 +129,140 @@ class VizioDevice(MediaPlayerDevice): self._supported_commands = SUPPORTED_COMMANDS[device_type] self._device = device self._max_volume = float(self._device.get_max_volume()) - self._unique_id = None self._icon = ICON[device_type] + self._available = True @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) async def async_update(self) -> None: """Retrieve latest state of the device.""" + is_on = await self._device.get_power_state(False) - if not self._unique_id: - self._unique_id = await self._device.get_esn() + if is_on is None: + self._available = False + return - is_on = await self._device.get_power_state() - - if is_on: - self._state = STATE_ON - - volume = await self._device.get_current_volume() - if volume is not None: - self._volume_level = float(volume) / self._max_volume - - input_ = await self._device.get_current_input() - if input_ is not None: - self._current_input = input_.meta_name - - inputs = await self._device.get_inputs() - if inputs is not None: - self._available_inputs = [input_.name for input_ in inputs] - - else: - if is_on is None: - self._state = None - else: - self._state = STATE_OFF + self._available = True + if not is_on: + self._state = STATE_OFF self._volume_level = None self._current_input = None self._available_inputs = None + return + + self._state = STATE_ON + + volume = await self._device.get_current_volume(False) + if volume is not None: + self._volume_level = float(volume) / self._max_volume + + input_ = await self._device.get_current_input(False) + if input_ is not None: + self._current_input = input_.meta_name + + inputs = await self._device.get_inputs(False) + if inputs is not None: + self._available_inputs = [input_.name for input_ in inputs] + + @staticmethod + async def _async_send_update_options_signal( + hass: HomeAssistantType, config_entry: ConfigEntry + ) -> None: + """Send update event when when Vizio config entry is updated.""" + # Move this method to component level if another entity ever gets added for a single config entry. See here: https://github.com/home-assistant/home-assistant/pull/30653#discussion_r366426121 + async_dispatcher_send(hass, config_entry.entry_id, config_entry) + + async def _async_update_options(self, config_entry: ConfigEntry) -> None: + """Update options if the update signal comes from this entity.""" + self._volume_step = config_entry.options[CONF_VOLUME_STEP] + + async def async_added_to_hass(self): + """Register callbacks when entity is added.""" + # Register callback for when config entry is updated. + self._async_unsub_listeners.append( + self._config_entry.add_update_listener( + self._async_send_update_options_signal + ) + ) + + # Register callback for update event + self._async_unsub_listeners.append( + async_dispatcher_connect( + self.hass, self._config_entry.entry_id, self._async_update_options + ) + ) + + async def async_will_remove_from_hass(self): + """Disconnect callbacks when entity is removed.""" + for listener in self._async_unsub_listeners: + listener() + + self._async_unsub_listeners.clear() + + @property + def available(self) -> bool: + """Return the availabiliity of the device.""" + return self._available @property def state(self) -> str: """Return the state of the device.""" - return self._state @property def name(self) -> str: """Return the name of the device.""" - return self._name @property def icon(self) -> str: """Return the icon of the device.""" - return self._icon @property def volume_level(self) -> float: """Return the volume level of the device.""" - return self._volume_level @property def source(self) -> str: """Return current input of the device.""" - return self._current_input @property def source_list(self) -> List: """Return list of available inputs of the device.""" - return self._available_inputs @property def supported_features(self) -> int: """Flag device features that are supported.""" - return self._supported_commands @property def unique_id(self) -> str: """Return the unique id of the device.""" + return self._config_entry.unique_id - return self._unique_id + @property + def device_info(self): + """Return device registry information.""" + return { + "identifiers": {(DOMAIN, self._config_entry.unique_id)}, + "name": self.name, + "manufacturer": "VIZIO", + } async def async_turn_on(self) -> None: """Turn the device on.""" - await self._device.pow_on() async def async_turn_off(self) -> None: """Turn the device off.""" - await self._device.pow_off() async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - if mute: await self._device.mute_on() else: @@ -211,22 +270,18 @@ class VizioDevice(MediaPlayerDevice): async def async_media_previous_track(self) -> None: """Send previous channel command.""" - await self._device.ch_down() async def async_media_next_track(self) -> None: """Send next channel command.""" - await self._device.ch_up() async def async_select_source(self, source: str) -> None: """Select input source.""" - await self._device.input_switch(source) async def async_volume_up(self) -> None: """Increasing volume of the device.""" - await self._device.vol_up(self._volume_step) if self._volume_level is not None: @@ -236,7 +291,6 @@ class VizioDevice(MediaPlayerDevice): async def async_volume_down(self) -> None: """Decreasing volume of the device.""" - await self._device.vol_down(self._volume_step) if self._volume_level is not None: @@ -246,7 +300,6 @@ class VizioDevice(MediaPlayerDevice): async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" - if self._volume_level is not None: if volume > self._volume_level: num = int(self._max_volume * (volume - self._volume_level)) diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json new file mode 100644 index 00000000000..029643ab578 --- /dev/null +++ b/homeassistant/components/vizio/strings.json @@ -0,0 +1,40 @@ +{ + "config": { + "title": "Vizio SmartCast", + "step": { + "user": { + "title": "Setup Vizio SmartCast Client", + "data": { + "name": "Name", + "host": ":", + "device_class": "Device Type", + "access_token": "Access Token" + } + } + }, + "error": { + "host_exists": "Host already configured.", + "name_exists": "Name already configured.", + "cant_connect": "Could not connect to the device. [Review the docs](https://www.home-assistant.io/integrations/vizio/) and re-verify that:\n- The device is powered on\n- The device is connected to the network\n- The values you filled in are accurate\nbefore attempting to resubmit.", + "tv_needs_token": "When Device Type is `tv` then a valid Access Token is needed." + }, + "abort": { + "already_in_progress": "Config flow for vizio component already in progress.", + "already_setup": "This entry has already been setup.", + "host_exists": "Vizio component with host already configured.", + "name_exists": "Vizio component with name already configured.", + "updated_volume_step": "This entry has already been setup but the volume step size in the config does not match the config entry so the config entry has been updated accordingly." + } + }, + "options": { + "title": "Update Vizo SmartCast Options", + "step": { + "init": { + "title": "Update Vizo SmartCast Options", + "data": { + "volume_step": "Volume Step Size" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f6154e1929d..3886dfd2f20 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -92,6 +92,7 @@ FLOWS = [ "upnp", "velbus", "vesync", + "vizio", "wemo", "withings", "wled", diff --git a/requirements_all.txt b/requirements_all.txt index ed6bba9174c..586a0530fc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1693,7 +1693,7 @@ pyversasense==0.0.6 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.0.15 +pyvizio==0.0.20 # homeassistant.components.velux pyvlx==0.2.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc3b1d18199..1287711f512 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -557,6 +557,9 @@ pyvera==0.3.7 # homeassistant.components.vesync pyvesync==1.1.0 +# homeassistant.components.vizio +pyvizio==0.0.20 + # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/vizio/__init__.py b/tests/components/vizio/__init__.py new file mode 100644 index 00000000000..f6cd65f56c1 --- /dev/null +++ b/tests/components/vizio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Vizio integration.""" diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py new file mode 100644 index 00000000000..9e657cf926d --- /dev/null +++ b/tests/components/vizio/test_config_flow.py @@ -0,0 +1,289 @@ +"""Tests for Vizio config flow.""" +import logging + +from asynctest import patch +import pytest +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.vizio import VIZIO_SCHEMA +from homeassistant.components.vizio.const import ( + CONF_VOLUME_STEP, + DEFAULT_NAME, + DEFAULT_VOLUME_STEP, + DOMAIN, +) +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_DEVICE_CLASS, + CONF_HOST, + CONF_NAME, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + +_LOGGER = logging.getLogger(__name__) + +NAME = "Vizio" +HOST = "192.168.1.1:9000" +DEVICE_CLASS_TV = "tv" +DEVICE_CLASS_SOUNDBAR = "soundbar" +ACCESS_TOKEN = "deadbeef" +VOLUME_STEP = 2 +UNIQUE_ID = "testid" + +MOCK_USER_VALID_TV_ENTRY = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, +} + +MOCK_IMPORT_VALID_TV_ENTRY = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_VOLUME_STEP: VOLUME_STEP, +} + +MOCK_INVALID_TV_ENTRY = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, +} + +MOCK_SOUNDBAR_ENTRY = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_SOUNDBAR, +} + + +@pytest.fixture(name="vizio_connect") +def vizio_connect_fixture(): + """Mock valid vizio device and entry setup.""" + with patch( + "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", + return_value=True, + ), patch( + "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id", + return_value=UNIQUE_ID, + ), patch( + "homeassistant.components.vizio.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture(name="vizio_cant_connect") +def vizio_cant_connect_fixture(): + """Mock vizio device cant connect.""" + with patch( + "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", + return_value=False, + ): + yield + + +async def test_user_flow_minimum_fields(hass: HomeAssistantType, vizio_connect) -> None: + """Test user config flow with minimum fields.""" + # test form shows + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_SOUNDBAR, + }, + ) + + 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_SOUNDBAR + + +async def test_user_flow_all_fields(hass: HomeAssistantType, vizio_connect) -> None: + """Test user config flow with all fields.""" + # test form shows + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + }, + ) + + 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 + + +async def test_user_host_already_configured( + hass: HomeAssistantType, vizio_connect +) -> None: + """Test host is already configured during user setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_SOUNDBAR_ENTRY, + options={CONF_VOLUME_STEP: VOLUME_STEP}, + ) + entry.add_to_hass(hass) + fail_entry = MOCK_SOUNDBAR_ENTRY.copy() + fail_entry[CONF_NAME] = "newtestname" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=fail_entry, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "host_exists"} + + +async def test_user_name_already_configured( + hass: HomeAssistantType, vizio_connect +) -> None: + """Test name is already configured during user setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_SOUNDBAR_ENTRY, + options={CONF_VOLUME_STEP: VOLUME_STEP}, + ) + entry.add_to_hass(hass) + + fail_entry = MOCK_SOUNDBAR_ENTRY.copy() + fail_entry[CONF_HOST] = "0.0.0.0" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], fail_entry + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_NAME: "name_exists"} + + +async def test_user_error_on_could_not_connect( + hass: HomeAssistantType, vizio_cant_connect +) -> None: + """Test with could_not_connect during user_setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_USER_VALID_TV_ENTRY + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cant_connect"} + + +async def test_user_error_on_tv_needs_token( + hass: HomeAssistantType, vizio_connect +) -> None: + """Test when config fails custom validation for non null access token when device_class = tv during user setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], MOCK_INVALID_TV_ENTRY + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "tv_needs_token"} + + +async def test_import_flow_minimum_fields( + hass: HomeAssistantType, vizio_connect +) -> None: + """Test import config flow with minimum fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data=vol.Schema(VIZIO_SCHEMA)( + {CONF_HOST: HOST, CONF_DEVICE_CLASS: DEVICE_CLASS_SOUNDBAR} + ), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"][CONF_NAME] == DEFAULT_NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_SOUNDBAR + assert result["data"][CONF_VOLUME_STEP] == DEFAULT_VOLUME_STEP + + +async def test_import_flow_all_fields(hass: HomeAssistantType, vizio_connect) -> None: + """Test import config flow with all fields.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_ENTRY), + ) + + 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_VOLUME_STEP] == VOLUME_STEP + + +async def test_import_entity_already_configured( + hass: HomeAssistantType, vizio_connect +) -> None: + """Test entity is already configured during import setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_SOUNDBAR_ENTRY), + options={CONF_VOLUME_STEP: VOLUME_STEP}, + ) + entry.add_to_hass(hass) + fail_entry = vol.Schema(VIZIO_SCHEMA)(MOCK_SOUNDBAR_ENTRY.copy()) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=fail_entry + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_setup"