"""Config flow for generic (IP Camera).""" from __future__ import annotations import asyncio from collections.abc import Mapping import contextlib from datetime import datetime, timedelta from errno import EHOSTUNREACH, EIO import io import logging from typing import Any, cast from aiohttp import web from httpx import HTTPStatusError, RequestError, TimeoutException import PIL.Image import voluptuous as vol import yarl from homeassistant.components import websocket_api from homeassistant.components.camera import ( CAMERA_IMAGE_TIMEOUT, DOMAIN as CAMERA_DOMAIN, DynamicStreamSettings, _async_get_image, ) from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, HLS_PROVIDER, RTSP_TRANSPORTS, SOURCE_TIMEOUT, Stream, create_stream, ) from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, ) from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.httpx_client import get_async_client from homeassistant.setup import async_prepare_setup_platform from homeassistant.util import slugify from .camera import GenericCamera, generate_auth from .const import ( CONF_CONFIRMED_OK, CONF_CONTENT_TYPE, CONF_FRAMERATE, CONF_LIMIT_REFETCH_TO_URL_CHANGE, CONF_STILL_IMAGE_URL, CONF_STREAM_SOURCE, DEFAULT_NAME, DOMAIN, GET_IMAGE_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) DEFAULT_DATA = { CONF_NAME: DEFAULT_NAME, CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_FRAMERATE: 2, CONF_VERIFY_SSL: True, } SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} IMAGE_PREVIEWS_ACTIVE = "previews" class InvalidStreamException(HomeAssistantError): """Error to indicate an invalid stream.""" def __init__(self, error: str, details: str | None = None) -> None: """Initialize the error.""" super().__init__(error) self.details = details def build_schema( user_input: Mapping[str, Any], is_options_flow: bool = False, show_advanced_options: bool = False, ) -> vol.Schema: """Create schema for camera config setup.""" spec = { vol.Optional( CONF_STILL_IMAGE_URL, description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")}, ): str, vol.Optional( CONF_STREAM_SOURCE, description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")}, ): str, vol.Optional( CONF_RTSP_TRANSPORT, description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)}, ): vol.In(RTSP_TRANSPORTS), vol.Optional( CONF_AUTHENTICATION, description={"suggested_value": user_input.get(CONF_AUTHENTICATION)}, ): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional( CONF_USERNAME, description={"suggested_value": user_input.get(CONF_USERNAME, "")}, ): str, vol.Optional( CONF_PASSWORD, description={"suggested_value": user_input.get(CONF_PASSWORD, "")}, ): str, vol.Required( CONF_FRAMERATE, description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)}, ): vol.All(vol.Range(min=0, min_included=False), cv.positive_float), vol.Required( CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True) ): bool, } if is_options_flow: spec[ vol.Required( CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False), ) ] = bool if show_advanced_options: spec[ vol.Required( CONF_USE_WALLCLOCK_AS_TIMESTAMPS, default=user_input.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), ) ] = bool return vol.Schema(spec) def get_image_type(image: bytes) -> str | None: """Get the format of downloaded bytes that could be an image.""" fmt = None imagefile = io.BytesIO(image) with contextlib.suppress(PIL.UnidentifiedImageError): img = PIL.Image.open(imagefile) fmt = img.format.lower() if img.format else None if fmt is None: # if PIL can't figure it out, could be svg. with contextlib.suppress(UnicodeDecodeError): if image.decode("utf-8").lstrip().startswith(" tuple[dict[str, str], str | None]: """Verify that the still image is valid before we create an entity.""" fmt = None if not (url := info.get(CONF_STILL_IMAGE_URL)): # If user didn't specify a still image URL,the automatically generated # still image that stream generates is always jpeg. return {}, info.get(CONF_CONTENT_TYPE, "image/jpeg") try: if not isinstance(url, template_helper.Template): url = template_helper.Template(url, hass) url = url.async_render(parse_result=False) except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", url, err) return {CONF_STILL_IMAGE_URL: "template_error"}, None try: yarl_url = yarl.URL(url) except ValueError: return {CONF_STILL_IMAGE_URL: "malformed_url"}, None if not yarl_url.is_absolute(): return {CONF_STILL_IMAGE_URL: "relative_url"}, None verify_ssl = info[CONF_VERIFY_SSL] auth = generate_auth(info) try: async_client = get_async_client(hass, verify_ssl=verify_ssl) async with asyncio.timeout(GET_IMAGE_TIMEOUT): response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT) response.raise_for_status() image = response.content except ( TimeoutError, RequestError, TimeoutException, ) as err: _LOGGER.error("Error getting camera image from %s: %s", url, type(err).__name__) return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None except HTTPStatusError as err: _LOGGER.error( "Error getting camera image from %s: %s %s", url, type(err).__name__, err.response.text, ) if err.response.status_code in [401, 403]: return {CONF_STILL_IMAGE_URL: "unable_still_load_auth"}, None if err.response.status_code in [404]: return {CONF_STILL_IMAGE_URL: "unable_still_load_not_found"}, None if err.response.status_code in [500, 503]: return {CONF_STILL_IMAGE_URL: "unable_still_load_server_error"}, None return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None if not image: return {CONF_STILL_IMAGE_URL: "unable_still_load_no_image"}, None fmt = get_image_type(image) _LOGGER.debug( "Still image at '%s' detected format: %s", info[CONF_STILL_IMAGE_URL], fmt, ) if fmt not in SUPPORTED_IMAGE_TYPES: return {CONF_STILL_IMAGE_URL: "invalid_still_image"}, None return {}, f"image/{fmt}" def slug( hass: HomeAssistant, template: str | template_helper.Template | None ) -> str | None: """Convert a camera url into a string suitable for a camera name.""" url = "" if not template: return None if not isinstance(template, template_helper.Template): template = template_helper.Template(template, hass) try: url = template.async_render(parse_result=False) return slugify(yarl.URL(url).host) except (ValueError, TemplateError, TypeError) as err: _LOGGER.error("Syntax error in '%s': %s", template, err) return None async def async_test_and_preview_stream( hass: HomeAssistant, info: Mapping[str, Any] ) -> Stream | None: """Verify that the stream is valid before we create an entity. Returns the stream object if valid. Raises InvalidStreamException if not. The stream object is used to preview the video in the UI. """ if not (stream_source := info.get(CONF_STREAM_SOURCE)): return None if not isinstance(stream_source, template_helper.Template): stream_source = template_helper.Template(stream_source, hass) try: stream_source = stream_source.async_render(parse_result=False) except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) raise InvalidStreamException("template_error") from err stream_options: dict[str, str | bool | float] = {} if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True try: url = yarl.URL(stream_source) except ValueError as err: raise InvalidStreamException("malformed_url") from err if not url.is_absolute(): raise InvalidStreamException("relative_url") if not url.user and not url.password: username = info.get(CONF_USERNAME) password = info.get(CONF_PASSWORD) if username and password: url = url.with_user(username).with_password(password) stream_source = str(url) try: stream = create_stream( hass, stream_source, stream_options, DynamicStreamSettings(), f"{DOMAIN}.test_stream", ) hls_provider = stream.add_provider(HLS_PROVIDER) except PermissionError as err: raise InvalidStreamException("stream_not_permitted") from err except OSError as err: if err.errno == EHOSTUNREACH: raise InvalidStreamException("stream_no_route_to_host") from err if err.errno == EIO: # input/output error raise InvalidStreamException("stream_io_error") from err raise except HomeAssistantError as err: if "Stream integration is not set up" in str(err): raise InvalidStreamException("stream_not_set_up") from err raise await stream.start() if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): hass.async_create_task(stream.stop()) raise InvalidStreamException("timeout") return stream def register_still_preview(hass: HomeAssistant) -> None: """Set up still image preview for camera feeds during config flow.""" hass.data.setdefault(DOMAIN, {}) if not hass.data[DOMAIN].get(IMAGE_PREVIEWS_ACTIVE): _LOGGER.debug("Registering camera image preview handler") hass.http.register_view(CameraImagePreview(hass)) hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] = True class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for generic IP camera.""" VERSION = 1 def __init__(self) -> None: """Initialize Generic ConfigFlow.""" self.preview_image_settings: dict[str, Any] = {} self.preview_stream: Stream | None = None self.user_input: dict[str, Any] = {} self.title = "" @staticmethod def async_get_options_flow( config_entry: ConfigEntry, ) -> GenericOptionsFlowHandler: """Get the options flow for this handler.""" return GenericOptionsFlowHandler() async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the start of the config flow.""" errors = {} hass = self.hass if user_input: # Secondary validation because serialised vol can't seem to handle this complexity: if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get( CONF_STREAM_SOURCE ): errors["base"] = "no_still_image_or_stream_url" else: errors, still_format = await async_test_still(hass, user_input) try: self.preview_stream = await async_test_and_preview_stream( hass, user_input ) except InvalidStreamException as err: errors[CONF_STREAM_SOURCE] = str(err) self.preview_stream = None if not errors: user_input[CONF_CONTENT_TYPE] = still_format still_url = user_input.get(CONF_STILL_IMAGE_URL) stream_url = user_input.get(CONF_STREAM_SOURCE) name = ( slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME ) self.user_input = user_input self.title = name # temporary preview for user to check the image self.preview_image_settings = user_input return await self.async_step_user_confirm() elif self.user_input: user_input = self.user_input else: user_input = DEFAULT_DATA.copy() return self.async_show_form( step_id="user", data_schema=build_schema(user_input), errors=errors, ) async def async_step_user_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle user clicking confirm after still preview.""" if user_input: if ha_stream := self.preview_stream: # Kill off the temp stream we created. await ha_stream.stop() if not user_input.get(CONF_CONFIRMED_OK): return await self.async_step_user() return self.async_create_entry( title=self.title, data={}, options=self.user_input ) register_still_preview(self.hass) return self.async_show_form( step_id="user_confirm", data_schema=vol.Schema( { vol.Required(CONF_CONFIRMED_OK, default=False): bool, } ), errors=None, preview="generic_camera", ) @staticmethod async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview WS API.""" websocket_api.async_register_command(hass, ws_start_preview) class GenericOptionsFlowHandler(OptionsFlow): """Handle Generic IP Camera options.""" def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" self.preview_image_settings: dict[str, Any] = {} self.preview_stream: Stream | None = None self.user_input: dict[str, Any] = {} async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage Generic IP Camera options.""" errors: dict[str, str] = {} hass = self.hass if user_input: # Secondary validation because serialised vol can't seem to handle this complexity: if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get( CONF_STREAM_SOURCE ): errors["base"] = "no_still_image_or_stream_url" else: errors, still_format = await async_test_still(hass, user_input) try: self.preview_stream = await async_test_and_preview_stream( hass, user_input ) except InvalidStreamException as err: errors[CONF_STREAM_SOURCE] = str(err) self.preview_stream = None if not errors: data = { CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get( CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False ), **user_input, CONF_CONTENT_TYPE: still_format or self.config_entry.options.get(CONF_CONTENT_TYPE), } self.user_input = data # temporary preview for user to check the image self.preview_image_settings = data return await self.async_step_user_confirm() elif self.user_input: user_input = self.user_input return self.async_show_form( step_id="init", data_schema=build_schema( user_input or self.config_entry.options, True, self.show_advanced_options, ), errors=errors, ) async def async_step_user_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle user clicking confirm after still preview.""" if user_input: if ha_stream := self.preview_stream: # Kill off the temp stream we created. await ha_stream.stop() if not user_input.get(CONF_CONFIRMED_OK): return await self.async_step_init() return self.async_create_entry( title=self.config_entry.title, data=self.user_input, ) register_still_preview(self.hass) return self.async_show_form( step_id="user_confirm", data_schema=vol.Schema( { vol.Required(CONF_CONFIRMED_OK, default=False): bool, } ), errors=None, preview="generic_camera", ) @staticmethod async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview WS API.""" websocket_api.async_register_command(hass, ws_start_preview) class CameraImagePreview(HomeAssistantView): """Camera view to temporarily serve an image.""" url = "/api/generic/preview_flow_image/{flow_id}" name = "api:generic:preview_flow_image" requires_auth = False def __init__(self, hass: HomeAssistant) -> None: """Initialise.""" self.hass = hass async def get(self, request: web.Request, flow_id: str) -> web.Response: """Start a GET request.""" _LOGGER.debug("processing GET request for flow_id=%s", flow_id) flow = cast( GenericIPCamConfigFlow, self.hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 ) or cast( GenericOptionsFlowHandler, self.hass.config_entries.options._progress.get(flow_id), # noqa: SLF001 ) if not flow: _LOGGER.warning("Unknown flow while getting image preview") raise web.HTTPNotFound user_input = flow.preview_image_settings camera = GenericCamera(self.hass, user_input, flow_id, "preview") if not camera.is_on: _LOGGER.debug("Camera is off") raise web.HTTPServiceUnavailable image = await _async_get_image( camera, CAMERA_IMAGE_TIMEOUT, ) return web.Response(body=image.content, content_type=image.content_type) @websocket_api.websocket_command( { vol.Required("type"): "generic_camera/start_preview", vol.Required("flow_id"): str, vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"), vol.Optional("user_input"): dict, } ) @websocket_api.async_response async def ws_start_preview( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Generate websocket handler for the camera still/stream preview.""" _LOGGER.debug("Generating websocket handler for generic camera preview") flow_id = msg["flow_id"] flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler if msg.get("flow_type", "config_flow") == "config_flow": flow = cast( GenericIPCamConfigFlow, hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 ) else: # (flow type == "options flow") flow = cast( GenericOptionsFlowHandler, hass.config_entries.options._progress.get(flow_id), # noqa: SLF001 ) user_input = flow.preview_image_settings # Create an EntityPlatform, needed for name translations platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) entity_platform = EntityPlatform( hass=hass, logger=_LOGGER, domain=CAMERA_DOMAIN, platform_name=DOMAIN, platform=platform, scan_interval=timedelta(seconds=3600), entity_namespace=None, ) await entity_platform.async_load_translations() ha_still_url = None ha_stream_url = None if user_input.get(CONF_STILL_IMAGE_URL): ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}" _LOGGER.debug("Got preview still URL: %s", ha_still_url) if ha_stream := flow.preview_stream: ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER) _LOGGER.debug("Got preview stream URL: %s", ha_stream_url) connection.send_message( websocket_api.event_message( msg["id"], {"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}}, ) )