Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for Vizio."""
2 
3 from __future__ import annotations
4 
5 import copy
6 import logging
7 import socket
8 from typing import Any
9 
10 from pyvizio import VizioAsync, async_guess_device_type
11 from pyvizio.const import APP_HOME
12 import voluptuous as vol
13 
14 from homeassistant.components import zeroconf
15 from homeassistant.components.media_player import MediaPlayerDeviceClass
16 from homeassistant.config_entries import (
17  SOURCE_IGNORE,
18  SOURCE_IMPORT,
19  SOURCE_ZEROCONF,
20  ConfigEntry,
21  ConfigFlow,
22  ConfigFlowResult,
23  OptionsFlow,
24 )
25 from homeassistant.const import (
26  CONF_ACCESS_TOKEN,
27  CONF_DEVICE_CLASS,
28  CONF_EXCLUDE,
29  CONF_HOST,
30  CONF_INCLUDE,
31  CONF_NAME,
32  CONF_PIN,
33 )
34 from homeassistant.core import callback
35 from homeassistant.helpers import config_validation as cv
36 from homeassistant.helpers.aiohttp_client import async_get_clientsession
37 from homeassistant.util.network import is_ip_address
38 
39 from .const import (
40  CONF_APPS,
41  CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
42  CONF_INCLUDE_OR_EXCLUDE,
43  CONF_VOLUME_STEP,
44  DEFAULT_DEVICE_CLASS,
45  DEFAULT_NAME,
46  DEFAULT_VOLUME_STEP,
47  DEVICE_ID,
48  DOMAIN,
49 )
50 
51 _LOGGER = logging.getLogger(__name__)
52 
53 
54 def _get_config_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema:
55  """Return schema defaults for init step based on user input/config dict.
56 
57  Retain info already provided for future form views by setting them
58  as defaults in schema.
59  """
60  if input_dict is None:
61  input_dict = {}
62 
63  return vol.Schema(
64  {
65  vol.Required(
66  CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME)
67  ): str,
68  vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str,
69  vol.Required(
70  CONF_DEVICE_CLASS,
71  default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS),
72  ): vol.All(
73  str,
74  vol.Lower,
75  vol.In([MediaPlayerDeviceClass.TV, MediaPlayerDeviceClass.SPEAKER]),
76  ),
77  vol.Optional(
78  CONF_ACCESS_TOKEN, default=input_dict.get(CONF_ACCESS_TOKEN, "")
79  ): str,
80  },
81  extra=vol.REMOVE_EXTRA,
82  )
83 
84 
85 def _get_pairing_schema(input_dict: dict[str, Any] | None = None) -> vol.Schema:
86  """Return schema defaults for pairing data based on user input.
87 
88  Retain info already provided for future form views by setting
89  them as defaults in schema.
90  """
91  if input_dict is None:
92  input_dict = {}
93 
94  return vol.Schema(
95  {vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}
96  )
97 
98 
99 def _host_is_same(host1: str, host2: str) -> bool:
100  """Check if host1 and host2 are the same."""
101  host1 = host1.split(":")[0]
102  host1 = host1 if is_ip_address(host1) else socket.gethostbyname(host1)
103  host2 = host2.split(":")[0]
104  host2 = host2 if is_ip_address(host2) else socket.gethostbyname(host2)
105  return host1 == host2
106 
107 
109  """Handle Vizio options."""
110 
111  async def async_step_init(
112  self, user_input: dict[str, Any] | None = None
113  ) -> ConfigFlowResult:
114  """Manage the vizio options."""
115  if user_input is not None:
116  if user_input.get(CONF_APPS_TO_INCLUDE_OR_EXCLUDE):
117  user_input[CONF_APPS] = {
118  user_input[CONF_INCLUDE_OR_EXCLUDE]: user_input[
119  CONF_APPS_TO_INCLUDE_OR_EXCLUDE
120  ].copy()
121  }
122 
123  user_input.pop(CONF_INCLUDE_OR_EXCLUDE)
124  user_input.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE)
125 
126  return self.async_create_entryasync_create_entry(title="", data=user_input)
127 
128  options = vol.Schema(
129  {
130  vol.Optional(
131  CONF_VOLUME_STEP,
132  default=self.config_entryconfig_entryconfig_entry.options.get(
133  CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
134  ),
135  ): vol.All(vol.Coerce(int), vol.Range(min=1, max=10))
136  }
137  )
138 
139  if self.config_entryconfig_entryconfig_entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV:
140  default_include_or_exclude = (
141  CONF_EXCLUDE
142  if self.config_entryconfig_entryconfig_entry.options
143  and CONF_EXCLUDE in self.config_entryconfig_entryconfig_entry.options.get(CONF_APPS, {})
144  else CONF_INCLUDE
145  )
146  options = options.extend(
147  {
148  vol.Optional(
149  CONF_INCLUDE_OR_EXCLUDE,
150  default=default_include_or_exclude.title(),
151  ): vol.All(
152  vol.In([CONF_INCLUDE.title(), CONF_EXCLUDE.title()]), vol.Lower
153  ),
154  vol.Optional(
155  CONF_APPS_TO_INCLUDE_OR_EXCLUDE,
156  default=self.config_entryconfig_entryconfig_entry.options.get(CONF_APPS, {}).get(
157  default_include_or_exclude, []
158  ),
159  ): cv.multi_select(
160  [
161  APP_HOME["name"],
162  *(
163  app["name"]
164  for app in self.hass.data[DOMAIN][CONF_APPS].data
165  ),
166  ]
167  ),
168  }
169  )
170 
171  return self.async_show_formasync_show_form(step_id="init", data_schema=options)
172 
173 
174 class VizioConfigFlow(ConfigFlow, domain=DOMAIN):
175  """Handle a Vizio config flow."""
176 
177  VERSION = 1
178 
179  @staticmethod
180  @callback
181  def async_get_options_flow(config_entry: ConfigEntry) -> VizioOptionsConfigFlow:
182  """Get the options flow for this handler."""
183  return VizioOptionsConfigFlow()
184 
185  def __init__(self) -> None:
186  """Initialize config flow."""
187  self._user_schema_user_schema: vol.Schema | None = None
188  self._must_show_form_must_show_form: bool | None = None
189  self._ch_type_ch_type: str | None = None
190  self._pairing_token_pairing_token: str | None = None
191  self._data_data: dict[str, Any] | None = None
192  self._apps_apps: dict[str, list] = {}
193 
194  async def _create_entry(self, input_dict: dict[str, Any]) -> ConfigFlowResult:
195  """Create vizio config entry."""
196  # Remove extra keys that will not be used by entry setup
197  input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None)
198  input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None)
199 
200  if self._apps_apps:
201  input_dict[CONF_APPS] = self._apps_apps
202 
203  return self.async_create_entryasync_create_entryasync_create_entry(title=input_dict[CONF_NAME], data=input_dict)
204 
205  async def async_step_user(
206  self, user_input: dict[str, Any] | None = None
207  ) -> ConfigFlowResult:
208  """Handle a flow initialized by the user."""
209  errors: dict[str, str] = {}
210 
211  if user_input is not None:
212  # Store current values in case setup fails and user needs to edit
213  self._user_schema_user_schema = _get_config_schema(user_input)
214  if self.unique_idunique_id is None:
215  unique_id = await VizioAsync.get_unique_id(
216  user_input[CONF_HOST],
217  user_input[CONF_DEVICE_CLASS],
218  session=async_get_clientsession(self.hass, False),
219  )
220 
221  # Check if unique ID was found, set unique ID, and abort if a flow with
222  # the same unique ID is already in progress
223  if not unique_id:
224  errors[CONF_HOST] = "cannot_connect"
225  elif (
226  await self.async_set_unique_idasync_set_unique_id(
227  unique_id=unique_id, raise_on_progress=True
228  )
229  is not None
230  ):
231  errors[CONF_HOST] = "existing_config_entry_found"
232 
233  if not errors:
234  if self._must_show_form_must_show_form and self.context["source"] == SOURCE_ZEROCONF:
235  # Discovery should always display the config form before trying to
236  # create entry so that user can update default config options
237  self._must_show_form_must_show_form = False
238  elif user_input[
239  CONF_DEVICE_CLASS
240  ] == MediaPlayerDeviceClass.SPEAKER or user_input.get(
241  CONF_ACCESS_TOKEN
242  ):
243  # Ensure config is valid for a device
244  if not await VizioAsync.validate_ha_config(
245  user_input[CONF_HOST],
246  user_input.get(CONF_ACCESS_TOKEN),
247  user_input[CONF_DEVICE_CLASS],
248  session=async_get_clientsession(self.hass, False),
249  ):
250  errors["base"] = "cannot_connect"
251 
252  if not errors:
253  return await self._create_entry_create_entry(user_input)
254  elif self._must_show_form_must_show_form and self.context["source"] == SOURCE_IMPORT:
255  # Import should always display the config form if CONF_ACCESS_TOKEN
256  # wasn't included but is needed so that the user can choose to update
257  # their configuration.yaml or to proceed with config flow pairing. We
258  # will also provide contextual message to user explaining why
259  _LOGGER.warning(
260  (
261  "Couldn't complete configuration.yaml import: '%s' key is "
262  "missing. Either provide '%s' key in configuration.yaml or "
263  "finish setup by completing configuration via frontend"
264  ),
265  CONF_ACCESS_TOKEN,
266  CONF_ACCESS_TOKEN,
267  )
268  self._must_show_form_must_show_form = False
269  else:
270  self._data_data = copy.deepcopy(user_input)
271  return await self.async_step_pair_tvasync_step_pair_tv()
272 
273  schema = self._user_schema_user_schema or _get_config_schema()
274 
275  if errors and self.context["source"] == SOURCE_IMPORT:
276  # Log an error message if import config flow fails since otherwise failure is silent
277  _LOGGER.error(
278  "Importing from configuration.yaml failed: %s",
279  ", ".join(errors.values()),
280  )
281 
282  return self.async_show_formasync_show_formasync_show_form(step_id="user", data_schema=schema, errors=errors)
283 
284  async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
285  """Import a config entry from configuration.yaml."""
286  # Check if new config entry matches any existing config entries
287  for entry in self._async_current_entries_async_current_entries():
288  # If source is ignore bypass host check and continue through loop
289  if entry.source == SOURCE_IGNORE:
290  continue
291 
292  if await self.hass.async_add_executor_job(
293  _host_is_same, entry.data[CONF_HOST], import_data[CONF_HOST]
294  ):
295  updated_options: dict[str, Any] = {}
296  updated_data: dict[str, Any] = {}
297  remove_apps = False
298 
299  if entry.data[CONF_HOST] != import_data[CONF_HOST]:
300  updated_data[CONF_HOST] = import_data[CONF_HOST]
301 
302  if entry.data[CONF_NAME] != import_data[CONF_NAME]:
303  updated_data[CONF_NAME] = import_data[CONF_NAME]
304 
305  # Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and
306  # pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified
307  if entry.data.get(CONF_APPS) != import_data.get(CONF_APPS):
308  if not import_data.get(CONF_APPS):
309  remove_apps = True
310  else:
311  updated_options[CONF_APPS] = import_data[CONF_APPS]
312 
313  if entry.data.get(CONF_VOLUME_STEP) != import_data[CONF_VOLUME_STEP]:
314  updated_options[CONF_VOLUME_STEP] = import_data[CONF_VOLUME_STEP]
315 
316  if updated_options or updated_data or remove_apps:
317  new_data = entry.data.copy()
318  new_options = entry.options.copy()
319 
320  if remove_apps:
321  new_data.pop(CONF_APPS)
322  new_options.pop(CONF_APPS)
323 
324  if updated_data:
325  new_data.update(updated_data)
326 
327  # options are stored in entry options and data so update both
328  if updated_options:
329  new_data.update(updated_options)
330  new_options.update(updated_options)
331 
332  self.hass.config_entries.async_update_entry(
333  entry=entry, data=new_data, options=new_options
334  )
335  return self.async_abortasync_abortasync_abort(reason="updated_entry")
336 
337  return self.async_abortasync_abortasync_abort(reason="already_configured_device")
338 
339  self._must_show_form_must_show_form = True
340  # Store config key/value pairs that are not configurable in user step so they
341  # don't get lost on user step
342  if import_data.get(CONF_APPS):
343  self._apps_apps = copy.deepcopy(import_data[CONF_APPS])
344  return await self.async_step_userasync_step_userasync_step_user(user_input=import_data)
345 
347  self, discovery_info: zeroconf.ZeroconfServiceInfo
348  ) -> ConfigFlowResult:
349  """Handle zeroconf discovery."""
350  host = discovery_info.host
351  # If host already has port, no need to add it again
352  if ":" not in host:
353  host = f"{host}:{discovery_info.port}"
354 
355  # Set default name to discovered device name by stripping zeroconf service
356  # (`type`) from `name`
357  num_chars_to_strip = len(discovery_info.type) + 1
358  name = discovery_info.name[:-num_chars_to_strip]
359 
360  device_class = await async_guess_device_type(host)
361 
362  # Set unique ID early for discovery flow so we can abort if needed
363  unique_id = await VizioAsync.get_unique_id(
364  host,
365  device_class,
366  session=async_get_clientsession(self.hass, False),
367  )
368 
369  if not unique_id:
370  return self.async_abortasync_abortasync_abort(reason="cannot_connect")
371 
372  await self.async_set_unique_idasync_set_unique_id(unique_id=unique_id, raise_on_progress=True)
373  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
374 
375  # Form must be shown after discovery so user can confirm/update configuration
376  # before ConfigEntry creation.
377  self._must_show_form_must_show_form = True
378  return await self.async_step_userasync_step_userasync_step_user(
379  user_input={
380  CONF_HOST: host,
381  CONF_NAME: name,
382  CONF_DEVICE_CLASS: device_class,
383  }
384  )
385 
387  self, user_input: dict[str, Any] | None = None
388  ) -> ConfigFlowResult:
389  """Start pairing process for TV.
390 
391  Ask user for PIN to complete pairing process.
392  """
393  errors: dict[str, str] = {}
394  assert self._data_data
395 
396  # Start pairing process if it hasn't already started
397  if not self._ch_type_ch_type and not self._pairing_token_pairing_token:
398  dev = VizioAsync(
399  DEVICE_ID,
400  self._data_data[CONF_HOST],
401  self._data_data[CONF_NAME],
402  None,
403  self._data_data[CONF_DEVICE_CLASS],
404  session=async_get_clientsession(self.hass, False),
405  )
406  pair_data = await dev.start_pair()
407 
408  if pair_data:
409  self._ch_type_ch_type = pair_data.ch_type
410  self._pairing_token_pairing_token = pair_data.token
411  return await self.async_step_pair_tvasync_step_pair_tv()
412 
413  return self.async_show_formasync_show_formasync_show_form(
414  step_id="user",
415  data_schema=_get_config_schema(self._data_data),
416  errors={"base": "cannot_connect"},
417  )
418 
419  # Complete pairing process if PIN has been provided
420  if user_input and user_input.get(CONF_PIN):
421  dev = VizioAsync(
422  DEVICE_ID,
423  self._data_data[CONF_HOST],
424  self._data_data[CONF_NAME],
425  None,
426  self._data_data[CONF_DEVICE_CLASS],
427  session=async_get_clientsession(self.hass, False),
428  )
429  pair_data = await dev.pair(
430  self._ch_type_ch_type, self._pairing_token_pairing_token, user_input[CONF_PIN]
431  )
432 
433  if pair_data:
434  self._data_data[CONF_ACCESS_TOKEN] = pair_data.auth_token
435  self._must_show_form_must_show_form = True
436 
437  if self.context["source"] == SOURCE_IMPORT:
438  # If user is pairing via config import, show different message
439  return await self.async_step_pairing_complete_importasync_step_pairing_complete_import()
440 
441  return await self.async_step_pairing_completeasync_step_pairing_complete()
442 
443  # If no data was retrieved, it's assumed that the pairing attempt was not
444  # successful
445  errors[CONF_PIN] = "complete_pairing_failed"
446 
447  return self.async_show_formasync_show_formasync_show_form(
448  step_id="pair_tv",
449  data_schema=_get_pairing_schema(user_input),
450  errors=errors,
451  )
452 
453  async def _pairing_complete(self, step_id: str) -> ConfigFlowResult:
454  """Handle config flow completion."""
455  assert self._data_data
456  if not self._must_show_form_must_show_form:
457  return await self._create_entry_create_entry(self._data_data)
458 
459  self._must_show_form_must_show_form = False
460  return self.async_show_formasync_show_formasync_show_form(
461  step_id=step_id,
462  description_placeholders={"access_token": self._data_data[CONF_ACCESS_TOKEN]},
463  )
464 
466  self, user_input: dict[str, Any] | None = None
467  ) -> ConfigFlowResult:
468  """Complete non-import sourced config flow.
469 
470  Display final message to user confirming pairing.
471  """
472  return await self._pairing_complete_pairing_complete("pairing_complete")
473 
475  self, user_input: dict[str, Any] | None = None
476  ) -> ConfigFlowResult:
477  """Complete import sourced config flow.
478 
479  Display final message to user confirming pairing and displaying
480  access token.
481  """
482  return await self._pairing_complete_pairing_complete("pairing_complete_import")
ConfigFlowResult async_step_import(self, dict[str, Any] import_data)
Definition: config_flow.py:284
ConfigFlowResult async_step_pairing_complete_import(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:476
ConfigFlowResult async_step_zeroconf(self, zeroconf.ZeroconfServiceInfo discovery_info)
Definition: config_flow.py:348
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:207
ConfigFlowResult _create_entry(self, dict[str, Any] input_dict)
Definition: config_flow.py:194
ConfigFlowResult async_step_pair_tv(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:388
VizioOptionsConfigFlow async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:181
ConfigFlowResult async_step_pairing_complete(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:467
ConfigFlowResult _pairing_complete(self, str step_id)
Definition: config_flow.py:453
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:113
None _abort_if_unique_id_configured(self, dict[str, Any]|None updates=None, bool reload_on_update=True, *str error="already_configured")
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
None config_entry(self, ConfigEntry value)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
vol.Schema _get_pairing_schema(dict[str, Any]|None input_dict=None)
Definition: config_flow.py:85
bool _host_is_same(str host1, str host2)
Definition: config_flow.py:99
vol.Schema _get_config_schema(dict[str, Any]|None input_dict=None)
Definition: config_flow.py:54
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
bool is_ip_address(str address)
Definition: network.py:63