Add stream preview to options flow in generic camera (#133927)

* Add stream preview to options flow

* Increase test coverage

* Code review: use correct flow handler type in cast

* Restore test coverage to 100%

* Remove error and test that can't be triggered yet
This commit is contained in:
Dave T 2024-12-30 23:46:42 +00:00 committed by GitHub
parent 57b7635b70
commit bf59241dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 148 additions and 71 deletions

View File

@ -343,7 +343,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the start of the config flow.""" """Handle the start of the config flow."""
errors = {} errors = {}
description_placeholders = {}
hass = self.hass hass = self.hass
if user_input: if user_input:
# Secondary validation because serialised vol can't seem to handle this complexity: # Secondary validation because serialised vol can't seem to handle this complexity:
@ -359,8 +358,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
) )
except InvalidStreamException as err: 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 self.preview_stream = None
if not errors: if not errors:
user_input[CONF_CONTENT_TYPE] = still_format user_input[CONF_CONTENT_TYPE] = still_format
@ -379,8 +376,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
# temporary preview for user to check the image # temporary preview for user to check the image
self.preview_cam = user_input self.preview_cam = user_input
return await self.async_step_user_confirm() return await self.async_step_user_confirm()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
elif self.user_input: elif self.user_input:
user_input = self.user_input user_input = self.user_input
else: else:
@ -388,7 +383,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="user", step_id="user",
data_schema=build_schema(user_input), data_schema=build_schema(user_input),
description_placeholders=description_placeholders,
errors=errors, errors=errors,
) )
@ -406,7 +400,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
title=self.title, data={}, options=self.user_input title=self.title, data={}, options=self.user_input
) )
register_preview(self.hass) register_preview(self.hass)
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
return self.async_show_form( return self.async_show_form(
step_id="user_confirm", step_id="user_confirm",
data_schema=vol.Schema( data_schema=vol.Schema(
@ -414,7 +407,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Required(CONF_CONFIRMED_OK, default=False): bool, vol.Required(CONF_CONFIRMED_OK, default=False): bool,
} }
), ),
description_placeholders={"preview_url": preview_url},
errors=None, errors=None,
preview="generic_camera", preview="generic_camera",
) )
@ -431,6 +423,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
def __init__(self) -> None: def __init__(self) -> None:
"""Initialize Generic IP Camera options flow.""" """Initialize Generic IP Camera options flow."""
self.preview_cam: dict[str, Any] = {} self.preview_cam: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {} self.user_input: dict[str, Any] = {}
async def async_step_init( async def async_step_init(
@ -438,23 +431,26 @@ class GenericOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Manage Generic IP Camera options.""" """Manage Generic IP Camera options."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
description_placeholders = {}
hass = self.hass hass = self.hass
if user_input is not None: if user_input:
errors, still_format = await async_test_still( # Secondary validation because serialised vol can't seem to handle this complexity:
hass, self.config_entry.options | user_input 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: try:
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: except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err) errors[CONF_STREAM_SOURCE] = str(err)
if err.details: self.preview_stream = None
errors["error_details"] = err.details
# Stream preview during options flow not yet implemented
still_url = user_input.get(CONF_STILL_IMAGE_URL)
if not errors: if not errors:
user_input[CONF_CONTENT_TYPE] = still_format
still_url = user_input.get(CONF_STILL_IMAGE_URL)
if still_url is None: if still_url is None:
# If user didn't specify a still image URL, # If user didn't specify a still image URL,
# The automatically generated still image that stream generates # The automatically generated still image that stream generates
@ -471,9 +467,9 @@ class GenericOptionsFlowHandler(OptionsFlow):
self.user_input = data self.user_input = data
# temporary preview for user to check the image # temporary preview for user to check the image
self.preview_cam = data self.preview_cam = data
return await self.async_step_confirm_still() return await self.async_step_user_confirm()
if "error_details" in errors: elif self.user_input:
description_placeholders["error"] = errors.pop("error_details") user_input = self.user_input
return self.async_show_form( return self.async_show_form(
step_id="init", step_id="init",
data_schema=build_schema( data_schema=build_schema(
@ -481,15 +477,17 @@ class GenericOptionsFlowHandler(OptionsFlow):
True, True,
self.show_advanced_options, self.show_advanced_options,
), ),
description_placeholders=description_placeholders,
errors=errors, errors=errors,
) )
async def async_step_confirm_still( async def async_step_user_confirm(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle user clicking confirm after still preview.""" """Handle user clicking confirm after still preview."""
if user_input: 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): if not user_input.get(CONF_CONFIRMED_OK):
return await self.async_step_init() return await self.async_step_init()
return self.async_create_entry( return self.async_create_entry(
@ -497,18 +495,22 @@ class GenericOptionsFlowHandler(OptionsFlow):
data=self.user_input, data=self.user_input,
) )
register_preview(self.hass) register_preview(self.hass)
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
return self.async_show_form( return self.async_show_form(
step_id="confirm_still", step_id="user_confirm",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(CONF_CONFIRMED_OK, default=False): bool, vol.Required(CONF_CONFIRMED_OK, default=False): bool,
} }
), ),
description_placeholders={"preview_url": preview_url},
errors=None, 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): class CameraImagePreview(HomeAssistantView):
"""Camera view to temporarily serve an image.""" """Camera view to temporarily serve an image."""
@ -550,7 +552,7 @@ class CameraImagePreview(HomeAssistantView):
{ {
vol.Required("type"): "generic_camera/start_preview", vol.Required("type"): "generic_camera/start_preview",
vol.Required("flow_id"): str, vol.Required("flow_id"): str,
vol.Optional("flow_type"): vol.Any("config_flow"), vol.Optional("flow_type"): vol.Any("config_flow", "options_flow"),
vol.Optional("user_input"): dict, vol.Optional("user_input"): dict,
} }
) )
@ -564,10 +566,17 @@ async def ws_start_preview(
_LOGGER.debug("Generating websocket handler for generic camera preview") _LOGGER.debug("Generating websocket handler for generic camera preview")
flow_id = msg["flow_id"] flow_id = msg["flow_id"]
flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler
if msg.get("flow_type", "config_flow") == "config_flow":
flow = cast( flow = cast(
GenericIPCamConfigFlow, GenericIPCamConfigFlow,
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001 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_cam user_input = flow.preview_cam
# Create an EntityPlatform, needed for name translations # Create an EntityPlatform, needed for name translations

View File

@ -67,11 +67,11 @@
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras" "use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
} }
}, },
"confirm_still": { "user_confirm": {
"title": "Preview", "title": "Confirmation",
"description": "![Camera Still Image Preview]({preview_url})", "description": "Please wait for previews to load...",
"data": { "data": {
"confirmed_ok": "This image looks good." "confirmed_ok": "Everything looks good."
} }
} }
}, },

View File

@ -92,12 +92,6 @@ async def test_form(
) )
assert result1["type"] is FlowResultType.FORM assert result1["type"] is FlowResultType.FORM
assert result1["step_id"] == "user_confirm" assert result1["step_id"] == "user_confirm"
client = await hass_client()
preview_url = result1["description_placeholders"]["preview_url"]
# Check the preview image works.
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. # HA should now be serving a WS connection for a preview stream.
ws_client = await hass_ws_client() ws_client = await hass_ws_client()
@ -108,7 +102,14 @@ async def test_form(
"flow_id": flow_id, "flow_id": flow_id,
}, },
) )
_ = await ws_client.receive_json() json = await ws_client.receive_json()
client = await hass_client()
still_preview_url = json["event"]["attributes"]["still_url"]
# Check the preview image works.
resp = await client.get(still_preview_url)
assert resp.status == HTTPStatus.OK
assert await resp.read() == fakeimgbytes_png
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result1["flow_id"], result1["flow_id"],
@ -128,7 +129,7 @@ async def test_form(
} }
# Check that the preview image is disabled after. # Check that the preview image is disabled after.
resp = await client.get(preview_url) resp = await client.get(still_preview_url)
assert resp.status == HTTPStatus.NOT_FOUND assert resp.status == HTTPStatus.NOT_FOUND
assert len(mock_setup.mock_calls) == 1 assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -206,6 +207,7 @@ async def test_form_still_preview_cam_off(
mock_create_stream: _patch[MagicMock], mock_create_stream: _patch[MagicMock],
user_flow: ConfigFlowResult, user_flow: ConfigFlowResult,
hass_client: ClientSessionGenerator, hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None: ) -> None:
"""Test camera errors are triggered during preview.""" """Test camera errors are triggered during preview."""
with ( with (
@ -221,10 +223,23 @@ async def test_form_still_preview_cam_off(
) )
assert result1["type"] is FlowResultType.FORM assert result1["type"] is FlowResultType.FORM
assert result1["step_id"] == "user_confirm" assert result1["step_id"] == "user_confirm"
preview_url = result1["description_placeholders"]["preview_url"]
# 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,
},
)
json = await ws_client.receive_json()
client = await hass_client()
still_preview_url = json["event"]["attributes"]["still_url"]
# Try to view the image, should be unavailable. # Try to view the image, should be unavailable.
client = await hass_client() client = await hass_client()
resp = await client.get(preview_url) resp = await client.get(still_preview_url)
assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE
@ -686,7 +701,7 @@ async def test_form_no_route_to_host(
async def test_form_stream_io_error( async def test_form_stream_io_error(
hass: HomeAssistant, user_flow: ConfigFlowResult hass: HomeAssistant, user_flow: ConfigFlowResult
) -> None: ) -> None:
"""Test we handle no io error when setting up stream.""" """Test we handle an io error when setting up stream."""
with patch( with patch(
"homeassistant.components.generic.config_flow.create_stream", "homeassistant.components.generic.config_flow.create_stream",
side_effect=OSError(errno.EIO, "Input/output error"), side_effect=OSError(errno.EIO, "Input/output error"),
@ -779,7 +794,7 @@ async def test_options_template_error(
user_input=data, user_input=data,
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "confirm_still" assert result2["step_id"] == "user_confirm"
result2a = await hass.config_entries.options.async_configure( result2a = await hass.config_entries.options.async_configure(
result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} result2["flow_id"], user_input={CONF_CONFIRMED_OK: True}
@ -874,7 +889,7 @@ async def test_options_only_stream(
user_input=data, user_input=data,
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
assert result2["step_id"] == "confirm_still" assert result2["step_id"] == "user_confirm"
result3 = await hass.config_entries.options.async_configure( result3 = await hass.config_entries.options.async_configure(
result2["flow_id"], user_input={CONF_CONFIRMED_OK: True} result2["flow_id"], user_input={CONF_CONFIRMED_OK: True}
@ -883,6 +898,35 @@ async def test_options_only_stream(
assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg"
async def test_options_still_and_stream_not_provided(
hass: HomeAssistant,
) -> None:
"""Test we show a suitable error if neither still or stream URL are provided."""
data = TESTDATA.copy()
mock_entry = MockConfigEntry(
title="Test Camera",
domain=DOMAIN,
data={},
options=data,
)
mock_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_entry.entry_id)
result = await hass.config_entries.options.async_init(mock_entry.entry_id)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
data.pop(CONF_STILL_IMAGE_URL)
data.pop(CONF_STREAM_SOURCE)
result2 = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input=data,
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": "no_still_image_or_stream_url"}
@respx.mock @respx.mock
@pytest.mark.usefixtures("fakeimg_png") @pytest.mark.usefixtures("fakeimg_png")
async def test_form_options_permission_error( async def test_form_options_permission_error(
@ -976,10 +1020,15 @@ async def test_migrate_existing_ids(
@respx.mock @respx.mock
@pytest.mark.usefixtures("fakeimg_png") @pytest.mark.usefixtures("fakeimg_png")
async def test_use_wallclock_as_timestamps_option( async def test_use_wallclock_as_timestamps_option(
hass: HomeAssistant, mock_create_stream: _patch[MagicMock] hass: HomeAssistant,
mock_create_stream: _patch[MagicMock],
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
fakeimgbytes_png: bytes,
) -> None: ) -> None:
"""Test the use_wallclock_as_timestamps option flow.""" """Test the use_wallclock_as_timestamps option flow."""
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
mock_entry = MockConfigEntry( mock_entry = MockConfigEntry(
title="Test Camera", title="Test Camera",
domain=DOMAIN, domain=DOMAIN,
@ -1005,6 +1054,25 @@ async def test_use_wallclock_as_timestamps_option(
user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA},
) )
assert result2["type"] is FlowResultType.FORM assert result2["type"] is FlowResultType.FORM
ws_client = await hass_ws_client()
flow_id = result2["flow_id"]
await ws_client.send_json_auto_id(
{
"type": "generic_camera/start_preview",
"flow_id": flow_id,
"flow_type": "options_flow",
},
)
json = await ws_client.receive_json()
client = await hass_client()
still_preview_url = json["event"]["attributes"]["still_url"]
# Check the preview image works.
resp = await client.get(still_preview_url)
assert resp.status == HTTPStatus.OK
assert await resp.read() == fakeimgbytes_png
# Test what happens if user rejects the preview # Test what happens if user rejects the preview
result3 = await hass.config_entries.options.async_configure( result3 = await hass.config_entries.options.async_configure(
result2["flow_id"], user_input={CONF_CONFIRMED_OK: False} result2["flow_id"], user_input={CONF_CONFIRMED_OK: False}
@ -1020,7 +1088,7 @@ async def test_use_wallclock_as_timestamps_option(
user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA},
) )
assert result4["type"] is FlowResultType.FORM assert result4["type"] is FlowResultType.FORM
assert result4["step_id"] == "confirm_still" assert result4["step_id"] == "user_confirm"
result5 = await hass.config_entries.options.async_configure( result5 = await hass.config_entries.options.async_configure(
result4["flow_id"], result4["flow_id"],
user_input={CONF_CONFIRMED_OK: True}, user_input={CONF_CONFIRMED_OK: True},