Support vizio pairing through config flow (#31520)

* support pairing through config flow

* simplify import failure log messages

* remove unnecessary list comprehension

* bump pyvizio to add passing ClientSession in where it was missed

* show different message if user completes pairing through import

* remove dupe failure message since reasons for failure are the same in both instances

* remove extra constant

* add host reachability check during pairing workflow

* revert redundant connection check since check is implicitly done during pairing process

* fix rebase errors

* fix string

* updates based on review

* update docstring

* missed commit

* update import confirmation message to be less wordy

* use ConfigFlow _abort_if_unique_id_configured

* fix test
This commit is contained in:
Raman Gupta 2020-02-28 01:04:59 -05:00 committed by GitHub
parent 03d8abe1ba
commit b9fa32444a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 388 additions and 67 deletions

View File

@ -3,34 +3,14 @@ import asyncio
import voluptuous as vol
from homeassistant.components.media_player import DEVICE_CLASS_TV
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
from .const import DOMAIN, VIZIO_SCHEMA
def validate_auth(config: ConfigType) -> ConfigType:
"""Validate presence of CONF_ACCESS_TOKEN when CONF_DEVICE_CLASS == DEVICE_CLASS_TV."""
token = config.get(CONF_ACCESS_TOKEN)
if config[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV and not token:
raise vol.Invalid(
f"When '{CONF_DEVICE_CLASS}' is '{DEVICE_CLASS_TV}' then "
f"'{CONF_ACCESS_TOKEN}' is required.",
path=[CONF_ACCESS_TOKEN],
)
return config
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.All(
cv.ensure_list, [vol.All(vol.Schema(VIZIO_SCHEMA), validate_auth)]
)
},
{DOMAIN: vol.All(cv.ensure_list, [vol.Schema(VIZIO_SCHEMA)])},
extra=vol.ALLOW_EXTRA,
)

View File

@ -1,4 +1,5 @@
"""Config flow for Vizio."""
import copy
import logging
from typing import Any, Dict
@ -7,24 +8,25 @@ import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigEntry
from homeassistant.const import (
CONF_ACCESS_TOKEN,
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_NAME,
CONF_PIN,
CONF_PORT,
CONF_TYPE,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from . import validate_auth
from .const import (
CONF_VOLUME_STEP,
DEFAULT_DEVICE_CLASS,
DEFAULT_NAME,
DEFAULT_VOLUME_STEP,
DEVICE_ID,
DOMAIN,
)
@ -42,7 +44,7 @@ def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema:
CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME)
): str,
vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str,
vol.Optional(
vol.Required(
CONF_DEVICE_CLASS,
default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS),
): vol.All(str, vol.Lower, vol.In([DEVICE_CLASS_TV, DEVICE_CLASS_SPEAKER])),
@ -54,6 +56,17 @@ 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."""
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,
)
def _host_is_same(host1: str, host2: str) -> bool:
"""Check if host1 and host2 are the same."""
return host1.split(":")[0] == host2.split(":")[0]
@ -101,6 +114,27 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Initialize config flow."""
self._user_schema = None
self._must_show_form = None
self._ch_type = None
self._pairing_token = None
self._data = None
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),
)
# 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()
return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict)
async def async_step_user(
self, user_input: Dict[str, Any] = None
@ -116,17 +150,18 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
for entry in self.hass.config_entries.async_entries(DOMAIN):
if _host_is_same(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)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
if self._must_show_form and self.context["source"] == SOURCE_ZEROCONF:
# Discovery should always display the config form before trying to
# create entry so that user can update default config options
self._must_show_form = False
elif user_input[
CONF_DEVICE_CLASS
] == DEVICE_CLASS_SPEAKER or user_input.get(CONF_ACCESS_TOKEN):
# Ensure config is valid for a device
if not await VizioAsync.validate_ha_config(
user_input[CONF_HOST],
@ -135,38 +170,38 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
session=async_get_clientsession(self.hass, False),
):
errors["base"] = "cant_connect"
except vol.Invalid:
errors["base"] = "tv_needs_token"
if not errors:
# Skip validating config and creating entry if form must be shown
if self._must_show_form:
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:
# Import should always display the config form if CONF_ACCESS_TOKEN
# wasn't included but is needed so that the user can choose to update
# 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 ",
CONF_ACCESS_TOKEN,
CONF_ACCESS_TOKEN,
)
self._must_show_form = False
else:
# Abort flow if existing entry with same unique ID matches new config entry.
# Since name and host check have already passed, if an entry already exists,
# It is likely a reconfigured device.
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),
)
self._data = copy.deepcopy(user_input)
return await self.async_step_pair_tv()
if await self.async_set_unique_id(
unique_id=unique_id, raise_on_progress=True
):
return self.async_abort(
reason="already_setup_with_diff_host_and_name"
)
return self.async_create_entry(
title=user_input[CONF_NAME], data=user_input
)
# Use user_input params as default values for schema if user_input is non-empty, otherwise use default schema
schema = self._user_schema or _get_config_schema()
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
if errors and self.context["source"] == SOURCE_IMPORT:
# Log an error message if import config flow fails since otherwise failure is silent
_LOGGER.error(
"configuration.yaml import failure: %s", ", ".join(errors.values())
)
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]:
@ -201,6 +236,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_setup")
self._must_show_form = True
return await self.async_step_user(user_input=import_config)
async def async_step_zeroconf(
@ -231,7 +267,95 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
discovery_info[CONF_HOST]
)
# Form must be shown after discovery so user can confirm/update configuration before ConfigEntry creation.
# Form must be shown after discovery so user can confirm/update configuration
# before ConfigEntry creation.
self._must_show_form = True
return await self.async_step_user(user_input=discovery_info)
async def async_step_pair_tv(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
"""Start pairing process and ask user for PIN to complete pairing process."""
errors = {}
# Start pairing process if it hasn't already started
if not self._ch_type and not self._pairing_token:
dev = VizioAsync(
DEVICE_ID,
self._data[CONF_HOST],
self._data[CONF_NAME],
None,
self._data[CONF_DEVICE_CLASS],
session=async_get_clientsession(self.hass, False),
)
pair_data = await dev.start_pair()
if pair_data:
self._ch_type = pair_data.ch_type
self._pairing_token = pair_data.token
return await self.async_step_pair_tv()
return self.async_show_form(
step_id="user",
data_schema=_get_config_schema(self._data),
errors={"base": "cant_connect"},
)
# Complete pairing process if PIN has been provided
if user_input and user_input.get(CONF_PIN):
dev = VizioAsync(
DEVICE_ID,
self._data[CONF_HOST],
self._data[CONF_NAME],
None,
self._data[CONF_DEVICE_CLASS],
session=async_get_clientsession(self.hass, False),
)
pair_data = await dev.pair(
self._ch_type, self._pairing_token, user_input[CONF_PIN]
)
if pair_data:
self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token
self._must_show_form = True
return await self.async_step_user(user_input=discovery_info)
# 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()
# If no data was retrieved, it's assumed that the pairing attempt was not
# successful
errors[CONF_PIN] = "complete_pairing_failed"
return self.async_show_form(
step_id="pair_tv",
data_schema=_get_pairing_schema(user_input),
errors=errors,
)
async def _pairing_complete(self, step_id: str) -> Dict[str, Any]:
"""Handle config flow completion."""
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,
data_schema=vol.Schema({}),
description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]},
)
async def async_step_pairing_complete(
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")
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")

View File

@ -4,23 +4,38 @@
"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.",
"data": {
"name": "Name",
"host": "<Host/IP>:<Port>",
"device_class": "Device Type",
"access_token": "Access Token"
}
},
"pair_tv": {
"title": "Complete Pairing Process",
"description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.",
"data": {
"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}**'."
}
},
"error": {
"host_exists": "Vizio device with specified host already configured.",
"name_exists": "Vizio device with specified 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."
"complete_pairing failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.",
"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."
},
"abort": {
"already_setup": "This entry has already been setup.",
"already_setup_with_diff_host_and_name": "This entry appears to have already been setup with a different host and name based on its serial number. Please remove any old entries from your configuration.yaml and from the Integrations menu before reattempting to add this device.",
"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."
}
},

View File

@ -3,7 +3,18 @@ from asynctest import patch
import pytest
from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME
from .const import CURRENT_INPUT, INPUT_LIST, MODEL, UNIQUE_ID, VERSION
from .const import (
ACCESS_TOKEN,
CH_TYPE,
CURRENT_INPUT,
INPUT_LIST,
MODEL,
RESPONSE_TOKEN,
UNIQUE_ID,
VERSION,
MockCompletePairingResponse,
MockStartPairingResponse,
)
class MockInput:
@ -42,6 +53,41 @@ def vizio_connect_fixture():
yield
@pytest.fixture(name="vizio_complete_pairing")
def vizio_complete_pairing_fixture():
"""Mock complete vizio pairing workflow."""
with patch(
"homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN),
), patch(
"homeassistant.components.vizio.config_flow.VizioAsync.pair",
return_value=MockCompletePairingResponse(ACCESS_TOKEN),
):
yield
@pytest.fixture(name="vizio_start_pairing_failure")
def vizio_start_pairing_failure_fixture():
"""Mock vizio start pairing failure."""
with patch(
"homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
return_value=None,
):
yield
@pytest.fixture(name="vizio_invalid_pin_failure")
def vizio_invalid_pin_failure_fixture():
"""Mock vizio failure due to invalid pin."""
with patch(
"homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN),
), patch(
"homeassistant.components.vizio.config_flow.VizioAsync.pair", return_value=None,
):
yield
@pytest.fixture(name="vizio_bypass_setup")
def vizio_bypass_setup_fixture():
"""Mock component setup."""

View File

@ -12,6 +12,7 @@ from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_NAME,
CONF_PIN,
CONF_PORT,
CONF_TYPE,
)
@ -29,6 +30,30 @@ UNIQUE_ID = "testid"
MODEL = "model"
VERSION = "version"
CH_TYPE = 1
RESPONSE_TOKEN = 1234
PIN = "abcd"
class MockStartPairingResponse(object):
"""Mock Vizio start pairing response."""
def __init__(self, ch_type: int, token: int) -> None:
"""Initialize mock start pairing response."""
self.ch_type = ch_type
self.token = token
class MockCompletePairingResponse(object):
"""Mock Vizio complete pairing response."""
def __init__(self, auth_token: str) -> None:
"""Initialize mock complete pairing response."""
self.auth_token = auth_token
MOCK_PIN_CONFIG = {CONF_PIN: PIN}
MOCK_USER_VALID_TV_CONFIG = {
CONF_NAME: NAME,
CONF_HOST: HOST,

View File

@ -1,4 +1,6 @@
"""Tests for Vizio config flow."""
import logging
import pytest
import voluptuous as vol
@ -17,6 +19,7 @@ from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_HOST,
CONF_NAME,
CONF_PIN,
)
from homeassistant.helpers.typing import HomeAssistantType
@ -25,6 +28,7 @@ from .const import (
HOST,
HOST2,
MOCK_IMPORT_VALID_TV_CONFIG,
MOCK_PIN_CONFIG,
MOCK_SPEAKER_CONFIG,
MOCK_TV_CONFIG_NO_TOKEN,
MOCK_USER_VALID_TV_CONFIG,
@ -37,6 +41,8 @@ from .const import (
from tests.common import MockConfigEntry
_LOGGER = logging.getLogger(__name__)
async def test_user_flow_minimum_fields(
hass: HomeAssistantType,
@ -197,7 +203,7 @@ async def test_user_esn_already_exists(
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_setup_with_diff_host_and_name"
assert result["reason"] == "already_configured"
async def test_user_error_on_could_not_connect(
@ -212,18 +218,73 @@ async def test_user_error_on_could_not_connect(
assert result["errors"] == {"base": "cant_connect"}
async def test_user_error_on_tv_needs_token(
async def test_user_tv_pairing(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
vizio_complete_pairing: pytest.fixture,
) -> None:
"""Test when config fails custom validation for non null access token when device_class = tv during user setup."""
"""Test pairing config flow when access token not provided for tv during user entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "tv_needs_token"}
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"
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
async def test_user_start_pairing_failure(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
vizio_start_pairing_failure: pytest.fixture,
) -> None:
"""Test failure to start pairing from user config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cant_connect"}
async def test_user_invalid_pin(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
vizio_invalid_pin_failure: pytest.fixture,
) -> None:
"""Test failure to complete pairing from user config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=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"] == "pair_tv"
assert result["errors"] == {CONF_PIN: "complete_pairing_failed"}
async def test_import_flow_minimum_fields(
@ -354,6 +415,76 @@ async def test_import_flow_update_name(
assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2
async def test_import_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 during import."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_TV_CONFIG_NO_TOKEN
)
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=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
async def test_import_error(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
caplog: pytest.fixture,
) -> None:
"""Test that error is logged when import config has an error."""
entry = MockConfigEntry(
domain=DOMAIN,
data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG),
options={CONF_VOLUME_STEP: VOLUME_STEP},
)
entry.add_to_hass(hass)
fail_entry = MOCK_SPEAKER_CONFIG.copy()
fail_entry[CONF_HOST] = "0.0.0.0"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=vol.Schema(VIZIO_SCHEMA)(fail_entry),
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# Ensure error gets logged
vizio_log_list = [
log
for log in caplog.records
if log.name == "homeassistant.components.vizio.config_flow"
]
assert len(vizio_log_list) == 1
async def test_zeroconf_flow(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,