From e27e1d9f5cd29972581c005faebb336d9fc6bade Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 22 Jul 2024 20:33:39 +0000 Subject: [PATCH 01/23] Add config flow stream preview --- homeassistant/components/generic/camera.py | 6 +- .../components/generic/config_flow.py | 132 ++++++++++++++++-- .../components/generic/manifest.json | 2 +- homeassistant/components/generic/strings.json | 6 +- 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 3aac5145ca56ea..64638d0116cb87 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -95,8 +95,10 @@ def __init__( self._still_image_url = Template(self._still_image_url, hass) self._stream_source = device_info.get(CONF_STREAM_SOURCE) if self._stream_source: - self._stream_source = Template(self._stream_source, hass) - self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] + if not isinstance(self._stream_source, Template): + self._stream_source = Template(self._stream_source, hass) + self._stream_source.hass = hass + self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False) self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE] if self._stream_source: self._attr_supported_features = CameraEntityFeature.STREAM diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 84243101bd6691..9c7c2b1520c5a7 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -5,7 +5,8 @@ import asyncio from collections.abc import Mapping import contextlib -from datetime import datetime +from copy import deepcopy +from datetime import datetime, timedelta from errno import EHOSTUNREACH, EIO import io import logging @@ -17,12 +18,16 @@ import voluptuous as vol import yarl +from homeassistant.components import websocket_api from homeassistant.components.camera import ( CAMERA_IMAGE_TIMEOUT, + DATA_CAMERA_PREFS, + DOMAIN as CAMERA_DOMAIN, DynamicStreamSettings, _async_get_image, ) -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.camera.prefs import CameraPreferences +from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, @@ -49,7 +54,9 @@ 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 @@ -308,6 +315,40 @@ def register_preview(hass: HomeAssistant) -> None: hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] = True +async def register_stream_preview(hass: HomeAssistant, config) -> str: + """Set up preview for camera stream during config flow.""" + hass.data.setdefault("camera", {}) + + # Need to load the camera prefs early to avoid errors generating the stream + # if the user does not already have the stream component loaded. + if hass.data.get(DATA_CAMERA_PREFS) is None: + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs + + # Create a camera but don't add it to the hass object. + cam = GenericCamera(hass, config, "stream_preview", "Camera Preview Stream") + cam.entity_id = DOMAIN + ".stream_preview" + cam.platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=DOMAIN, + platform_name="camera", + platform=None, + scan_interval=timedelta(seconds=1), + entity_namespace=None, + ) + + stream = await cam.async_create_stream() + if not stream: + raise HomeAssistantError("Failed to create preview stream") + stream.add_provider(HLS_PROVIDER) + url = stream.endpoint_url(HLS_PROVIDER) + _LOGGER.debug("Registered preview stream URL: %s", url) + + return url + + class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for generic IP camera.""" @@ -352,7 +393,6 @@ async def async_step_user( errors = errors | await async_test_stream(hass, user_input) if not errors: user_input[CONF_CONTENT_TYPE] = still_format - user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False still_url = user_input.get(CONF_STILL_IMAGE_URL) stream_url = user_input.get(CONF_STREAM_SOURCE) name = ( @@ -365,11 +405,6 @@ async def async_step_user( user_input[CONF_CONTENT_TYPE] = "image/jpeg" self.user_input = user_input self.title = name - - if still_url is None: - return self.async_create_entry( - title=self.title, data={}, options=self.user_input - ) # temporary preview for user to check the image self.preview_cam = user_input return await self.async_step_user_confirm_still() @@ -407,8 +442,14 @@ async def async_step_user_confirm_still( ), description_placeholders={"preview_url": preview_url}, 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.""" @@ -518,3 +559,78 @@ async def get(self, request: web.Request, flow_id: str) -> web.Response: 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.Required("flow_type"): vol.Any("config_flow"), + vol.Required("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.""" + errors: dict[str, str] = {} + _LOGGER.debug("Generating websocket handler for generic camera preview") + + flow = hass.config_entries.flow.async_get(msg["flow_id"]) + schema = build_schema({}) + user_input = deepcopy(flow["context"]["preview_cam"]) + del user_input[CONF_CONTENT_TYPE] # The schema doesn't like this generated field. + validated = schema(user_input) + + # 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() + + validated[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False + + ext_still_url = validated.get(CONF_STILL_IMAGE_URL) + ext_stream_url = validated.get(CONF_STREAM_SOURCE) + + if ext_still_url: + errors, still_format = await async_test_still(hass, validated) + validated[CONF_CONTENT_TYPE] = still_format + register_preview(hass) + + ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}" + _LOGGER.debug("Preview still URL: %s", ha_still_url) + else: + # If user didn't specify a still image URL, + # The automatically generated still image that stream generates + # is always jpeg + validated[CONF_CONTENT_TYPE] = "image/jpeg" + ha_still_url = None + + ha_stream_url = None + if ext_stream_url: + errors = errors | await async_test_stream(hass, validated) + if not errors: + preview_entity = GenericCamera( + hass, validated, msg["flow_id"] + "stream_preview", "PreviewStream" + ) + preview_entity.platform = entity_platform + ha_stream_url = await register_stream_preview(hass, validated) + + connection.send_result(msg["id"]) + connection.send_message( + websocket_api.event_message( + msg["id"], + {"attributes": {"stillUrl": ha_still_url, "streamUrl": ha_stream_url}}, + ) + ) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index c1fbc16d9be316..0b6f07e8205dea 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -3,7 +3,7 @@ "name": "Generic Camera", "codeowners": ["@davet2001"], "config_flow": true, - "dependencies": ["http"], + "dependencies": ["http", "stream"], "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 94360a5b7c2955..f1f76fb572a34e 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -40,10 +40,10 @@ } }, "user_confirm_still": { - "title": "Preview", - "description": "![Camera Still Image Preview]({preview_url})", + "title": "Confirmation", + "description": "Please wait for previews to load...", "data": { - "confirmed_ok": "This image looks good." + "confirmed_ok": "Everything looks good." } } } From 1d3f3a2b7418d67de924477ea044e3dc4cb0c5cc Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 24 Jul 2024 21:25:32 +0000 Subject: [PATCH 02/23] Update tests --- tests/components/generic/test_config_flow.py | 29 +++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index a882ca4cd8dfa8..53783e86565b5b 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -110,9 +110,8 @@ async def test_form( CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } @@ -157,9 +156,8 @@ async def test_form_only_stillimage( CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } @@ -399,9 +397,8 @@ async def test_form_rtsp_mode( CONF_RTSP_TRANSPORT: "tcp", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/png", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } @@ -419,21 +416,28 @@ async def test_form_only_stream( data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_create_stream as mock_setup: + with mock_create_stream: result1 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data, ) - assert result1["type"] is FlowResultType.CREATE_ENTRY - assert result1["title"] == "127_0_0_1" - assert result1["options"] == { + + assert result1["type"] is FlowResultType.FORM + with mock_create_stream: + result2 = await hass.config_entries.flow.async_configure( + result1["flow_id"], + user_input={CONF_CONFIRMED_OK: True}, + ) + await hass.async_block_till_done() + + assert result2["title"] == "127_0_0_1" + assert result2["options"] == { CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2", CONF_USERNAME: "fred_flintstone", CONF_PASSWORD: "bambam", - CONF_LIMIT_REFETCH_TO_URL_CHANGE: False, CONF_CONTENT_TYPE: "image/jpeg", - CONF_FRAMERATE: 5, + CONF_FRAMERATE: 5.0, CONF_VERIFY_SSL: False, } @@ -445,7 +449,6 @@ async def test_form_only_stream( ): image_obj = await async_get_image(hass, "camera.127_0_0_1") assert image_obj.content == fakeimgbytes_jpg - assert len(mock_setup.mock_calls) == 1 async def test_form_still_and_stream_not_provided( From a46af3a5047c93bb7d01c1bd392ec25c263359f9 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 30 Jul 2024 06:32:24 +0000 Subject: [PATCH 03/23] Code review: Move init_camera_prefs to shared function --- homeassistant/components/camera/__init__.py | 78 ++++++++++++++++++- .../components/generic/config_flow.py | 6 +- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 781388f12d6be7..6a5b552cbaae95 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -345,15 +345,85 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: return response -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the camera component.""" - component = hass.data[DATA_COMPONENT] = EntityComponent[Camera]( - _LOGGER, DOMAIN, hass, SCAN_INTERVAL +def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera: + """Get camera component from entity_id.""" + if (component := hass.data.get(DOMAIN)) is None: + raise HomeAssistantError("Camera integration not set up") + + if (camera := component.get_entity(entity_id)) is None: + raise HomeAssistantError("Camera not found") + + if not camera.is_on: + raise HomeAssistantError("Camera is off") + + return cast(Camera, camera) + + +# An RtspToWebRtcProvider accepts these inputs: +# stream_source: The RTSP url +# offer_sdp: The WebRTC SDP offer +# stream_id: A unique id for the stream, used to update an existing source +# The output is the SDP answer, or None if the source or offer is not eligible. +# The Callable may throw HomeAssistantError on failure. +type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]] + + +def async_register_rtsp_to_web_rtc_provider( + hass: HomeAssistant, + domain: str, + provider: RtspToWebRtcProviderType, +) -> Callable[[], None]: + """Register an RTSP to WebRTC provider. + + The first provider to satisfy the offer will be used. + """ + if DOMAIN not in hass.data: + raise ValueError("Unexpected state, camera not loaded") + + def remove_provider() -> None: + if domain in hass.data[DATA_RTSP_TO_WEB_RTC]: + del hass.data[DATA_RTSP_TO_WEB_RTC] + hass.async_create_task(_async_refresh_providers(hass)) + + hass.data.setdefault(DATA_RTSP_TO_WEB_RTC, {}) + hass.data[DATA_RTSP_TO_WEB_RTC][domain] = provider + hass.async_create_task(_async_refresh_providers(hass)) + return remove_provider + + +async def _async_refresh_providers(hass: HomeAssistant) -> None: + """Check all cameras for any state changes for registered providers.""" + + component: EntityComponent[Camera] = hass.data[DOMAIN] + await asyncio.gather( + *(camera.async_refresh_providers() for camera in component.entities) + ) + + +def _async_get_rtsp_to_web_rtc_providers( + hass: HomeAssistant, +) -> Iterable[RtspToWebRtcProviderType]: + """Return registered RTSP to WebRTC providers.""" + providers: dict[str, RtspToWebRtcProviderType] = hass.data.get( + DATA_RTSP_TO_WEB_RTC, {} ) + return providers.values() + +async def init_camera_prefs(hass: HomeAssistant) -> CameraPreferences: + """Initialize camera preferences.""" prefs = CameraPreferences(hass) await prefs.async_load() hass.data[DATA_CAMERA_PREFS] = prefs + return prefs + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the camera component.""" + component = hass.data[DATA_COMPONENT] = EntityComponent[Camera]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + prefs = await init_camera_prefs(hass) hass.http.register_view(CameraImageView(component)) hass.http.register_view(CameraMjpegStream(component)) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 9c7c2b1520c5a7..c08b21ed954783 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -25,8 +25,8 @@ DOMAIN as CAMERA_DOMAIN, DynamicStreamSettings, _async_get_image, + init_camera_prefs, ) -from homeassistant.components.camera.prefs import CameraPreferences from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, @@ -322,9 +322,7 @@ async def register_stream_preview(hass: HomeAssistant, config) -> str: # Need to load the camera prefs early to avoid errors generating the stream # if the user does not already have the stream component loaded. if hass.data.get(DATA_CAMERA_PREFS) is None: - prefs = CameraPreferences(hass) - await prefs.async_load() - hass.data[DATA_CAMERA_PREFS] = prefs + await init_camera_prefs(hass) # Create a camera but don't add it to the hass object. cam = GenericCamera(hass, config, "stream_preview", "Camera Preview Stream") From 2d92191007295e46476e5e50b7a899957d4507d0 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 31 Jul 2024 21:45:43 +0000 Subject: [PATCH 04/23] Code review: Remove schema double check --- .../components/generic/config_flow.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index c08b21ed954783..ceadc45cd6b1f7 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -578,10 +578,8 @@ async def ws_start_preview( _LOGGER.debug("Generating websocket handler for generic camera preview") flow = hass.config_entries.flow.async_get(msg["flow_id"]) - schema = build_schema({}) user_input = deepcopy(flow["context"]["preview_cam"]) del user_input[CONF_CONTENT_TYPE] # The schema doesn't like this generated field. - validated = schema(user_input) # Create an EntityPlatform, needed for name translations platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) @@ -596,14 +594,14 @@ async def ws_start_preview( ) await entity_platform.async_load_translations() - validated[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False + user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False - ext_still_url = validated.get(CONF_STILL_IMAGE_URL) - ext_stream_url = validated.get(CONF_STREAM_SOURCE) + ext_still_url = user_input.get(CONF_STILL_IMAGE_URL) + ext_stream_url = user_input.get(CONF_STREAM_SOURCE) if ext_still_url: - errors, still_format = await async_test_still(hass, validated) - validated[CONF_CONTENT_TYPE] = still_format + errors, still_format = await async_test_still(hass, user_input) + user_input[CONF_CONTENT_TYPE] = still_format register_preview(hass) ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}" @@ -612,18 +610,18 @@ async def ws_start_preview( # If user didn't specify a still image URL, # The automatically generated still image that stream generates # is always jpeg - validated[CONF_CONTENT_TYPE] = "image/jpeg" + user_input[CONF_CONTENT_TYPE] = "image/jpeg" ha_still_url = None ha_stream_url = None if ext_stream_url: - errors = errors | await async_test_stream(hass, validated) + errors = errors | await async_test_stream(hass, user_input) if not errors: preview_entity = GenericCamera( - hass, validated, msg["flow_id"] + "stream_preview", "PreviewStream" + hass, user_input, msg["flow_id"] + "stream_preview", "PreviewStream" ) preview_entity.platform = entity_platform - ha_stream_url = await register_stream_preview(hass, validated) + ha_stream_url = await register_stream_preview(hass, user_input) connection.send_result(msg["id"]) connection.send_message( From 647f4dd0776a5937b14ba6de6fe85006a5b13d11 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 7 Aug 2024 06:42:47 +0000 Subject: [PATCH 05/23] Combine test and preview code for stream --- .../components/generic/config_flow.py | 156 +++++++++--------- homeassistant/components/generic/strings.json | 8 +- tests/components/generic/test_config_flow.py | 26 +-- 3 files changed, 94 insertions(+), 96 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index ceadc45cd6b1f7..0e643d31c7dbeb 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -5,7 +5,6 @@ import asyncio from collections.abc import Mapping import contextlib -from copy import deepcopy from datetime import datetime, timedelta from errno import EHOSTUNREACH, EIO import io @@ -21,11 +20,9 @@ from homeassistant.components import websocket_api from homeassistant.components.camera import ( CAMERA_IMAGE_TIMEOUT, - DATA_CAMERA_PREFS, DOMAIN as CAMERA_DOMAIN, DynamicStreamSettings, _async_get_image, - init_camera_prefs, ) from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.stream import ( @@ -34,6 +31,7 @@ HLS_PROVIDER, RTSP_TRANSPORTS, SOURCE_TIMEOUT, + Stream, create_stream, ) from homeassistant.config_entries import ( @@ -238,10 +236,14 @@ def slug( return None -async def async_test_stream( +async def async_test_and_preview_stream( hass: HomeAssistant, info: Mapping[str, Any] -) -> dict[str, str]: - """Verify that the stream is valid before we create an entity.""" +) -> dict[str, str] | PreviewStream: + """Verify that the stream is valid before we create an entity. + + Returns a dict with errors if any, or the stream object if valid. + The stream object is used to preview the video in the UI. + """ if not (stream_source := info.get(CONF_STREAM_SOURCE)): return {} # Import from stream.worker as stream cannot reexport from worker @@ -275,19 +277,20 @@ async def async_test_stream( url = url.with_user(username).with_password(password) stream_source = str(url) try: - stream = create_stream( - hass, - stream_source, - stream_options, - DynamicStreamSettings(), - "test_stream", + stream = PreviewStream( + create_stream( + hass, + stream_source, + stream_options, + DynamicStreamSettings(), + "test_stream", + ) ) hls_provider = stream.add_provider(HLS_PROVIDER) await stream.start() if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): hass.async_create_task(stream.stop()) return {CONF_STREAM_SOURCE: "timeout"} - await stream.stop() except StreamWorkerError as err: return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)} except PermissionError: @@ -302,7 +305,7 @@ async def async_test_stream( if "Stream integration is not set up" in str(err): return {CONF_STREAM_SOURCE: "stream_not_set_up"} raise - return {} + return stream def register_preview(hass: HomeAssistant) -> None: @@ -315,38 +318,6 @@ def register_preview(hass: HomeAssistant) -> None: hass.data[DOMAIN][IMAGE_PREVIEWS_ACTIVE] = True -async def register_stream_preview(hass: HomeAssistant, config) -> str: - """Set up preview for camera stream during config flow.""" - hass.data.setdefault("camera", {}) - - # Need to load the camera prefs early to avoid errors generating the stream - # if the user does not already have the stream component loaded. - if hass.data.get(DATA_CAMERA_PREFS) is None: - await init_camera_prefs(hass) - - # Create a camera but don't add it to the hass object. - cam = GenericCamera(hass, config, "stream_preview", "Camera Preview Stream") - cam.entity_id = DOMAIN + ".stream_preview" - cam.platform = EntityPlatform( - hass=hass, - logger=_LOGGER, - domain=DOMAIN, - platform_name="camera", - platform=None, - scan_interval=timedelta(seconds=1), - entity_namespace=None, - ) - - stream = await cam.async_create_stream() - if not stream: - raise HomeAssistantError("Failed to create preview stream") - stream.add_provider(HLS_PROVIDER) - url = stream.endpoint_url(HLS_PROVIDER) - _LOGGER.debug("Registered preview stream URL: %s", url) - - return url - - class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for generic IP camera.""" @@ -388,7 +359,12 @@ async def async_step_user( errors["base"] = "no_still_image_or_stream_url" else: errors, still_format = await async_test_still(hass, user_input) - errors = errors | await async_test_stream(hass, user_input) + result = await async_test_and_preview_stream(hass, user_input) + if isinstance(result, dict): + errors = errors | result + self.context.pop("preview_stream", None) + else: + self.context["preview_stream"] = result if not errors: user_input[CONF_CONTENT_TYPE] = still_format still_url = user_input.get(CONF_STILL_IMAGE_URL) @@ -405,7 +381,7 @@ async def async_step_user( self.title = name # temporary preview for user to check the image self.preview_cam = user_input - return await self.async_step_user_confirm_still() + return await self.async_step_user_confirm() if "error_details" in errors: description_placeholders["error"] = errors.pop("error_details") elif self.user_input: @@ -419,11 +395,14 @@ async def async_step_user( errors=errors, ) - async def async_step_user_confirm_still( + 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.context.get("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( @@ -432,7 +411,7 @@ async def async_step_user_confirm_still( register_preview(self.hass) preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}" return self.async_show_form( - step_id="user_confirm_still", + step_id="user_confirm", data_schema=vol.Schema( { vol.Required(CONF_CONFIRMED_OK, default=False): bool, @@ -468,7 +447,10 @@ async def async_step_init( errors, still_format = await async_test_still( hass, self.config_entry.options | user_input ) - errors = errors | await async_test_stream(hass, user_input) + + result = await async_test_and_preview_stream(hass, user_input) + if isinstance(result, dict): + errors = errors | result still_url = user_input.get(CONF_STILL_IMAGE_URL) if not errors: if still_url is None: @@ -559,6 +541,41 @@ async def get(self, request: web.Request, flow_id: str) -> web.Response: return web.Response(body=image.content, content_type=image.content_type) +class PreviewStream: + """A wrapper around the stream object to automatically close unused streams.""" + + def __init__(self, stream: Stream) -> None: + """Initialize the object.""" + self.stream = stream + self._deferred_stop = None + + async def start(self, timeout=600): + """Start the stream with a timeout.""" + + async def _timeout() -> None: + _LOGGER.debug("Starting preview stream with timeout %ss", timeout) + await asyncio.sleep(timeout) + _LOGGER.info("Preview stream stopping due to timeout") + await self.stream.stop() + + await self.stream.start() + self._deferred_stop = self.stream.hass.async_create_task(_timeout()) + + async def stop(self): + """Stop the stream.""" + if not self._deferred_stop.done(): + self._deferred_stop.cancel() + await self.stream.stop() + + def add_provider(self, provider): + """Add a provider to the stream.""" + return self.stream.add_provider(provider) + + def endpoint_url(self, fmt: str) -> str: + """Return the endpoint URL.""" + return self.stream.endpoint_url(fmt) + + @websocket_api.websocket_command( { vol.Required("type"): "generic_camera/start_preview", @@ -574,12 +591,12 @@ async def ws_start_preview( msg: dict[str, Any], ) -> None: """Generate websocket handler for the camera still/stream preview.""" - errors: dict[str, str] = {} _LOGGER.debug("Generating websocket handler for generic camera preview") + ha_still_url = None + ha_stream_url = None flow = hass.config_entries.flow.async_get(msg["flow_id"]) - user_input = deepcopy(flow["context"]["preview_cam"]) - del user_input[CONF_CONTENT_TYPE] # The schema doesn't like this generated field. + user_input = flow["context"]["preview_cam"] # Create an EntityPlatform, needed for name translations platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) @@ -594,34 +611,13 @@ async def ws_start_preview( ) await entity_platform.async_load_translations() - user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False - - ext_still_url = user_input.get(CONF_STILL_IMAGE_URL) - ext_stream_url = user_input.get(CONF_STREAM_SOURCE) - - if ext_still_url: - errors, still_format = await async_test_still(hass, user_input) - user_input[CONF_CONTENT_TYPE] = still_format - register_preview(hass) - + 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("Preview still URL: %s", ha_still_url) - else: - # If user didn't specify a still image URL, - # The automatically generated still image that stream generates - # is always jpeg - user_input[CONF_CONTENT_TYPE] = "image/jpeg" - ha_still_url = None + _LOGGER.debug("Got preview still URL: %s", ha_still_url) - ha_stream_url = None - if ext_stream_url: - errors = errors | await async_test_stream(hass, user_input) - if not errors: - preview_entity = GenericCamera( - hass, user_input, msg["flow_id"] + "stream_preview", "PreviewStream" - ) - preview_entity.platform = entity_platform - ha_stream_url = await register_stream_preview(hass, user_input) + if ha_stream := flow["context"].get("preview_stream"): + ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER) + _LOGGER.debug("Got preview stream URL: %s", ha_stream_url) connection.send_result(msg["id"]) connection.send_message( diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index f1f76fb572a34e..007fc9df23bc0e 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -39,7 +39,7 @@ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, - "user_confirm_still": { + "user_confirm": { "title": "Confirmation", "description": "Please wait for previews to load...", "data": { @@ -68,10 +68,10 @@ } }, "confirm_still": { - "title": "[%key:component::generic::config::step::user_confirm_still::title%]", - "description": "[%key:component::generic::config::step::user_confirm_still::description%]", + "title": "Preview", + "description": "![Camera Still Image Preview]({preview_url})", "data": { - "confirmed_ok": "[%key:component::generic::config::step::user_confirm_still::data::confirmed_ok%]" + "confirmed_ok": "This image looks good." } } }, diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 53783e86565b5b..742b53726ca3b3 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -90,7 +90,7 @@ async def test_form( TESTDATA, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" client = await hass_client() preview_url = result1["description_placeholders"]["preview_url"] # Check the preview image works. @@ -144,7 +144,7 @@ async def test_form_only_stillimage( ) await hass.async_block_till_done() assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, @@ -165,13 +165,13 @@ async def test_form_only_stillimage( @respx.mock -async def test_form_reject_still_preview( +async def test_form_reject_preview( hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock], user_flow: ConfigFlowResult, ) -> None: - """Test we go back to the config screen if the user rejects the still preview.""" + """Test we go back to the config screen if the user rejects the preview.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with mock_create_stream: result1 = await hass.config_entries.flow.async_configure( @@ -179,7 +179,7 @@ async def test_form_reject_still_preview( TESTDATA, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: False}, @@ -209,7 +209,7 @@ async def test_form_still_preview_cam_off( TESTDATA, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" preview_url = result1["description_placeholders"]["preview_url"] # Try to view the image, should be unavailable. client = await hass_client() @@ -231,7 +231,7 @@ async def test_form_only_stillimage_gif( data, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, @@ -256,7 +256,7 @@ async def test_form_only_svg_whitespace( data, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, @@ -291,7 +291,7 @@ async def test_form_only_still_sample( data, ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, @@ -308,13 +308,13 @@ async def test_form_only_still_sample( ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", - "user_confirm_still", + "user_confirm", None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", - "user_confirm_still", + "user_confirm", None, ), ( @@ -383,7 +383,7 @@ async def test_form_rtsp_mode( user_flow["flow_id"], data ) assert result1["type"] is FlowResultType.FORM - assert result1["step_id"] == "user_confirm_still" + assert result1["step_id"] == "user_confirm" result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, @@ -588,6 +588,8 @@ async def test_form_stream_timeout( "homeassistant.components.generic.config_flow.create_stream" ) as create_stream: create_stream.return_value.start = AsyncMock() + create_stream.return_value.stop = AsyncMock() + create_stream.return_value.hass = hass create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() create_stream.return_value.add_provider.return_value.part_recv.return_value = ( False From c7a01735045fd6d90230634265529e7f7ddc0fd9 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 7 Sep 2024 07:40:32 +0000 Subject: [PATCH 06/23] Code review: Switch stream preview error to specific Exception --- .../components/generic/config_flow.py | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 0e643d31c7dbeb..804774679da545 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -84,6 +84,10 @@ IMAGE_PREVIEWS_ACTIVE = "previews" +class InvalidStreamException(HomeAssistantError): + """Error to indicate an invalid stream.""" + + def build_schema( user_input: Mapping[str, Any], is_options_flow: bool = False, @@ -238,14 +242,14 @@ def slug( async def async_test_and_preview_stream( hass: HomeAssistant, info: Mapping[str, Any] -) -> dict[str, str] | PreviewStream: +) -> PreviewStream | None: """Verify that the stream is valid before we create an entity. - Returns a dict with errors if any, or the stream object if valid. + 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 {} + return None # Import from stream.worker as stream cannot reexport from worker # without forcing the av dependency on default_config # pylint: disable-next=import-outside-toplevel @@ -257,7 +261,7 @@ async def async_test_and_preview_stream( stream_source = stream_source.async_render(parse_result=False) except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) - return {CONF_STREAM_SOURCE: "template_error"} + 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 @@ -266,10 +270,10 @@ async def async_test_and_preview_stream( try: url = yarl.URL(stream_source) - except ValueError: - return {CONF_STREAM_SOURCE: "malformed_url"} + except ValueError as err: + raise InvalidStreamException("malformed_url") from err if not url.is_absolute(): - return {CONF_STREAM_SOURCE: "relative_url"} + raise InvalidStreamException("relative_url") if not url.user and not url.password: username = info.get(CONF_USERNAME) password = info.get(CONF_PASSWORD) @@ -287,24 +291,24 @@ async def async_test_and_preview_stream( ) ) hls_provider = stream.add_provider(HLS_PROVIDER) - await stream.start() - if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): - hass.async_create_task(stream.stop()) - return {CONF_STREAM_SOURCE: "timeout"} except StreamWorkerError as err: return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)} except PermissionError: return {CONF_STREAM_SOURCE: "stream_not_permitted"} except OSError as err: if err.errno == EHOSTUNREACH: - return {CONF_STREAM_SOURCE: "stream_no_route_to_host"} + raise InvalidStreamException("stream_no_route_to_host") from err if err.errno == EIO: # input/output error - return {CONF_STREAM_SOURCE: "stream_io_error"} + raise InvalidStreamException("stream_io_error") from err raise except HomeAssistantError as err: if "Stream integration is not set up" in str(err): - return {CONF_STREAM_SOURCE: "stream_not_set_up"} + 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 @@ -359,12 +363,12 @@ async def async_step_user( errors["base"] = "no_still_image_or_stream_url" else: errors, still_format = await async_test_still(hass, user_input) - result = await async_test_and_preview_stream(hass, user_input) - if isinstance(result, dict): - errors = errors | result - self.context.pop("preview_stream", None) - else: + try: + result = await async_test_and_preview_stream(hass, user_input) self.context["preview_stream"] = result + except InvalidStreamException as err: + errors |= {CONF_STREAM_SOURCE: str(err)} + self.context.pop("preview_stream", None) if not errors: user_input[CONF_CONTENT_TYPE] = still_format still_url = user_input.get(CONF_STILL_IMAGE_URL) @@ -447,10 +451,12 @@ async def async_step_init( errors, still_format = await async_test_still( hass, self.config_entry.options | user_input ) + try: + await async_test_and_preview_stream(hass, user_input) + except InvalidStreamException as err: + errors |= {CONF_STREAM_SOURCE: str(err)} + # Stream preview during options flow not yet implemented - result = await async_test_and_preview_stream(hass, user_input) - if isinstance(result, dict): - errors = errors | result still_url = user_input.get(CONF_STILL_IMAGE_URL) if not errors: if still_url is None: From 16e4bc28ffb76aa5f9acaf49223367a12369aeb8 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 7 Oct 2024 19:39:33 +0000 Subject: [PATCH 07/23] Update camera __init__.py from dev --- homeassistant/components/camera/__init__.py | 65 --------------------- 1 file changed, 65 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 6a5b552cbaae95..16071883c16da7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -345,71 +345,6 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: return response -def _get_camera_from_entity_id(hass: HomeAssistant, entity_id: str) -> Camera: - """Get camera component from entity_id.""" - if (component := hass.data.get(DOMAIN)) is None: - raise HomeAssistantError("Camera integration not set up") - - if (camera := component.get_entity(entity_id)) is None: - raise HomeAssistantError("Camera not found") - - if not camera.is_on: - raise HomeAssistantError("Camera is off") - - return cast(Camera, camera) - - -# An RtspToWebRtcProvider accepts these inputs: -# stream_source: The RTSP url -# offer_sdp: The WebRTC SDP offer -# stream_id: A unique id for the stream, used to update an existing source -# The output is the SDP answer, or None if the source or offer is not eligible. -# The Callable may throw HomeAssistantError on failure. -type RtspToWebRtcProviderType = Callable[[str, str, str], Awaitable[str | None]] - - -def async_register_rtsp_to_web_rtc_provider( - hass: HomeAssistant, - domain: str, - provider: RtspToWebRtcProviderType, -) -> Callable[[], None]: - """Register an RTSP to WebRTC provider. - - The first provider to satisfy the offer will be used. - """ - if DOMAIN not in hass.data: - raise ValueError("Unexpected state, camera not loaded") - - def remove_provider() -> None: - if domain in hass.data[DATA_RTSP_TO_WEB_RTC]: - del hass.data[DATA_RTSP_TO_WEB_RTC] - hass.async_create_task(_async_refresh_providers(hass)) - - hass.data.setdefault(DATA_RTSP_TO_WEB_RTC, {}) - hass.data[DATA_RTSP_TO_WEB_RTC][domain] = provider - hass.async_create_task(_async_refresh_providers(hass)) - return remove_provider - - -async def _async_refresh_providers(hass: HomeAssistant) -> None: - """Check all cameras for any state changes for registered providers.""" - - component: EntityComponent[Camera] = hass.data[DOMAIN] - await asyncio.gather( - *(camera.async_refresh_providers() for camera in component.entities) - ) - - -def _async_get_rtsp_to_web_rtc_providers( - hass: HomeAssistant, -) -> Iterable[RtspToWebRtcProviderType]: - """Return registered RTSP to WebRTC providers.""" - providers: dict[str, RtspToWebRtcProviderType] = hass.data.get( - DATA_RTSP_TO_WEB_RTC, {} - ) - return providers.values() - - async def init_camera_prefs(hass: HomeAssistant) -> CameraPreferences: """Initialize camera preferences.""" prefs = CameraPreferences(hass) From ce0e5313e5963472ac3c4cd15ecc589d198b31af Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 8 Oct 2024 08:17:54 +0000 Subject: [PATCH 08/23] Test image preview timeout --- tests/components/generic/conftest.py | 7 ++-- tests/components/generic/test_config_flow.py | 39 +++++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 69e6cc6b696004..c665be21442085 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -71,10 +71,11 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]: respx.pop("fake_img") -@pytest.fixture(scope="package") -def mock_create_stream() -> _patch[MagicMock]: +@pytest.fixture +def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: """Mock create stream.""" - mock_stream = Mock() + mock_stream = MagicMock() + mock_stream.hass = hass mock_provider = Mock() mock_provider.part_recv = AsyncMock() mock_provider.part_recv.return_value = True diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 742b53726ca3b3..f8ef0f4088db95 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -9,6 +9,7 @@ from pathlib import Path from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch +from freezegun.api import FrozenDateTimeFactory import httpx import pytest import respx @@ -44,7 +45,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import ClientSessionGenerator TESTDATA = { @@ -515,7 +516,6 @@ async def test_form_image_http_exceptions( user_flow["flow_id"], TESTDATA, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == expected_message @@ -534,7 +534,6 @@ async def test_form_stream_invalidimage( user_flow["flow_id"], TESTDATA, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -553,7 +552,6 @@ async def test_form_stream_invalidimage2( user_flow["flow_id"], TESTDATA, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"} @@ -572,7 +570,6 @@ async def test_form_stream_invalidimage3( user_flow["flow_id"], TESTDATA, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"still_image_url": "invalid_still_image"} @@ -732,6 +729,38 @@ async def test_form_oserror(hass: HomeAssistant, user_flow: ConfigFlowResult) -> ) +@respx.mock +async def test_form_stream_preview_auto_timeout( + hass: HomeAssistant, + user_flow: ConfigFlowResult, + mock_create_stream: _patch[MagicMock], + freezer: FrozenDateTimeFactory, + fakeimgbytes_png: bytes, +) -> None: + """Test that the stream preview times out after 10mins.""" + respx.get("http://fred_flintstone:bambam@127.0.0.1/testurl/2").respond( + stream=fakeimgbytes_png + ) + data = TESTDATA.copy() + data.pop(CONF_STILL_IMAGE_URL) + + with mock_create_stream as mock_stream: + result1 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + assert result1["type"] is FlowResultType.FORM + assert result1["step_id"] == "user_confirm" + + freezer.tick(600 + 12) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + mock_str = mock_stream.return_value + mock_str.start.assert_awaited_once() + mock_str.stop.assert_awaited_once() + + @respx.mock async def test_options_template_error( hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock] From 6e20d4b9a59c36c86f6f5ba66caa588fd6f16180 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:49:01 +0000 Subject: [PATCH 09/23] Test the camera preview websocket command --- .../components/generic/config_flow.py | 19 +++++++++++---- tests/components/generic/conftest.py | 1 + tests/components/generic/test_config_flow.py | 24 ++++++++++++------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 804774679da545..0279c1c1bb710f 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -330,6 +330,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize Generic ConfigFlow.""" self.preview_cam: dict[str, Any] = {} + self.preview_stream: PreviewStream | None = None self.user_input: dict[str, Any] = {} self.title = "" @@ -365,10 +366,10 @@ async def async_step_user( errors, still_format = await async_test_still(hass, user_input) try: result = await async_test_and_preview_stream(hass, user_input) - self.context["preview_stream"] = result + self.preview_stream = result except InvalidStreamException as err: errors |= {CONF_STREAM_SOURCE: str(err)} - self.context.pop("preview_stream", None) + self.preview_stream = None if not errors: user_input[CONF_CONTENT_TYPE] = still_format still_url = user_input.get(CONF_STILL_IMAGE_URL) @@ -438,6 +439,7 @@ class GenericOptionsFlowHandler(OptionsFlow): def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" self.preview_cam: dict[str, Any] = {} + self.preview_stream: PreviewStream | None = None self.user_input: dict[str, Any] = {} async def async_step_init( @@ -601,8 +603,15 @@ async def ws_start_preview( ha_still_url = None ha_stream_url = None - flow = hass.config_entries.flow.async_get(msg["flow_id"]) - user_input = flow["context"]["preview_cam"] + flow_id = msg["flow_id"] + flow = cast( + GenericIPCamConfigFlow, + hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 + ) or cast( + GenericOptionsFlowHandler, + hass.config_entries.options._progress.get(flow_id), # noqa: SLF001 + ) + user_input = flow.preview_cam # Create an EntityPlatform, needed for name translations platform = await async_prepare_setup_platform(hass, {}, CAMERA_DOMAIN, DOMAIN) @@ -621,7 +630,7 @@ async def ws_start_preview( 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["context"].get("preview_stream"): + 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) diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index c665be21442085..cdea83b599c7fb 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -82,6 +82,7 @@ def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]: mock_stream.add_provider.return_value = mock_provider mock_stream.start = AsyncMock() mock_stream.stop = AsyncMock() + mock_stream.endpoint_url.return_value = "http://127.0.0.1/nothing" return patch( "homeassistant.components.generic.config_flow.create_stream", return_value=mock_stream, diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index f8ef0f4088db95..41c3029ab8127c 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -46,7 +46,7 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator +from tests.typing import ClientSessionGenerator, WebSocketGenerator TESTDATA = { CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1", @@ -76,6 +76,7 @@ async def test_form( hass_client: ClientSessionGenerator, user_flow: ConfigFlowResult, mock_create_stream: _patch[MagicMock], + hass_ws_client: WebSocketGenerator, ) -> None: """Test the form with a normal set of settings.""" @@ -98,11 +99,24 @@ async def test_form( resp = await client.get(preview_url) assert resp.status == HTTPStatus.OK assert await resp.read() == fakeimgbytes_png + + # HA should now be serving a WS connection for a preview stream. + ws_client = await hass_ws_client() + flow_id = user_flow["flow_id"] + await ws_client.send_json_auto_id( + { + "type": "generic_camera/start_preview", + "flow_id": flow_id, + "flow_type": "config_flow", + "user_input": {}, + }, + ) + _ = await ws_client.receive_json() + result2 = await hass.config_entries.flow.async_configure( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) - await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -116,7 +130,6 @@ async def test_form( CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() # Check that the preview image is disabled after. resp = await client.get(preview_url) assert resp.status == HTTPStatus.NOT_FOUND @@ -403,7 +416,6 @@ async def test_form_rtsp_mode( CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 @@ -429,7 +441,6 @@ async def test_form_only_stream( result1["flow_id"], user_input={CONF_CONFIRMED_OK: True}, ) - await hass.async_block_till_done() assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -442,8 +453,6 @@ async def test_form_only_stream( CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() - with patch( "homeassistant.components.camera._async_get_stream_image", return_value=fakeimgbytes_jpg, @@ -876,7 +885,6 @@ async def test_options_only_stream( ) mock_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_init(mock_entry.entry_id) assert result["type"] is FlowResultType.FORM From e73efac7ebd53e0967853bdb01b27c0ea5099e68 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Tue, 8 Oct 2024 11:53:35 +0000 Subject: [PATCH 10/23] Increase test coverage --- homeassistant/components/generic/config_flow.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 0279c1c1bb710f..bf1214749dc8c3 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -341,14 +341,6 @@ def async_get_options_flow( """Get the options flow for this handler.""" return GenericOptionsFlowHandler() - def check_for_existing(self, options: dict[str, Any]) -> bool: - """Check whether an existing entry is using the same URLs.""" - return any( - entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL) - and entry.options.get(CONF_STREAM_SOURCE) == options.get(CONF_STREAM_SOURCE) - for entry in self._async_current_entries() - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -405,7 +397,7 @@ async def async_step_user_confirm( ) -> ConfigFlowResult: """Handle user clicking confirm after still preview.""" if user_input: - if ha_stream := self.context.get("preview_stream"): + 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): From e3e5e4ef4ab6e1e2d154f97063484680ffc1b24f Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sun, 17 Nov 2024 21:22:10 +0000 Subject: [PATCH 11/23] Fixes following #130672 --- .../components/generic/config_flow.py | 22 ++++++++++++++----- homeassistant/components/generic/strings.json | 1 + 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index bf1214749dc8c3..461a020d66cb0a 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -87,6 +87,11 @@ 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], @@ -292,9 +297,9 @@ async def async_test_and_preview_stream( ) hls_provider = stream.add_provider(HLS_PROVIDER) except StreamWorkerError as err: - return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)} - except PermissionError: - return {CONF_STREAM_SOURCE: "stream_not_permitted"} + raise InvalidStreamException("unknown_with_details", str(err)) from err + 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 @@ -360,7 +365,9 @@ async def async_step_user( result = await async_test_and_preview_stream(hass, user_input) self.preview_stream = result except InvalidStreamException as err: - errors |= {CONF_STREAM_SOURCE: str(err)} + errors[CONF_STREAM_SOURCE] = str(err) + if err.details: + errors["error_details"] = err.details self.preview_stream = None if not errors: user_input[CONF_CONTENT_TYPE] = still_format @@ -439,6 +446,7 @@ async def async_step_init( ) -> ConfigFlowResult: """Manage Generic IP Camera options.""" errors: dict[str, str] = {} + description_placeholders = {} hass = self.hass if user_input is not None: @@ -448,7 +456,9 @@ async def async_step_init( try: await async_test_and_preview_stream(hass, user_input) except InvalidStreamException as err: - errors |= {CONF_STREAM_SOURCE: str(err)} + errors[CONF_STREAM_SOURCE] = str(err) + if err.details: + errors["error_details"] = err.details # Stream preview during options flow not yet implemented still_url = user_input.get(CONF_STILL_IMAGE_URL) @@ -470,6 +480,8 @@ async def async_step_init( # temporary preview for user to check the image self.preview_cam = data return await self.async_step_confirm_still() + if "error_details" in errors: + description_placeholders["error"] = errors.pop("error_details") return self.async_show_form( step_id="init", data_schema=build_schema( diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 007fc9df23bc0e..b3ecadacba529a 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -77,6 +77,7 @@ }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", + "unknown_with_details": "[%key:common::config_flow::error::unknown_with_details]", "already_exists": "[%key:component::generic::config::error::already_exists%]", "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]", From c9d4c90c75b3b0431df52d2a9f592fb16ce1c78c Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:20:20 +0000 Subject: [PATCH 12/23] Code review: no need to split out prefs init --- homeassistant/components/camera/__init__.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 16071883c16da7..781388f12d6be7 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -345,20 +345,15 @@ async def write_to_mjpeg_stream(img_bytes: bytes) -> None: return response -async def init_camera_prefs(hass: HomeAssistant) -> CameraPreferences: - """Initialize camera preferences.""" - prefs = CameraPreferences(hass) - await prefs.async_load() - hass.data[DATA_CAMERA_PREFS] = prefs - return prefs - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the camera component.""" component = hass.data[DATA_COMPONENT] = EntityComponent[Camera]( _LOGGER, DOMAIN, hass, SCAN_INTERVAL ) - prefs = await init_camera_prefs(hass) + + prefs = CameraPreferences(hass) + await prefs.async_load() + hass.data[DATA_CAMERA_PREFS] = prefs hass.http.register_view(CameraImageView(component)) hass.http.register_view(CameraMjpegStream(component)) From b2401946a43610da07ced5be5522e62bc6e96df6 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:39:53 +0000 Subject: [PATCH 13/23] Code review: remove unnecessary check. Simplify. --- homeassistant/components/generic/camera.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 64638d0116cb87..edefbc55ca626a 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -95,13 +95,10 @@ def __init__( self._still_image_url = Template(self._still_image_url, hass) self._stream_source = device_info.get(CONF_STREAM_SOURCE) if self._stream_source: - if not isinstance(self._stream_source, Template): - self._stream_source = Template(self._stream_source, hass) - self._stream_source.hass = hass + self._stream_source = Template(self._stream_source, hass) + self._attr_supported_features = CameraEntityFeature.STREAM self._limit_refetch = device_info.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False) self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE] - if self._stream_source: - self._attr_supported_features = CameraEntityFeature.STREAM self.content_type = device_info[CONF_CONTENT_TYPE] self.verify_ssl = device_info[CONF_VERIFY_SSL] if device_info.get(CONF_RTSP_TRANSPORT): From 3efa6817229fddd855dc8c5d4c6bc42d9446bc3e Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 12:45:15 +0000 Subject: [PATCH 14/23] Code review: include domain in preview stream name Co-authored-by: Allen Porter --- homeassistant/components/generic/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 461a020d66cb0a..cdaba13b90c467 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -292,7 +292,7 @@ async def async_test_and_preview_stream( stream_source, stream_options, DynamicStreamSettings(), - "test_stream", + f"{DOMAIN}.test_stream" ) ) hls_provider = stream.add_provider(HLS_PROVIDER) From aa3aca56d204b6956ef8ddd849d0e52d3da7c3c1 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 15:31:42 +0000 Subject: [PATCH 15/23] Code review: apply suggestions Co-authored-by: Allen Porter --- homeassistant/components/generic/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index cdaba13b90c467..4637b20e5ef6a8 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -362,8 +362,7 @@ async def async_step_user( else: errors, still_format = await async_test_still(hass, user_input) try: - result = await async_test_and_preview_stream(hass, user_input) - self.preview_stream = result + self.preview_stream = await async_test_and_preview_stream(hass, user_input) except InvalidStreamException as err: errors[CONF_STREAM_SOURCE] = str(err) if err.details: From c9329a3cd2852a416de3c21884686a32a193d9bc Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:42:22 +0000 Subject: [PATCH 16/23] Code review: remove unused WS parameters --- homeassistant/components/generic/config_flow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 4637b20e5ef6a8..a670edf5c28165 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -292,7 +292,7 @@ async def async_test_and_preview_stream( stream_source, stream_options, DynamicStreamSettings(), - f"{DOMAIN}.test_stream" + f"{DOMAIN}.test_stream", ) ) hls_provider = stream.add_provider(HLS_PROVIDER) @@ -362,7 +362,9 @@ async def async_step_user( else: errors, still_format = await async_test_still(hass, user_input) try: - self.preview_stream = await async_test_and_preview_stream(hass, user_input) + self.preview_stream = await async_test_and_preview_stream( + hass, user_input + ) except InvalidStreamException as err: errors[CONF_STREAM_SOURCE] = str(err) if err.details: @@ -591,8 +593,6 @@ def endpoint_url(self, fmt: str) -> str: { vol.Required("type"): "generic_camera/start_preview", vol.Required("flow_id"): str, - vol.Required("flow_type"): vol.Any("config_flow"), - vol.Required("user_input"): dict, } ) @websocket_api.async_response From 8004ec53c378ee0fb8a5a0952c6443cb14cd6515 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:51:54 +0000 Subject: [PATCH 17/23] Code review: reorder and simplify WS function --- homeassistant/components/generic/config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index a670edf5c28165..71a40e0005a83b 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -603,8 +603,6 @@ async def ws_start_preview( ) -> None: """Generate websocket handler for the camera still/stream preview.""" _LOGGER.debug("Generating websocket handler for generic camera preview") - ha_still_url = None - ha_stream_url = None flow_id = msg["flow_id"] flow = cast( @@ -629,6 +627,9 @@ async def ws_start_preview( ) 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) @@ -637,7 +638,6 @@ async def ws_start_preview( ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER) _LOGGER.debug("Got preview stream URL: %s", ha_stream_url) - connection.send_result(msg["id"]) connection.send_message( websocket_api.event_message( msg["id"], From 6a5e41e6df378f7b107614e7c11e8f4d015f4323 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 17:54:36 +0000 Subject: [PATCH 18/23] Code review: remove Options flow code until later PR --- homeassistant/components/generic/config_flow.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 71a40e0005a83b..072e0ceee752c1 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -439,7 +439,6 @@ class GenericOptionsFlowHandler(OptionsFlow): def __init__(self) -> None: """Initialize Generic IP Camera options flow.""" self.preview_cam: dict[str, Any] = {} - self.preview_stream: PreviewStream | None = None self.user_input: dict[str, Any] = {} async def async_step_init( @@ -608,9 +607,6 @@ async def ws_start_preview( flow = cast( GenericIPCamConfigFlow, hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 - ) or cast( - GenericOptionsFlowHandler, - hass.config_entries.options._progress.get(flow_id), # noqa: SLF001 ) user_input = flow.preview_cam From 7ebd32876c881accb9e31379c27334dfb605556d Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sat, 23 Nov 2024 18:03:04 +0000 Subject: [PATCH 19/23] Code review: correct attribute naming style --- homeassistant/components/generic/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 072e0ceee752c1..590616b2c905b2 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -637,6 +637,6 @@ async def ws_start_preview( connection.send_message( websocket_api.event_message( msg["id"], - {"attributes": {"stillUrl": ha_still_url, "streamUrl": ha_stream_url}}, + {"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}}, ) ) From 928766fa08874f00754584113f8dad93d12b10d3 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sun, 24 Nov 2024 22:01:18 +0000 Subject: [PATCH 20/23] Fix websocket test --- tests/components/generic/test_config_flow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 41c3029ab8127c..ffb20e9c201908 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -107,8 +107,6 @@ async def test_form( { "type": "generic_camera/start_preview", "flow_id": flow_id, - "flow_type": "config_flow", - "user_input": {}, }, ) _ = await ws_client.receive_json() From 8a90c651557fd121d374c76625608a40949712a1 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:25:15 +0000 Subject: [PATCH 21/23] Increase test coverage --- .../components/generic/config_flow.py | 1 + tests/components/generic/test_config_flow.py | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 590616b2c905b2..2c5719fc246557 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -489,6 +489,7 @@ async def async_step_init( True, self.show_advanced_options, ), + description_placeholders=description_placeholders, errors=errors, ) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index ffb20e9c201908..8141fe276da019 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -904,6 +904,27 @@ async def test_options_only_stream( assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" +@respx.mock +@pytest.mark.usefixtures("fakeimg_png") +async def test_form_options_stream_worker_error( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test we handle a StreamWorkerError and pass the message through.""" + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + with patch( + "homeassistant.components.generic.config_flow.create_stream", + side_effect=StreamWorkerError("Some message"), + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + TESTDATA, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"stream_source": "unknown_with_details"} + assert result2["description_placeholders"] == {"error": "Some message"} + + @pytest.mark.usefixtures("fakeimg_png") async def test_unload_entry(hass: HomeAssistant) -> None: """Test unloading the generic IP Camera entry.""" From c0972de6e950cc035432f551134e4e8258d4892c Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 25 Nov 2024 21:47:08 +0000 Subject: [PATCH 22/23] Set unused parameters to optional --- homeassistant/components/generic/config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 2c5719fc246557..d610ab0f67c045 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -593,6 +593,8 @@ def endpoint_url(self, fmt: str) -> str: { vol.Required("type"): "generic_camera/start_preview", vol.Required("flow_id"): str, + vol.Optional("flow_type"): vol.Any("config_flow"), + vol.Optional("user_input"): dict, } ) @websocket_api.async_response From 88b69965170754bb5a2f0fae340e9d0bf75bd5aa Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 2 Dec 2024 21:19:37 +0000 Subject: [PATCH 23/23] Ditch Stream wrapper. Use built in timeout for streams. --- .../components/generic/config_flow.py | 53 +++---------------- tests/components/generic/test_config_flow.py | 1 - 2 files changed, 8 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index d610ab0f67c045..83894b489f06b1 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -247,7 +247,7 @@ def slug( async def async_test_and_preview_stream( hass: HomeAssistant, info: Mapping[str, Any] -) -> PreviewStream | None: +) -> Stream | None: """Verify that the stream is valid before we create an entity. Returns the stream object if valid. Raises InvalidStreamException if not. @@ -286,14 +286,12 @@ async def async_test_and_preview_stream( url = url.with_user(username).with_password(password) stream_source = str(url) try: - stream = PreviewStream( - create_stream( - hass, - stream_source, - stream_options, - DynamicStreamSettings(), - f"{DOMAIN}.test_stream", - ) + stream = create_stream( + hass, + stream_source, + stream_options, + DynamicStreamSettings(), + f"{DOMAIN}.test_stream", ) hls_provider = stream.add_provider(HLS_PROVIDER) except StreamWorkerError as err: @@ -335,7 +333,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize Generic ConfigFlow.""" self.preview_cam: dict[str, Any] = {} - self.preview_stream: PreviewStream | None = None + self.preview_stream: Stream | None = None self.user_input: dict[str, Any] = {} self.title = "" @@ -554,41 +552,6 @@ async def get(self, request: web.Request, flow_id: str) -> web.Response: return web.Response(body=image.content, content_type=image.content_type) -class PreviewStream: - """A wrapper around the stream object to automatically close unused streams.""" - - def __init__(self, stream: Stream) -> None: - """Initialize the object.""" - self.stream = stream - self._deferred_stop = None - - async def start(self, timeout=600): - """Start the stream with a timeout.""" - - async def _timeout() -> None: - _LOGGER.debug("Starting preview stream with timeout %ss", timeout) - await asyncio.sleep(timeout) - _LOGGER.info("Preview stream stopping due to timeout") - await self.stream.stop() - - await self.stream.start() - self._deferred_stop = self.stream.hass.async_create_task(_timeout()) - - async def stop(self): - """Stop the stream.""" - if not self._deferred_stop.done(): - self._deferred_stop.cancel() - await self.stream.stop() - - def add_provider(self, provider): - """Add a provider to the stream.""" - return self.stream.add_provider(provider) - - def endpoint_url(self, fmt: str) -> str: - """Return the endpoint URL.""" - return self.stream.endpoint_url(fmt) - - @websocket_api.websocket_command( { vol.Required("type"): "generic_camera/start_preview", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 8141fe276da019..f121b210c0c001 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -765,7 +765,6 @@ async def test_form_stream_preview_auto_timeout( mock_str = mock_stream.return_value mock_str.start.assert_awaited_once() - mock_str.stop.assert_awaited_once() @respx.mock