Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add stream preview to options flow in generic camera #133927

Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 65 additions & 44 deletions homeassistant/components/generic/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,15 +406,13 @@
title=self.title, data={}, options=self.user_input
)
register_preview(self.hass)
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
davet2001 marked this conversation as resolved.
Show resolved Hide resolved
return self.async_show_form(
step_id="user_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
}
),
description_placeholders={"preview_url": preview_url},
errors=None,
preview="generic_camera",
)
Expand All @@ -431,6 +429,7 @@
def __init__(self) -> None:
"""Initialize Generic IP Camera options flow."""
self.preview_cam: dict[str, Any] = {}
self.preview_stream: Stream | None = None
self.user_input: dict[str, Any] = {}

async def async_step_init(
Expand All @@ -441,39 +440,47 @@
description_placeholders = {}
hass = self.hass

if user_input is not None:
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)
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)
if not errors:
if still_url is None:
# If user didn't specify a still image URL,
# The automatically generated still image that stream generates
# is always jpeg
still_format = "image/jpeg"
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input,
CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE),
}
self.user_input = data
# temporary preview for user to check the image
self.preview_cam = data
return await self.async_step_confirm_still()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")
if user_input:
# Secondary validation because serialised vol can't seem to handle this complexity:
if not user_input.get(CONF_STILL_IMAGE_URL) and not user_input.get(
CONF_STREAM_SOURCE
):
errors["base"] = "no_still_image_or_stream_url"
else:
errors, still_format = await async_test_still(hass, user_input)
try:
self.preview_stream = await async_test_and_preview_stream(
hass, user_input
)
except InvalidStreamException as err:
errors[CONF_STREAM_SOURCE] = str(err)
if err.details:
errors["error_details"] = err.details

Check warning on line 458 in homeassistant/components/generic/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/generic/config_flow.py#L458

Added line #L458 was not covered by tests
self.preview_stream = None
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 user didn't specify a still image URL,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this just be handled inside of async_test_still? This seems like extra detail spilling out.

(I realize this is not changing in this PR, but it shows up as a diff)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a copy/paste of what is in the existing config flow, so designed to be as similar as possible.
I would like to rationalise these into one combined function for config_flow/options_flow in future.

Yes, the image format assignment could probably go into async_test_still, but I would prefer to do that as a separate PR rather than make this one bigger.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow up PR to address this point: #134330

# The automatically generated still image that stream generates
# is always jpeg
still_format = "image/jpeg"
data = {
CONF_USE_WALLCLOCK_AS_TIMESTAMPS: self.config_entry.options.get(
CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False
),
**user_input,
CONF_CONTENT_TYPE: still_format
or self.config_entry.options.get(CONF_CONTENT_TYPE),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also seems like it could be handled there given there is already code doing stuff like this and determining the defaults

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I agree. I suggest it is put in a follow up PR though.

}
self.user_input = data
# temporary preview for user to check the image
self.preview_cam = data
return await self.async_step_user_confirm()
if "error_details" in errors:
description_placeholders["error"] = errors.pop("error_details")

Check warning on line 481 in homeassistant/components/generic/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/generic/config_flow.py#L481

Added line #L481 was not covered by tests
allenporter marked this conversation as resolved.
Show resolved Hide resolved
elif self.user_input:
user_input = self.user_input
allenporter marked this conversation as resolved.
Show resolved Hide resolved
return self.async_show_form(
step_id="init",
data_schema=build_schema(
Expand All @@ -485,30 +492,37 @@
errors=errors,
)

async def async_step_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.preview_stream:
# Kill off the temp stream we created.
await ha_stream.stop()
if not user_input.get(CONF_CONFIRMED_OK):
return await self.async_step_init()
return self.async_create_entry(
title=self.config_entry.title,
data=self.user_input,
)
register_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="confirm_still",
step_id="user_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_CONFIRMED_OK, default=False): bool,
}
),
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 CameraImagePreview(HomeAssistantView):
"""Camera view to temporarily serve an image."""
Expand Down Expand Up @@ -550,7 +564,7 @@
{
vol.Required("type"): "generic_camera/start_preview",
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,
}
)
Expand All @@ -564,10 +578,17 @@
_LOGGER.debug("Generating websocket handler for generic camera preview")

flow_id = msg["flow_id"]
flow = cast(
GenericIPCamConfigFlow,
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
)
flow: GenericIPCamConfigFlow | GenericOptionsFlowHandler
if msg.get("flow_type", "config_flow") == "config_flow":
flow = cast(
GenericIPCamConfigFlow,
hass.config_entries.flow._progress.get(flow_id), # noqa: SLF001
)
else: # (flow type == "options flow")
flow = cast(
GenericOptionsFlowHandler,
hass.config_entries.options._progress.get(flow_id), # noqa: SLF001
)
user_input = flow.preview_cam

# Create an EntityPlatform, needed for name translations
Expand Down
8 changes: 4 additions & 4 deletions homeassistant/components/generic/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,11 @@
"use_wallclock_as_timestamps": "This option may correct segmenting or crashing issues arising from buggy timestamp implementations on some cameras"
}
},
"confirm_still": {
"title": "Preview",
"description": "![Camera Still Image Preview]({preview_url})",
"user_confirm": {
"title": "Confirmation",
"description": "Please wait for previews to load...",
"data": {
"confirmed_ok": "This image looks good."
"confirmed_ok": "Everything looks good."
}
}
},
Expand Down
96 changes: 82 additions & 14 deletions tests/components/generic/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,6 @@ async def test_form(
)
assert result1["type"] is FlowResultType.FORM
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.
ws_client = await hass_ws_client()
Expand All @@ -108,7 +102,14 @@ async def test_form(
"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(
result1["flow_id"],
Expand All @@ -128,7 +129,7 @@ async def test_form(
}

# 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 len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
Expand Down Expand Up @@ -206,6 +207,7 @@ async def test_form_still_preview_cam_off(
mock_create_stream: _patch[MagicMock],
user_flow: ConfigFlowResult,
hass_client: ClientSessionGenerator,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test camera errors are triggered during preview."""
with (
Expand All @@ -221,10 +223,23 @@ async def test_form_still_preview_cam_off(
)
assert result1["type"] is FlowResultType.FORM
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.
client = await hass_client()
resp = await client.get(preview_url)
resp = await client.get(still_preview_url)
assert resp.status == HTTPStatus.SERVICE_UNAVAILABLE


Expand Down Expand Up @@ -779,7 +794,7 @@ async def test_options_template_error(
user_input=data,
)
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(
result2["flow_id"], user_input={CONF_CONFIRMED_OK: True}
Expand Down Expand Up @@ -874,7 +889,7 @@ async def test_options_only_stream(
user_input=data,
)
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(
result2["flow_id"], user_input={CONF_CONFIRMED_OK: True}
Expand All @@ -883,6 +898,35 @@ async def test_options_only_stream(
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
@pytest.mark.usefixtures("fakeimg_png")
async def test_form_options_permission_error(
Expand Down Expand Up @@ -976,10 +1020,15 @@ async def test_migrate_existing_ids(
@respx.mock
@pytest.mark.usefixtures("fakeimg_png")
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:
"""Test the use_wallclock_as_timestamps option flow."""

respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
mock_entry = MockConfigEntry(
title="Test Camera",
domain=DOMAIN,
Expand All @@ -1005,6 +1054,25 @@ async def test_use_wallclock_as_timestamps_option(
user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA},
)
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
result3 = await hass.config_entries.options.async_configure(
result2["flow_id"], user_input={CONF_CONFIRMED_OK: False}
Expand All @@ -1020,7 +1088,7 @@ async def test_use_wallclock_as_timestamps_option(
user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA},
)
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(
result4["flow_id"],
user_input={CONF_CONFIRMED_OK: True},
Expand Down