Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for generic (IP Camera)."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Mapping
7 import contextlib
8 from datetime import datetime
9 from errno import EHOSTUNREACH, EIO
10 import io
11 import logging
12 from typing import Any, cast
13 
14 from aiohttp import web
15 from httpx import HTTPStatusError, RequestError, TimeoutException
16 import PIL.Image
17 import voluptuous as vol
18 import yarl
19 
21  CAMERA_IMAGE_TIMEOUT,
22  DynamicStreamSettings,
23  _async_get_image,
24 )
25 from homeassistant.components.http import HomeAssistantView
27  CONF_RTSP_TRANSPORT,
28  CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
29  HLS_PROVIDER,
30  RTSP_TRANSPORTS,
31  SOURCE_TIMEOUT,
32  create_stream,
33 )
34 from homeassistant.config_entries import (
35  ConfigEntry,
36  ConfigFlow,
37  ConfigFlowResult,
38  OptionsFlow,
39 )
40 from homeassistant.const import (
41  CONF_AUTHENTICATION,
42  CONF_NAME,
43  CONF_PASSWORD,
44  CONF_USERNAME,
45  CONF_VERIFY_SSL,
46  HTTP_BASIC_AUTHENTICATION,
47  HTTP_DIGEST_AUTHENTICATION,
48 )
49 from homeassistant.core import HomeAssistant
50 from homeassistant.exceptions import HomeAssistantError, TemplateError
51 from homeassistant.helpers import config_validation as cv, template as template_helper
52 from homeassistant.helpers.httpx_client import get_async_client
53 from homeassistant.util import slugify
54 
55 from .camera import GenericCamera, generate_auth
56 from .const import (
57  CONF_CONFIRMED_OK,
58  CONF_CONTENT_TYPE,
59  CONF_FRAMERATE,
60  CONF_LIMIT_REFETCH_TO_URL_CHANGE,
61  CONF_STILL_IMAGE_URL,
62  CONF_STREAM_SOURCE,
63  DEFAULT_NAME,
64  DOMAIN,
65  GET_IMAGE_TIMEOUT,
66 )
67 
68 _LOGGER = logging.getLogger(__name__)
69 
70 DEFAULT_DATA = {
71  CONF_NAME: DEFAULT_NAME,
72  CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
73  CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
74  CONF_FRAMERATE: 2,
75  CONF_VERIFY_SSL: True,
76 }
77 
78 SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
79 IMAGE_PREVIEWS_ACTIVE = "previews"
80 
81 
83  user_input: Mapping[str, Any],
84  is_options_flow: bool = False,
85  show_advanced_options: bool = False,
86 ) -> vol.Schema:
87  """Create schema for camera config setup."""
88  spec = {
89  vol.Optional(
90  CONF_STILL_IMAGE_URL,
91  description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
92  ): str,
93  vol.Optional(
94  CONF_STREAM_SOURCE,
95  description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")},
96  ): str,
97  vol.Optional(
98  CONF_RTSP_TRANSPORT,
99  description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
100  ): vol.In(RTSP_TRANSPORTS),
101  vol.Optional(
102  CONF_AUTHENTICATION,
103  description={"suggested_value": user_input.get(CONF_AUTHENTICATION)},
104  ): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
105  vol.Optional(
106  CONF_USERNAME,
107  description={"suggested_value": user_input.get(CONF_USERNAME, "")},
108  ): str,
109  vol.Optional(
110  CONF_PASSWORD,
111  description={"suggested_value": user_input.get(CONF_PASSWORD, "")},
112  ): str,
113  vol.Required(
114  CONF_FRAMERATE,
115  description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
116  ): vol.All(vol.Range(min=0, min_included=False), cv.positive_float),
117  vol.Required(
118  CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
119  ): bool,
120  }
121  if is_options_flow:
122  spec[
123  vol.Required(
124  CONF_LIMIT_REFETCH_TO_URL_CHANGE,
125  default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
126  )
127  ] = bool
128  if show_advanced_options:
129  spec[
130  vol.Required(
131  CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
132  default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False),
133  )
134  ] = bool
135  return vol.Schema(spec)
136 
137 
138 def get_image_type(image: bytes) -> str | None:
139  """Get the format of downloaded bytes that could be an image."""
140  fmt = None
141  imagefile = io.BytesIO(image)
142  with contextlib.suppress(PIL.UnidentifiedImageError):
143  img = PIL.Image.open(imagefile)
144  fmt = img.format.lower() if img.format else None
145 
146  if fmt is None:
147  # if PIL can't figure it out, could be svg.
148  with contextlib.suppress(UnicodeDecodeError):
149  if image.decode("utf-8").lstrip().startswith("<svg"):
150  return "svg+xml"
151  return fmt
152 
153 
155  hass: HomeAssistant, info: Mapping[str, Any]
156 ) -> tuple[dict[str, str], str | None]:
157  """Verify that the still image is valid before we create an entity."""
158  fmt = None
159  if not (url := info.get(CONF_STILL_IMAGE_URL)):
160  return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg")
161  try:
162  if not isinstance(url, template_helper.Template):
163  url = template_helper.Template(url, hass)
164  url = url.async_render(parse_result=False)
165  except TemplateError as err:
166  _LOGGER.warning("Problem rendering template %s: %s", url, err)
167  return {CONF_STILL_IMAGE_URL: "template_error"}, None
168  try:
169  yarl_url = yarl.URL(url)
170  except ValueError:
171  return {CONF_STILL_IMAGE_URL: "malformed_url"}, None
172  if not yarl_url.is_absolute():
173  return {CONF_STILL_IMAGE_URL: "relative_url"}, None
174  verify_ssl = info[CONF_VERIFY_SSL]
175  auth = generate_auth(info)
176  try:
177  async_client = get_async_client(hass, verify_ssl=verify_ssl)
178  async with asyncio.timeout(GET_IMAGE_TIMEOUT):
179  response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT)
180  response.raise_for_status()
181  image = response.content
182  except (
183  TimeoutError,
184  RequestError,
185  TimeoutException,
186  ) as err:
187  _LOGGER.error("Error getting camera image from %s: %s", url, type(err).__name__)
188  return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None
189  except HTTPStatusError as err:
190  _LOGGER.error(
191  "Error getting camera image from %s: %s %s",
192  url,
193  type(err).__name__,
194  err.response.text,
195  )
196  if err.response.status_code in [401, 403]:
197  return {CONF_STILL_IMAGE_URL: "unable_still_load_auth"}, None
198  if err.response.status_code in [404]:
199  return {CONF_STILL_IMAGE_URL: "unable_still_load_not_found"}, None
200  if err.response.status_code in [500, 503]:
201  return {CONF_STILL_IMAGE_URL: "unable_still_load_server_error"}, None
202  return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None
203 
204  if not image:
205  return {CONF_STILL_IMAGE_URL: "unable_still_load_no_image"}, None
206  fmt = get_image_type(image)
207  _LOGGER.debug(
208  "Still image at '%s' detected format: %s",
209  info[CONF_STILL_IMAGE_URL],
210  fmt,
211  )
212  if fmt not in SUPPORTED_IMAGE_TYPES:
213  return {CONF_STILL_IMAGE_URL: "invalid_still_image"}, None
214  return {}, f"image/{fmt}"
215 
216 
217 def slug(
218  hass: HomeAssistant, template: str | template_helper.Template | None
219 ) -> str | None:
220  """Convert a camera url into a string suitable for a camera name."""
221  url = ""
222  if not template:
223  return None
224  if not isinstance(template, template_helper.Template):
225  template = template_helper.Template(template, hass)
226  try:
227  url = template.async_render(parse_result=False)
228  return slugify(yarl.URL(url).host)
229  except (ValueError, TemplateError, TypeError) as err:
230  _LOGGER.error("Syntax error in '%s': %s", template, err)
231  return None
232 
233 
235  hass: HomeAssistant, info: Mapping[str, Any]
236 ) -> dict[str, str]:
237  """Verify that the stream is valid before we create an entity."""
238  if not (stream_source := info.get(CONF_STREAM_SOURCE)):
239  return {}
240  # Import from stream.worker as stream cannot reexport from worker
241  # without forcing the av dependency on default_config
242  # pylint: disable-next=import-outside-toplevel
243  from homeassistant.components.stream.worker import StreamWorkerError
244 
245  if not isinstance(stream_source, template_helper.Template):
246  stream_source = template_helper.Template(stream_source, hass)
247  try:
248  stream_source = stream_source.async_render(parse_result=False)
249  except TemplateError as err:
250  _LOGGER.warning("Problem rendering template %s: %s", stream_source, err)
251  return {CONF_STREAM_SOURCE: "template_error"}
252  stream_options: dict[str, str | bool | float] = {}
253  if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
254  stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
255  if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS):
256  stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True
257 
258  try:
259  url = yarl.URL(stream_source)
260  except ValueError:
261  return {CONF_STREAM_SOURCE: "malformed_url"}
262  if not url.is_absolute():
263  return {CONF_STREAM_SOURCE: "relative_url"}
264  if not url.user and not url.password:
265  username = info.get(CONF_USERNAME)
266  password = info.get(CONF_PASSWORD)
267  if username and password:
268  url = url.with_user(username).with_password(password)
269  stream_source = str(url)
270  try:
271  stream = create_stream(
272  hass,
273  stream_source,
274  stream_options,
276  "test_stream",
277  )
278  hls_provider = stream.add_provider(HLS_PROVIDER)
279  await stream.start()
280  if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
281  hass.async_create_task(stream.stop())
282  return {CONF_STREAM_SOURCE: "timeout"}
283  await stream.stop()
284  except StreamWorkerError as err:
285  return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
286  except PermissionError:
287  return {CONF_STREAM_SOURCE: "stream_not_permitted"}
288  except OSError as err:
289  if err.errno == EHOSTUNREACH:
290  return {CONF_STREAM_SOURCE: "stream_no_route_to_host"}
291  if err.errno == EIO: # input/output error
292  return {CONF_STREAM_SOURCE: "stream_io_error"}
293  raise
294  except HomeAssistantError as err:
295  if "Stream integration is not set up" in str(err):
296  return {CONF_STREAM_SOURCE: "stream_not_set_up"}
297  raise
298  return {}
299 
300 
301 def register_preview(hass: HomeAssistant) -> None:
302  """Set up previews for camera feeds during config flow."""
303  hass.data.setdefault(DOMAIN, {})
304 
305  if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE):
306  _LOGGER.debug("Registering camera image preview handler")
307  hass.http.register_view(CameraImagePreview(hass))
308  hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] = True
309 
310 
311 class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
312  """Config flow for generic IP camera."""
313 
314  VERSION = 1
315 
316  def __init__(self) -> None:
317  """Initialize Generic ConfigFlow."""
318  self.preview_campreview_cam: dict[str, Any] = {}
319  self.user_inputuser_input: dict[str, Any] = {}
320  self.titletitle = ""
321 
322  @staticmethod
324  config_entry: ConfigEntry,
325  ) -> GenericOptionsFlowHandler:
326  """Get the options flow for this handler."""
328 
329  def check_for_existing(self, options: dict[str, Any]) -> bool:
330  """Check whether an existing entry is using the same URLs."""
331  return any(
332  entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL)
333  and entry.options.get(CONF_STREAM_SOURCE) == options.get(CONF_STREAM_SOURCE)
334  for entry in self._async_current_entries_async_current_entries()
335  )
336 
337  async def async_step_user(
338  self, user_input: dict[str, Any] | None = None
339  ) -> ConfigFlowResult:
340  """Handle the start of the config flow."""
341  errors = {}
342  description_placeholders = {}
343  hass = self.hass
344  if user_input:
345  # Secondary validation because serialised vol can't seem to handle this complexity:
346  if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
347  CONF_STREAM_SOURCE
348  ):
349  errors["base"] = "no_still_image_or_stream_url"
350  else:
351  errors, still_format = await async_test_still(hass, user_input)
352  errors = errors | await async_test_stream(hass, user_input)
353  if not errors:
354  user_input[CONF_CONTENT_TYPE] = still_format
355  user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
356  still_url = user_input.get(CONF_STILL_IMAGE_URL)
357  stream_url = user_input.get(CONF_STREAM_SOURCE)
358  name = (
359  slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME
360  )
361  if still_url is None:
362  # If user didn't specify a still image URL,
363  # The automatically generated still image that stream generates
364  # is always jpeg
365  user_input[CONF_CONTENT_TYPE] = "image/jpeg"
366  self.user_inputuser_input = user_input
367  self.titletitle = name
368 
369  if still_url is None:
370  return self.async_create_entryasync_create_entryasync_create_entry(
371  title=self.titletitle, data={}, options=self.user_inputuser_input
372  )
373  # temporary preview for user to check the image
374  self.preview_campreview_cam = user_input
375  return await self.async_step_user_confirm_stillasync_step_user_confirm_still()
376  if "error_details" in errors:
377  description_placeholders["error"] = errors.pop("error_details")
378  elif self.user_inputuser_input:
379  user_input = self.user_inputuser_input
380  else:
381  user_input = DEFAULT_DATA.copy()
382  return self.async_show_formasync_show_formasync_show_form(
383  step_id="user",
384  data_schema=build_schema(user_input),
385  description_placeholders=description_placeholders,
386  errors=errors,
387  )
388 
390  self, user_input: dict[str, Any] | None = None
391  ) -> ConfigFlowResult:
392  """Handle user clicking confirm after still preview."""
393  if user_input:
394  if not user_input.get(CONF_CONFIRMED_OK):
395  return await self.async_step_userasync_step_userasync_step_user()
396  return self.async_create_entryasync_create_entryasync_create_entry(
397  title=self.titletitle, data={}, options=self.user_inputuser_input
398  )
399  register_preview(self.hass)
400  preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
401  return self.async_show_formasync_show_formasync_show_form(
402  step_id="user_confirm_still",
403  data_schema=vol.Schema(
404  {
405  vol.Required(CONF_CONFIRMED_OK, default=False): bool,
406  }
407  ),
408  description_placeholders={"preview_url": preview_url},
409  errors=None,
410  )
411 
412 
414  """Handle Generic IP Camera options."""
415 
416  def __init__(self) -> None:
417  """Initialize Generic IP Camera options flow."""
418  self.preview_campreview_cam: dict[str, Any] = {}
419  self.user_inputuser_input: dict[str, Any] = {}
420 
421  async def async_step_init(
422  self, user_input: dict[str, Any] | None = None
423  ) -> ConfigFlowResult:
424  """Manage Generic IP Camera options."""
425  errors: dict[str, str] = {}
426  hass = self.hass
427 
428  if user_input is not None:
429  errors, still_format = await async_test_still(
430  hass, self.config_entryconfig_entryconfig_entry.options | user_input
431  )
432  errors = errors | await async_test_stream(hass, user_input)
433  still_url = user_input.get(CONF_STILL_IMAGE_URL)
434  if not errors:
435  if still_url is None:
436  # If user didn't specify a still image URL,
437  # The automatically generated still image that stream generates
438  # is always jpeg
439  still_format = "image/jpeg"
440  data = {
441  CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entryconfig_entryconfig_entry.options.get(
442  CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
443  ),
444  **user_input,
445  CONF_CONTENT_TYPE: still_format
446  or self.config_entryconfig_entryconfig_entry.options.get(CONF_CONTENT_TYPE),
447  }
448  self.user_inputuser_input = data
449  # temporary preview for user to check the image
450  self.preview_campreview_cam = data
451  return await self.async_step_confirm_stillasync_step_confirm_still()
452  return self.async_show_formasync_show_form(
453  step_id="init",
454  data_schema=build_schema(
455  user_input or self.config_entryconfig_entryconfig_entry.options,
456  True,
457  self.show_advanced_optionsshow_advanced_options,
458  ),
459  errors=errors,
460  )
461 
463  self, user_input: dict[str, Any] | None = None
464  ) -> ConfigFlowResult:
465  """Handle user clicking confirm after still preview."""
466  if user_input:
467  if not user_input.get(CONF_CONFIRMED_OK):
468  return await self.async_step_initasync_step_init()
469  return self.async_create_entryasync_create_entry(
470  title=self.config_entryconfig_entryconfig_entry.title,
471  data=self.user_inputuser_input,
472  )
473  register_preview(self.hass)
474  preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
475  return self.async_show_formasync_show_form(
476  step_id="confirm_still",
477  data_schema=vol.Schema(
478  {
479  vol.Required(CONF_CONFIRMED_OK, default=False): bool,
480  }
481  ),
482  description_placeholders={"preview_url": preview_url},
483  errors=None,
484  )
485 
486 
487 class CameraImagePreview(HomeAssistantView):
488  """Camera view to temporarily serve an image."""
489 
490  url = "/api/generic/preview_flow_image/{flow_id}"
491  name = "api:generic:preview_flow_image"
492  requires_auth = False
493 
494  def __init__(self, hass: HomeAssistant) -> None:
495  """Initialise."""
496  self.hasshass = hass
497 
498  async def get(self, request: web.Request, flow_id: str) -> web.Response:
499  """Start a GET request."""
500  _LOGGER.debug("processing GET request for flow_id=%s", flow_id)
501  flow = cast(
502  GenericIPCamConfigFlow,
503  self.hasshass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
504  ) or cast(
505  GenericOptionsFlowHandler,
506  self.hasshass.config_entries.options._progress.get(flow_id), # noqa: SLF001
507  )
508  if not flow:
509  _LOGGER.warning("Unknown flow while getting image preview")
510  raise web.HTTPNotFound
511  user_input = flow.preview_cam
512  camera = GenericCamera(self.hasshass, user_input, flow_id, "preview")
513  if not camera.is_on:
514  _LOGGER.debug("Camera is off")
515  raise web.HTTPServiceUnavailable
516  image = await _async_get_image(
517  camera,
518  CAMERA_IMAGE_TIMEOUT,
519  )
520  return web.Response(body=image.content, content_type=image.content_type)
web.Response get(self, web.Request request, str flow_id)
Definition: config_flow.py:498
ConfigFlowResult async_step_user_confirm_still(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:391
GenericOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
Definition: config_flow.py:325
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:339
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:423
ConfigFlowResult async_step_confirm_still(self, dict[str, Any]|None user_input=None)
Definition: config_flow.py:464
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_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)
bool show_advanced_options(self)
_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)
Image _async_get_image(Camera camera, int timeout=10, int|None width=None, int|None height=None)
Definition: __init__.py:201
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
httpx.Auth|None generate_auth(Mapping[str, Any] device_info)
Definition: camera.py:59
tuple[dict[str, str], str|None] async_test_still(HomeAssistant hass, Mapping[str, Any] info)
Definition: config_flow.py:156
None register_preview(HomeAssistant hass)
Definition: config_flow.py:301
dict[str, str] async_test_stream(HomeAssistant hass, Mapping[str, Any] info)
Definition: config_flow.py:236
vol.Schema build_schema(Mapping[str, Any] user_input, bool is_options_flow=False, bool show_advanced_options=False)
Definition: config_flow.py:86
str|None slug(HomeAssistant hass, str|template_helper.Template|None template)
Definition: config_flow.py:219
Stream create_stream(HomeAssistant hass, str stream_source, Mapping[str, str|bool|float] options, DynamicStreamSettings dynamic_stream_settings, str|None stream_label=None)
Definition: __init__.py:117
httpx.AsyncClient get_async_client(HomeAssistant hass, bool verify_ssl=True)
Definition: httpx_client.py:41