1 """Config flow for generic (IP Camera)."""
3 from __future__
import annotations
6 from collections.abc
import Mapping
8 from datetime
import datetime
9 from errno
import EHOSTUNREACH, EIO
12 from typing
import Any, cast
14 from aiohttp
import web
15 from httpx
import HTTPStatusError, RequestError, TimeoutException
17 import voluptuous
as vol
22 DynamicStreamSettings,
28 CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
46 HTTP_BASIC_AUTHENTICATION,
47 HTTP_DIGEST_AUTHENTICATION,
55 from .camera
import GenericCamera, generate_auth
60 CONF_LIMIT_REFETCH_TO_URL_CHANGE,
68 _LOGGER = logging.getLogger(__name__)
71 CONF_NAME: DEFAULT_NAME,
72 CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
73 CONF_LIMIT_REFETCH_TO_URL_CHANGE:
False,
75 CONF_VERIFY_SSL:
True,
78 SUPPORTED_IMAGE_TYPES = {
"png",
"jpeg",
"gif",
"svg+xml",
"webp"}
79 IMAGE_PREVIEWS_ACTIVE =
"previews"
83 user_input: Mapping[str, Any],
84 is_options_flow: bool =
False,
85 show_advanced_options: bool =
False,
87 """Create schema for camera config setup."""
91 description={
"suggested_value": user_input.get(CONF_STILL_IMAGE_URL,
"")},
95 description={
"suggested_value": user_input.get(CONF_STREAM_SOURCE,
"")},
99 description={
"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
100 ): vol.In(RTSP_TRANSPORTS),
103 description={
"suggested_value": user_input.get(CONF_AUTHENTICATION)},
104 ): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
107 description={
"suggested_value": user_input.get(CONF_USERNAME,
"")},
111 description={
"suggested_value": user_input.get(CONF_PASSWORD,
"")},
115 description={
"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
116 ): vol.All(vol.Range(min=0, min_included=
False), cv.positive_float),
118 CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL,
True)
124 CONF_LIMIT_REFETCH_TO_URL_CHANGE,
125 default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE,
False),
128 if show_advanced_options:
131 CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
132 default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
False),
135 return vol.Schema(spec)
139 """Get the format of downloaded bytes that could be an image."""
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
148 with contextlib.suppress(UnicodeDecodeError):
149 if image.decode(
"utf-8").lstrip().startswith(
"<svg"):
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."""
159 if not (url := info.get(CONF_STILL_IMAGE_URL)):
160 return {}, info.get(CONF_CONTENT_TYPE,
"image/jpeg")
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
169 yarl_url = yarl.URL(url)
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]
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
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:
191 "Error getting camera image from %s: %s %s",
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
205 return {CONF_STILL_IMAGE_URL:
"unable_still_load_no_image"},
None
208 "Still image at '%s' detected format: %s",
209 info[CONF_STILL_IMAGE_URL],
212 if fmt
not in SUPPORTED_IMAGE_TYPES:
213 return {CONF_STILL_IMAGE_URL:
"invalid_still_image"},
None
214 return {}, f
"image/{fmt}"
218 hass: HomeAssistant, template: str | template_helper.Template |
None
220 """Convert a camera url into a string suitable for a camera name."""
224 if not isinstance(template, template_helper.Template):
225 template = template_helper.Template(template, hass)
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)
235 hass: HomeAssistant, info: Mapping[str, Any]
237 """Verify that the stream is valid before we create an entity."""
238 if not (stream_source := info.get(CONF_STREAM_SOURCE)):
245 if not isinstance(stream_source, template_helper.Template):
246 stream_source = template_helper.Template(stream_source, hass)
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
259 url = yarl.URL(stream_source)
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)
278 hls_provider = stream.add_provider(HLS_PROVIDER)
280 if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
281 hass.async_create_task(stream.stop())
282 return {CONF_STREAM_SOURCE:
"timeout"}
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"}
292 return {CONF_STREAM_SOURCE:
"stream_io_error"}
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"}
302 """Set up previews for camera feeds during config flow."""
303 hass.data.setdefault(DOMAIN, {})
305 if not hass.data[DOMAIN].
get(IMAGE_PREVIEWS_ACTIVE):
306 _LOGGER.debug(
"Registering camera image preview handler")
308 hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] =
True
312 """Config flow for generic IP camera."""
317 """Initialize Generic ConfigFlow."""
319 self.
user_inputuser_input: dict[str, Any] = {}
324 config_entry: ConfigEntry,
325 ) -> GenericOptionsFlowHandler:
326 """Get the options flow for this handler."""
330 """Check whether an existing entry is using the same URLs."""
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)
338 self, user_input: dict[str, Any] |
None =
None
339 ) -> ConfigFlowResult:
340 """Handle the start of the config flow."""
342 description_placeholders = {}
346 if not user_input.get(CONF_STILL_IMAGE_URL)
and not user_input.get(
349 errors[
"base"] =
"no_still_image_or_stream_url"
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)
359 slug(hass, still_url)
or slug(hass, stream_url)
or DEFAULT_NAME
361 if still_url
is None:
365 user_input[CONF_CONTENT_TYPE] =
"image/jpeg"
367 self.
titletitle = name
369 if still_url
is None:
376 if "error_details" in errors:
377 description_placeholders[
"error"] = errors.pop(
"error_details")
381 user_input = DEFAULT_DATA.copy()
385 description_placeholders=description_placeholders,
390 self, user_input: dict[str, Any] |
None =
None
391 ) -> ConfigFlowResult:
392 """Handle user clicking confirm after still preview."""
394 if not user_input.get(CONF_CONFIRMED_OK):
400 preview_url = f
"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
402 step_id=
"user_confirm_still",
403 data_schema=vol.Schema(
405 vol.Required(CONF_CONFIRMED_OK, default=
False): bool,
408 description_placeholders={
"preview_url": preview_url},
414 """Handle Generic IP Camera options."""
417 """Initialize Generic IP Camera options flow."""
419 self.
user_inputuser_input: dict[str, Any] = {}
422 self, user_input: dict[str, Any] |
None =
None
423 ) -> ConfigFlowResult:
424 """Manage Generic IP Camera options."""
425 errors: dict[str, str] = {}
428 if user_input
is not None:
433 still_url = user_input.get(CONF_STILL_IMAGE_URL)
435 if still_url
is None:
439 still_format =
"image/jpeg"
442 CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
False
445 CONF_CONTENT_TYPE: still_format
463 self, user_input: dict[str, Any] |
None =
None
464 ) -> ConfigFlowResult:
465 """Handle user clicking confirm after still preview."""
467 if not user_input.get(CONF_CONFIRMED_OK):
474 preview_url = f
"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
476 step_id=
"confirm_still",
477 data_schema=vol.Schema(
479 vol.Required(CONF_CONFIRMED_OK, default=
False): bool,
482 description_placeholders={
"preview_url": preview_url},
488 """Camera view to temporarily serve an image."""
490 url =
"/api/generic/preview_flow_image/{flow_id}"
491 name =
"api:generic:preview_flow_image"
492 requires_auth =
False
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)
502 GenericIPCamConfigFlow,
503 self.
hasshass.config_entries.flow._progress.get(flow_id),
505 GenericOptionsFlowHandler,
506 self.
hasshass.config_entries.options._progress.get(flow_id),
509 _LOGGER.warning(
"Unknown flow while getting image preview")
510 raise web.HTTPNotFound
511 user_input = flow.preview_cam
514 _LOGGER.debug(
"Camera is off")
515 raise web.HTTPServiceUnavailable
518 CAMERA_IMAGE_TIMEOUT,
520 return web.Response(body=image.content, content_type=image.content_type)
web.Response get(self, web.Request request, str flow_id)
None __init__(self, HomeAssistant hass)
ConfigFlowResult async_step_user_confirm_still(self, dict[str, Any]|None user_input=None)
bool check_for_existing(self, dict[str, Any] options)
GenericOptionsFlowHandler async_get_options_flow(ConfigEntry config_entry)
ConfigFlowResult async_step_user(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_init(self, dict[str, Any]|None user_input=None)
ConfigFlowResult async_step_confirm_still(self, dict[str, Any]|None user_input=None)
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)
ConfigEntry config_entry(self)
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)
web.Response get(self, web.Request request, str config_key)
httpx.Auth|None generate_auth(Mapping[str, Any] device_info)
str|None get_image_type(bytes image)
tuple[dict[str, str], str|None] async_test_still(HomeAssistant hass, Mapping[str, Any] info)
None register_preview(HomeAssistant hass)
dict[str, str] async_test_stream(HomeAssistant hass, Mapping[str, Any] info)
vol.Schema build_schema(Mapping[str, Any] user_input, bool is_options_flow=False, bool show_advanced_options=False)
str|None slug(HomeAssistant hass, str|template_helper.Template|None template)
Stream create_stream(HomeAssistant hass, str stream_source, Mapping[str, str|bool|float] options, DynamicStreamSettings dynamic_stream_settings, str|None stream_label=None)
httpx.AsyncClient get_async_client(HomeAssistant hass, bool verify_ssl=True)