Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for EZVIZ."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 import logging
7 from typing import TYPE_CHECKING, Any
8 
9 from pyezviz.client import EzvizClient
10 from pyezviz.exceptions import (
11  AuthTestResultFailed,
12  EzvizAuthVerificationCode,
13  InvalidHost,
14  InvalidURL,
15  PyEzvizError,
16 )
17 from pyezviz.test_cam_rtsp import TestRTSPAuth
18 import voluptuous as vol
19 
20 from homeassistant.config_entries import (
21  ConfigEntry,
22  ConfigFlow,
23  ConfigFlowResult,
24  OptionsFlow,
25 )
26 from homeassistant.const import (
27  CONF_CUSTOMIZE,
28  CONF_IP_ADDRESS,
29  CONF_PASSWORD,
30  CONF_TIMEOUT,
31  CONF_TYPE,
32  CONF_URL,
33  CONF_USERNAME,
34 )
35 from homeassistant.core import callback
36 
37 from .const import (
38  ATTR_SERIAL,
39  ATTR_TYPE_CAMERA,
40  ATTR_TYPE_CLOUD,
41  CONF_FFMPEG_ARGUMENTS,
42  CONF_RFSESSION_ID,
43  CONF_SESSION_ID,
44  DEFAULT_CAMERA_USERNAME,
45  DEFAULT_FFMPEG_ARGUMENTS,
46  DEFAULT_TIMEOUT,
47  DOMAIN,
48  EU_URL,
49  RUSSIA_URL,
50 )
51 
52 _LOGGER = logging.getLogger(__name__)
53 DEFAULT_OPTIONS = {
54  CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS,
55  CONF_TIMEOUT: DEFAULT_TIMEOUT,
56 }
57 
58 
59 def _validate_and_create_auth(data: dict) -> dict[str, Any]:
60  """Try to login to EZVIZ cloud account and return token."""
61  # Verify cloud credentials by attempting a login request with username and password.
62  # Return login token.
63 
64  ezviz_client = EzvizClient(
65  data[CONF_USERNAME],
66  data[CONF_PASSWORD],
67  data[CONF_URL],
68  data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
69  )
70 
71  ezviz_token = ezviz_client.login()
72 
73  return {
74  CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID],
75  CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID],
76  CONF_URL: ezviz_token["api_url"],
77  CONF_TYPE: ATTR_TYPE_CLOUD,
78  }
79 
80 
81 def _test_camera_rtsp_creds(data: dict) -> None:
82  """Try DESCRIBE on RTSP camera with credentials."""
83 
84  test_rtsp = TestRTSPAuth(
85  data[CONF_IP_ADDRESS], data[CONF_USERNAME], data[CONF_PASSWORD]
86  )
87 
88  test_rtsp.main()
89 
90 
91 class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
92  """Handle a config flow for EZVIZ."""
93 
94  VERSION = 1
95 
96  ip_address: str
97  username: str | None
98  password: str | None
99  unique_id: str
100 
101  async def _validate_and_create_camera_rtsp(self, data: dict) -> ConfigFlowResult:
102  """Try DESCRIBE on RTSP camera with credentials."""
103 
104  # Get EZVIZ cloud credentials from config entry
105  ezviz_token = {
106  CONF_SESSION_ID: None,
107  CONF_RFSESSION_ID: None,
108  "api_url": None,
109  }
110  ezviz_timeout = DEFAULT_TIMEOUT
111 
112  for item in self._async_current_entries_async_current_entries():
113  if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
114  ezviz_token = {
115  CONF_SESSION_ID: item.data.get(CONF_SESSION_ID),
116  CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID),
117  "api_url": item.data.get(CONF_URL),
118  }
119  ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
120 
121  # Abort flow if user removed cloud account before adding camera.
122  if ezviz_token.get(CONF_SESSION_ID) is None:
123  return self.async_abortasync_abortasync_abort(reason="ezviz_cloud_account_missing")
124 
125  ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout)
126 
127  # We need to wake hibernating cameras.
128  # First create EZVIZ API instance.
129  await self.hass.async_add_executor_job(ezviz_client.login)
130 
131  # Secondly try to wake hybernating camera.
132  await self.hass.async_add_executor_job(
133  ezviz_client.get_detection_sensibility, data[ATTR_SERIAL]
134  )
135 
136  # Thirdly attempts an authenticated RTSP DESCRIBE request.
137  await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data)
138 
139  return self.async_create_entryasync_create_entryasync_create_entry(
140  title=data[ATTR_SERIAL],
141  data={
142  CONF_USERNAME: data[CONF_USERNAME],
143  CONF_PASSWORD: data[CONF_PASSWORD],
144  CONF_TYPE: ATTR_TYPE_CAMERA,
145  },
146  options=DEFAULT_OPTIONS,
147  )
148 
149  @staticmethod
150  @callback
151  def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler:
152  """Get the options flow for this handler."""
153  return EzvizOptionsFlowHandler()
154 
155  async def async_step_user(
156  self, user_input: dict[str, Any] | None = None
157  ) -> ConfigFlowResult:
158  """Handle a flow initiated by the user."""
159 
160  # Check if EZVIZ cloud account is present in entry config,
161  # abort if already configured.
162  for item in self._async_current_entries_async_current_entries():
163  if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
164  return self.async_abortasync_abortasync_abort(reason="already_configured_account")
165 
166  errors = {}
167  auth_data = {}
168 
169  if user_input is not None:
170  await self.async_set_unique_idasync_set_unique_id(user_input[CONF_USERNAME])
171  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
172 
173  if user_input[CONF_URL] == CONF_CUSTOMIZE:
174  self.usernameusername = user_input[CONF_USERNAME]
175  self.passwordpassword = user_input[CONF_PASSWORD]
176  return await self.async_step_user_custom_urlasync_step_user_custom_url()
177 
178  try:
179  auth_data = await self.hass.async_add_executor_job(
180  _validate_and_create_auth, user_input
181  )
182 
183  except InvalidURL:
184  errors["base"] = "invalid_host"
185 
186  except InvalidHost:
187  errors["base"] = "cannot_connect"
188 
189  except EzvizAuthVerificationCode:
190  errors["base"] = "mfa_required"
191 
192  except PyEzvizError:
193  errors["base"] = "invalid_auth"
194 
195  except Exception:
196  _LOGGER.exception("Unexpected exception")
197  return self.async_abortasync_abortasync_abort(reason="unknown")
198 
199  else:
200  return self.async_create_entryasync_create_entryasync_create_entry(
201  title=user_input[CONF_USERNAME],
202  data=auth_data,
203  options=DEFAULT_OPTIONS,
204  )
205 
206  data_schema = vol.Schema(
207  {
208  vol.Required(CONF_USERNAME): str,
209  vol.Required(CONF_PASSWORD): str,
210  vol.Required(CONF_URL, default=EU_URL): vol.In(
211  [EU_URL, RUSSIA_URL, CONF_CUSTOMIZE]
212  ),
213  }
214  )
215 
216  return self.async_show_formasync_show_formasync_show_form(
217  step_id="user", data_schema=data_schema, errors=errors
218  )
219 
221  self, user_input: dict[str, Any] | None = None
222  ) -> ConfigFlowResult:
223  """Handle a flow initiated by the user for custom region url."""
224  errors = {}
225  auth_data = {}
226 
227  if user_input is not None:
228  user_input[CONF_USERNAME] = self.usernameusername
229  user_input[CONF_PASSWORD] = self.passwordpassword
230 
231  try:
232  auth_data = await self.hass.async_add_executor_job(
233  _validate_and_create_auth, user_input
234  )
235 
236  except InvalidURL:
237  errors["base"] = "invalid_host"
238 
239  except InvalidHost:
240  errors["base"] = "cannot_connect"
241 
242  except EzvizAuthVerificationCode:
243  errors["base"] = "mfa_required"
244 
245  except PyEzvizError:
246  errors["base"] = "invalid_auth"
247 
248  except Exception:
249  _LOGGER.exception("Unexpected exception")
250  return self.async_abortasync_abortasync_abort(reason="unknown")
251 
252  else:
253  return self.async_create_entryasync_create_entryasync_create_entry(
254  title=user_input[CONF_USERNAME],
255  data=auth_data,
256  options=DEFAULT_OPTIONS,
257  )
258 
259  data_schema_custom_url = vol.Schema(
260  {
261  vol.Required(CONF_URL, default=EU_URL): str,
262  }
263  )
264 
265  return self.async_show_formasync_show_formasync_show_form(
266  step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors
267  )
268 
270  self, discovery_info: dict[str, Any]
271  ) -> ConfigFlowResult:
272  """Handle a flow for discovered camera without rtsp config entry."""
273 
274  await self.async_set_unique_idasync_set_unique_id(discovery_info[ATTR_SERIAL])
275  self._abort_if_unique_id_configured_abort_if_unique_id_configured()
276 
277  if TYPE_CHECKING:
278  # A unique ID is passed in via the discovery info
279  assert self.unique_idunique_id is not None
280  self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_idunique_id}
281  self.ip_addressip_address = discovery_info[CONF_IP_ADDRESS]
282 
283  return await self.async_step_confirmasync_step_confirm()
284 
286  self, user_input: dict[str, Any] | None = None
287  ) -> ConfigFlowResult:
288  """Confirm and create entry from discovery step."""
289  errors = {}
290 
291  if user_input is not None:
292  user_input[ATTR_SERIAL] = self.unique_idunique_id
293  user_input[CONF_IP_ADDRESS] = self.ip_addressip_address
294  try:
295  return await self._validate_and_create_camera_rtsp_validate_and_create_camera_rtsp(user_input)
296 
297  except (InvalidHost, InvalidURL):
298  errors["base"] = "invalid_host"
299 
300  except EzvizAuthVerificationCode:
301  errors["base"] = "mfa_required"
302 
303  except (PyEzvizError, AuthTestResultFailed):
304  errors["base"] = "invalid_auth"
305 
306  except Exception:
307  _LOGGER.exception("Unexpected exception")
308  return self.async_abortasync_abortasync_abort(reason="unknown")
309 
310  discovered_camera_schema = vol.Schema(
311  {
312  vol.Required(CONF_USERNAME, default=DEFAULT_CAMERA_USERNAME): str,
313  vol.Required(CONF_PASSWORD): str,
314  }
315  )
316 
317  return self.async_show_formasync_show_formasync_show_form(
318  step_id="confirm",
319  data_schema=discovered_camera_schema,
320  errors=errors,
321  description_placeholders={
322  ATTR_SERIAL: self.unique_idunique_id,
323  CONF_IP_ADDRESS: self.ip_addressip_address,
324  },
325  )
326 
327  async def async_step_reauth(
328  self, entry_data: Mapping[str, Any]
329  ) -> ConfigFlowResult:
330  """Handle a flow for reauthentication with password."""
331 
332  return await self.async_step_reauth_confirmasync_step_reauth_confirm()
333 
335  self, user_input: dict[str, Any] | None = None
336  ) -> ConfigFlowResult:
337  """Handle a Confirm flow for reauthentication with password."""
338  auth_data = {}
339  errors = {}
340  entry = None
341 
342  for item in self._async_current_entries_async_current_entries():
343  if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
344  self.context["title_placeholders"] = {ATTR_SERIAL: item.title}
345  entry = await self.async_set_unique_idasync_set_unique_id(item.title)
346 
347  if not entry:
348  return self.async_abortasync_abortasync_abort(reason="ezviz_cloud_account_missing")
349 
350  if user_input is not None:
351  user_input[CONF_URL] = entry.data[CONF_URL]
352 
353  try:
354  auth_data = await self.hass.async_add_executor_job(
355  _validate_and_create_auth, user_input
356  )
357 
358  except (InvalidHost, InvalidURL):
359  errors["base"] = "invalid_host"
360 
361  except EzvizAuthVerificationCode:
362  errors["base"] = "mfa_required"
363 
364  except (PyEzvizError, AuthTestResultFailed):
365  errors["base"] = "invalid_auth"
366 
367  except Exception:
368  _LOGGER.exception("Unexpected exception")
369  return self.async_abortasync_abortasync_abort(reason="unknown")
370 
371  else:
372  return self.async_update_reload_and_abortasync_update_reload_and_abort(
373  entry,
374  data=auth_data,
375  )
376 
377  data_schema = vol.Schema(
378  {
379  vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]),
380  vol.Required(CONF_PASSWORD): str,
381  }
382  )
383 
384  return self.async_show_formasync_show_formasync_show_form(
385  step_id="reauth_confirm",
386  data_schema=data_schema,
387  errors=errors,
388  )
389 
390 
392  """Handle EZVIZ client options."""
393 
394  async def async_step_init(
395  self, user_input: dict[str, Any] | None = None
396  ) -> ConfigFlowResult:
397  """Manage EZVIZ options."""
398  if user_input is not None:
399  return self.async_create_entryasync_create_entry(title="", data=user_input)
400 
401  options = vol.Schema(
402  {
403  vol.Optional(
404  CONF_TIMEOUT,
405  default=self.config_entryconfig_entryconfig_entry.options.get(
406  CONF_TIMEOUT, DEFAULT_TIMEOUT
407  ),
408  ): int,
409  vol.Optional(
410  CONF_FFMPEG_ARGUMENTS,
411  default=self.config_entryconfig_entryconfig_entry.options.get(
412  CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
413  ),
414  ): str,
415  }
416  )
417 
418  return self.async_show_formasync_show_form(step_id="init", data_schema=options)
ConfigFlowResult async_step_reauth_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:336
ConfigFlowResult async_step_reauth(self, Mapping[str, Any] entry_data)
Definition: config_flow.py:329
ConfigFlowResult async_step_confirm(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:287
ConfigFlowResult async_step_user_custom_url(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:222
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:157
EzvizOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:151
ConfigFlowResult _validate_and_create_camera_rtsp(self, dict data)
Definition: config_flow.py:101
ConfigFlowResult async_step_integration_discovery(self, dict[str, Any] discovery_info)
Definition: config_flow.py:271
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:396
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_update_reload_and_abort(self, ConfigEntry entry, *str|None|UndefinedType unique_id=UNDEFINED, str|UndefinedType title=UNDEFINED, Mapping[str, Any]|UndefinedType data=UNDEFINED, Mapping[str, Any]|UndefinedType data_updates=UNDEFINED, Mapping[str, Any]|UndefinedType options=UNDEFINED, str|UndefinedType reason=UNDEFINED, bool reload_even_if_entry_is_unchanged=True)
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)
dict[str, Any] _validate_and_create_auth(dict data)
Definition: config_flow.py:59