mirror of
https://github.com/home-assistant/core.git
synced 2025-07-22 20:57:21 +00:00
Add config flow stream preview to generic camera (#122563)
Co-authored-by: Allen Porter <allen.porter@gmail.com>
This commit is contained in:
parent
3cc75c3cf6
commit
484f149e61
@ -96,10 +96,9 @@ class GenericCamera(Camera):
|
|||||||
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
|
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
|
||||||
if self._stream_source:
|
if self._stream_source:
|
||||||
self._stream_source = Template(self._stream_source, hass)
|
self._stream_source = Template(self._stream_source, hass)
|
||||||
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
|
||||||
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
|
|
||||||
if self._stream_source:
|
|
||||||
self._attr_supported_features = CameraEntityFeature.STREAM
|
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]
|
||||||
self.content_type = device_info[CONF_CONTENT_TYPE]
|
self.content_type = device_info[CONF_CONTENT_TYPE]
|
||||||
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
self.verify_ssl = device_info[CONF_VERIFY_SSL]
|
||||||
if device_info.get(CONF_RTSP_TRANSPORT):
|
if device_info.get(CONF_RTSP_TRANSPORT):
|
||||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
import contextlib
|
import contextlib
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from errno import EHOSTUNREACH, EIO
|
from errno import EHOSTUNREACH, EIO
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
@ -17,18 +17,21 @@ import PIL.Image
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
import yarl
|
import yarl
|
||||||
|
|
||||||
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.components.camera import (
|
from homeassistant.components.camera import (
|
||||||
CAMERA_IMAGE_TIMEOUT,
|
CAMERA_IMAGE_TIMEOUT,
|
||||||
|
DOMAIN as CAMERA_DOMAIN,
|
||||||
DynamicStreamSettings,
|
DynamicStreamSettings,
|
||||||
_async_get_image,
|
_async_get_image,
|
||||||
)
|
)
|
||||||
from homeassistant.components.http import HomeAssistantView
|
from homeassistant.components.http.view import HomeAssistantView
|
||||||
from homeassistant.components.stream import (
|
from homeassistant.components.stream import (
|
||||||
CONF_RTSP_TRANSPORT,
|
CONF_RTSP_TRANSPORT,
|
||||||
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
CONF_USE_WALLCLOCK_AS_TIMESTAMPS,
|
||||||
HLS_PROVIDER,
|
HLS_PROVIDER,
|
||||||
RTSP_TRANSPORTS,
|
RTSP_TRANSPORTS,
|
||||||
SOURCE_TIMEOUT,
|
SOURCE_TIMEOUT,
|
||||||
|
Stream,
|
||||||
create_stream,
|
create_stream,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
@ -49,7 +52,9 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||||
from homeassistant.helpers import config_validation as cv, template as template_helper
|
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.helpers.httpx_client import get_async_client
|
||||||
|
from homeassistant.setup import async_prepare_setup_platform
|
||||||
from homeassistant.util import slugify
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from .camera import GenericCamera, generate_auth
|
from .camera import GenericCamera, generate_auth
|
||||||
@ -79,6 +84,15 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"}
|
|||||||
IMAGE_PREVIEWS_ACTIVE = "previews"
|
IMAGE_PREVIEWS_ACTIVE = "previews"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidStreamException(HomeAssistantError):
|
||||||
|
"""Error to indicate an invalid stream."""
|
||||||
|
|
||||||
|
def __init__(self, error: str, details: str | None = None) -> None:
|
||||||
|
"""Initialize the error."""
|
||||||
|
super().__init__(error)
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
|
||||||
def build_schema(
|
def build_schema(
|
||||||
user_input: Mapping[str, Any],
|
user_input: Mapping[str, Any],
|
||||||
is_options_flow: bool = False,
|
is_options_flow: bool = False,
|
||||||
@ -231,12 +245,16 @@ def slug(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def async_test_stream(
|
async def async_test_and_preview_stream(
|
||||||
hass: HomeAssistant, info: Mapping[str, Any]
|
hass: HomeAssistant, info: Mapping[str, Any]
|
||||||
) -> dict[str, str]:
|
) -> Stream | None:
|
||||||
"""Verify that the stream is valid before we create an entity."""
|
"""Verify that the stream is valid before we create an entity.
|
||||||
|
|
||||||
|
Returns the stream object if valid. Raises InvalidStreamException if not.
|
||||||
|
The stream object is used to preview the video in the UI.
|
||||||
|
"""
|
||||||
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
|
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
|
||||||
return {}
|
return None
|
||||||
# Import from stream.worker as stream cannot reexport from worker
|
# Import from stream.worker as stream cannot reexport from worker
|
||||||
# without forcing the av dependency on default_config
|
# without forcing the av dependency on default_config
|
||||||
# pylint: disable-next=import-outside-toplevel
|
# pylint: disable-next=import-outside-toplevel
|
||||||
@ -248,7 +266,7 @@ async def async_test_stream(
|
|||||||
stream_source = stream_source.async_render(parse_result=False)
|
stream_source = stream_source.async_render(parse_result=False)
|
||||||
except TemplateError as err:
|
except TemplateError as err:
|
||||||
_LOGGER.warning("Problem rendering template %s: %s", stream_source, 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] = {}
|
stream_options: dict[str, str | bool | float] = {}
|
||||||
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
|
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
|
||||||
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport
|
||||||
@ -257,10 +275,10 @@ async def async_test_stream(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
url = yarl.URL(stream_source)
|
url = yarl.URL(stream_source)
|
||||||
except ValueError:
|
except ValueError as err:
|
||||||
return {CONF_STREAM_SOURCE: "malformed_url"}
|
raise InvalidStreamException("malformed_url") from err
|
||||||
if not url.is_absolute():
|
if not url.is_absolute():
|
||||||
return {CONF_STREAM_SOURCE: "relative_url"}
|
raise InvalidStreamException("relative_url")
|
||||||
if not url.user and not url.password:
|
if not url.user and not url.password:
|
||||||
username = info.get(CONF_USERNAME)
|
username = info.get(CONF_USERNAME)
|
||||||
password = info.get(CONF_PASSWORD)
|
password = info.get(CONF_PASSWORD)
|
||||||
@ -273,29 +291,28 @@ async def async_test_stream(
|
|||||||
stream_source,
|
stream_source,
|
||||||
stream_options,
|
stream_options,
|
||||||
DynamicStreamSettings(),
|
DynamicStreamSettings(),
|
||||||
"test_stream",
|
f"{DOMAIN}.test_stream",
|
||||||
)
|
)
|
||||||
hls_provider = stream.add_provider(HLS_PROVIDER)
|
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:
|
except StreamWorkerError as err:
|
||||||
return {CONF_STREAM_SOURCE: "unknown_with_details", "error_details": str(err)}
|
raise InvalidStreamException("unknown_with_details", str(err)) from err
|
||||||
except PermissionError:
|
except PermissionError as err:
|
||||||
return {CONF_STREAM_SOURCE: "stream_not_permitted"}
|
raise InvalidStreamException("stream_not_permitted") from err
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
if err.errno == EHOSTUNREACH:
|
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
|
if err.errno == EIO: # input/output error
|
||||||
return {CONF_STREAM_SOURCE: "stream_io_error"}
|
raise InvalidStreamException("stream_io_error") from err
|
||||||
raise
|
raise
|
||||||
except HomeAssistantError as err:
|
except HomeAssistantError as err:
|
||||||
if "Stream integration is not set up" in str(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
|
raise
|
||||||
return {}
|
await stream.start()
|
||||||
|
if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT):
|
||||||
|
hass.async_create_task(stream.stop())
|
||||||
|
raise InvalidStreamException("timeout")
|
||||||
|
return stream
|
||||||
|
|
||||||
|
|
||||||
def register_preview(hass: HomeAssistant) -> None:
|
def register_preview(hass: HomeAssistant) -> None:
|
||||||
@ -316,6 +333,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Initialize Generic ConfigFlow."""
|
"""Initialize Generic ConfigFlow."""
|
||||||
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] = {}
|
||||||
self.title = ""
|
self.title = ""
|
||||||
|
|
||||||
@ -326,14 +344,6 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Get the options flow for this handler."""
|
"""Get the options flow for this handler."""
|
||||||
return GenericOptionsFlowHandler()
|
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(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -349,10 +359,17 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors["base"] = "no_still_image_or_stream_url"
|
errors["base"] = "no_still_image_or_stream_url"
|
||||||
else:
|
else:
|
||||||
errors, still_format = await async_test_still(hass, user_input)
|
errors, still_format = await async_test_still(hass, user_input)
|
||||||
errors = errors | await async_test_stream(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
|
||||||
|
self.preview_stream = None
|
||||||
if not errors:
|
if not errors:
|
||||||
user_input[CONF_CONTENT_TYPE] = still_format
|
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)
|
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||||
stream_url = user_input.get(CONF_STREAM_SOURCE)
|
stream_url = user_input.get(CONF_STREAM_SOURCE)
|
||||||
name = (
|
name = (
|
||||||
@ -365,14 +382,9 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
user_input[CONF_CONTENT_TYPE] = "image/jpeg"
|
user_input[CONF_CONTENT_TYPE] = "image/jpeg"
|
||||||
self.user_input = user_input
|
self.user_input = user_input
|
||||||
self.title = name
|
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
|
# 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_still()
|
return await self.async_step_user_confirm()
|
||||||
if "error_details" in errors:
|
if "error_details" in errors:
|
||||||
description_placeholders["error"] = errors.pop("error_details")
|
description_placeholders["error"] = errors.pop("error_details")
|
||||||
elif self.user_input:
|
elif self.user_input:
|
||||||
@ -386,11 +398,14 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_user_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_user()
|
return await self.async_step_user()
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
@ -399,7 +414,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
register_preview(self.hass)
|
register_preview(self.hass)
|
||||||
preview_url = f"/api/generic/preview_flow_image/{self.flow_id}?t={datetime.now().isoformat()}"
|
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_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,
|
||||||
@ -407,8 +422,14 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
),
|
),
|
||||||
description_placeholders={"preview_url": preview_url},
|
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 GenericOptionsFlowHandler(OptionsFlow):
|
class GenericOptionsFlowHandler(OptionsFlow):
|
||||||
"""Handle Generic IP Camera options."""
|
"""Handle Generic IP Camera options."""
|
||||||
@ -423,13 +444,21 @@ 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 is not None:
|
||||||
errors, still_format = await async_test_still(
|
errors, still_format = await async_test_still(
|
||||||
hass, self.config_entry.options | user_input
|
hass, self.config_entry.options | user_input
|
||||||
)
|
)
|
||||||
errors = errors | await async_test_stream(hass, 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)
|
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||||
if not errors:
|
if not errors:
|
||||||
if still_url is None:
|
if still_url is None:
|
||||||
@ -449,6 +478,8 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
|||||||
# 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_confirm_still()
|
||||||
|
if "error_details" in errors:
|
||||||
|
description_placeholders["error"] = errors.pop("error_details")
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
data_schema=build_schema(
|
data_schema=build_schema(
|
||||||
@ -456,6 +487,7 @@ class GenericOptionsFlowHandler(OptionsFlow):
|
|||||||
True,
|
True,
|
||||||
self.show_advanced_options,
|
self.show_advanced_options,
|
||||||
),
|
),
|
||||||
|
description_placeholders=description_placeholders,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -518,3 +550,59 @@ class CameraImagePreview(HomeAssistantView):
|
|||||||
CAMERA_IMAGE_TIMEOUT,
|
CAMERA_IMAGE_TIMEOUT,
|
||||||
)
|
)
|
||||||
return web.Response(body=image.content, content_type=image.content_type)
|
return web.Response(body=image.content, content_type=image.content_type)
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "generic_camera/start_preview",
|
||||||
|
vol.Required("flow_id"): str,
|
||||||
|
vol.Optional("flow_type"): vol.Any("config_flow"),
|
||||||
|
vol.Optional("user_input"): dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_start_preview(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
connection: websocket_api.ActiveConnection,
|
||||||
|
msg: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Generate websocket handler for the camera still/stream preview."""
|
||||||
|
_LOGGER.debug("Generating websocket handler for generic camera preview")
|
||||||
|
|
||||||
|
flow_id = msg["flow_id"]
|
||||||
|
flow = cast(
|
||||||
|
GenericIPCamConfigFlow,
|
||||||
|
hass.config_entries.flow._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)
|
||||||
|
entity_platform = EntityPlatform(
|
||||||
|
hass=hass,
|
||||||
|
logger=_LOGGER,
|
||||||
|
domain=CAMERA_DOMAIN,
|
||||||
|
platform_name=DOMAIN,
|
||||||
|
platform=platform,
|
||||||
|
scan_interval=timedelta(seconds=3600),
|
||||||
|
entity_namespace=None,
|
||||||
|
)
|
||||||
|
await entity_platform.async_load_translations()
|
||||||
|
|
||||||
|
ha_still_url = None
|
||||||
|
ha_stream_url = None
|
||||||
|
|
||||||
|
if user_input.get(CONF_STILL_IMAGE_URL):
|
||||||
|
ha_still_url = f"/api/generic/preview_flow_image/{msg['flow_id']}?t={datetime.now().isoformat()}"
|
||||||
|
_LOGGER.debug("Got preview still URL: %s", ha_still_url)
|
||||||
|
|
||||||
|
if ha_stream := flow.preview_stream:
|
||||||
|
ha_stream_url = ha_stream.endpoint_url(HLS_PROVIDER)
|
||||||
|
_LOGGER.debug("Got preview stream URL: %s", ha_stream_url)
|
||||||
|
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(
|
||||||
|
msg["id"],
|
||||||
|
{"attributes": {"still_url": ha_still_url, "stream_url": ha_stream_url}},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"name": "Generic Camera",
|
"name": "Generic Camera",
|
||||||
"codeowners": ["@davet2001"],
|
"codeowners": ["@davet2001"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["http"],
|
"dependencies": ["http", "stream"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
|
@ -39,11 +39,11 @@
|
|||||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user_confirm_still": {
|
"user_confirm": {
|
||||||
"title": "Preview",
|
"title": "Confirmation",
|
||||||
"description": "",
|
"description": "Please wait for previews to load...",
|
||||||
"data": {
|
"data": {
|
||||||
"confirmed_ok": "This image looks good."
|
"confirmed_ok": "Everything looks good."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -68,15 +68,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"confirm_still": {
|
"confirm_still": {
|
||||||
"title": "[%key:component::generic::config::step::user_confirm_still::title%]",
|
"title": "Preview",
|
||||||
"description": "[%key:component::generic::config::step::user_confirm_still::description%]",
|
"description": "",
|
||||||
"data": {
|
"data": {
|
||||||
"confirmed_ok": "[%key:component::generic::config::step::user_confirm_still::data::confirmed_ok%]"
|
"confirmed_ok": "This image looks good."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
"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%]",
|
"already_exists": "[%key:component::generic::config::error::already_exists%]",
|
||||||
"unable_still_load": "[%key:component::generic::config::error::unable_still_load%]",
|
"unable_still_load": "[%key:component::generic::config::error::unable_still_load%]",
|
||||||
"unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]",
|
"unable_still_load_auth": "[%key:component::generic::config::error::unable_still_load_auth%]",
|
||||||
|
@ -71,16 +71,18 @@ def fakeimg_gif(fakeimgbytes_gif: bytes) -> Generator[None]:
|
|||||||
respx.pop("fake_img")
|
respx.pop("fake_img")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="package")
|
@pytest.fixture
|
||||||
def mock_create_stream() -> _patch[MagicMock]:
|
def mock_create_stream(hass: HomeAssistant) -> _patch[MagicMock]:
|
||||||
"""Mock create stream."""
|
"""Mock create stream."""
|
||||||
mock_stream = Mock()
|
mock_stream = MagicMock()
|
||||||
|
mock_stream.hass = hass
|
||||||
mock_provider = Mock()
|
mock_provider = Mock()
|
||||||
mock_provider.part_recv = AsyncMock()
|
mock_provider.part_recv = AsyncMock()
|
||||||
mock_provider.part_recv.return_value = True
|
mock_provider.part_recv.return_value = True
|
||||||
mock_stream.add_provider.return_value = mock_provider
|
mock_stream.add_provider.return_value = mock_provider
|
||||||
mock_stream.start = AsyncMock()
|
mock_stream.start = AsyncMock()
|
||||||
mock_stream.stop = AsyncMock()
|
mock_stream.stop = AsyncMock()
|
||||||
|
mock_stream.endpoint_url.return_value = "http://127.0.0.1/nothing"
|
||||||
return patch(
|
return patch(
|
||||||
"homeassistant.components.generic.config_flow.create_stream",
|
"homeassistant.components.generic.config_flow.create_stream",
|
||||||
return_value=mock_stream,
|
return_value=mock_stream,
|
||||||
|
@ -9,6 +9,7 @@ import os.path
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, _patch, patch
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
@ -44,8 +45,8 @@ from homeassistant.data_entry_flow import FlowResultType
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity_registry as er
|
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
|
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||||
|
|
||||||
TESTDATA = {
|
TESTDATA = {
|
||||||
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
@ -75,6 +76,7 @@ async def test_form(
|
|||||||
hass_client: ClientSessionGenerator,
|
hass_client: ClientSessionGenerator,
|
||||||
user_flow: ConfigFlowResult,
|
user_flow: ConfigFlowResult,
|
||||||
mock_create_stream: _patch[MagicMock],
|
mock_create_stream: _patch[MagicMock],
|
||||||
|
hass_ws_client: WebSocketGenerator,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the form with a normal set of settings."""
|
"""Test the form with a normal set of settings."""
|
||||||
|
|
||||||
@ -90,18 +92,29 @@ async def test_form(
|
|||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
assert result1["type"] is FlowResultType.FORM
|
||||||
assert result1["step_id"] == "user_confirm_still"
|
assert result1["step_id"] == "user_confirm"
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
preview_url = result1["description_placeholders"]["preview_url"]
|
preview_url = result1["description_placeholders"]["preview_url"]
|
||||||
# Check the preview image works.
|
# Check the preview image works.
|
||||||
resp = await client.get(preview_url)
|
resp = await client.get(preview_url)
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
assert await resp.read() == fakeimgbytes_png
|
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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_ = await ws_client.receive_json()
|
||||||
|
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: True},
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||||
assert result2["title"] == "127_0_0_1"
|
assert result2["title"] == "127_0_0_1"
|
||||||
assert result2["options"] == {
|
assert result2["options"] == {
|
||||||
@ -110,13 +123,11 @@ async def test_form(
|
|||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
CONF_USERNAME: "fred_flintstone",
|
CONF_USERNAME: "fred_flintstone",
|
||||||
CONF_PASSWORD: "bambam",
|
CONF_PASSWORD: "bambam",
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
|
||||||
CONF_CONTENT_TYPE: "image/png",
|
CONF_CONTENT_TYPE: "image/png",
|
||||||
CONF_FRAMERATE: 5,
|
CONF_FRAMERATE: 5.0,
|
||||||
CONF_VERIFY_SSL: False,
|
CONF_VERIFY_SSL: False,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
# 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(preview_url)
|
||||||
assert resp.status == HTTPStatus.NOT_FOUND
|
assert resp.status == HTTPStatus.NOT_FOUND
|
||||||
@ -145,7 +156,7 @@ async def test_form_only_stillimage(
|
|||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: True},
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
@ -157,9 +168,8 @@ async def test_form_only_stillimage(
|
|||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
CONF_USERNAME: "fred_flintstone",
|
CONF_USERNAME: "fred_flintstone",
|
||||||
CONF_PASSWORD: "bambam",
|
CONF_PASSWORD: "bambam",
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
|
||||||
CONF_CONTENT_TYPE: "image/png",
|
CONF_CONTENT_TYPE: "image/png",
|
||||||
CONF_FRAMERATE: 5,
|
CONF_FRAMERATE: 5.0,
|
||||||
CONF_VERIFY_SSL: False,
|
CONF_VERIFY_SSL: False,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -167,13 +177,13 @@ async def test_form_only_stillimage(
|
|||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_form_reject_still_preview(
|
async def test_form_reject_preview(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
fakeimgbytes_png: bytes,
|
fakeimgbytes_png: bytes,
|
||||||
mock_create_stream: _patch[MagicMock],
|
mock_create_stream: _patch[MagicMock],
|
||||||
user_flow: ConfigFlowResult,
|
user_flow: ConfigFlowResult,
|
||||||
) -> None:
|
) -> 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)
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
|
||||||
with mock_create_stream:
|
with mock_create_stream:
|
||||||
result1 = await hass.config_entries.flow.async_configure(
|
result1 = await hass.config_entries.flow.async_configure(
|
||||||
@ -181,7 +191,7 @@ async def test_form_reject_still_preview(
|
|||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: False},
|
user_input={CONF_CONFIRMED_OK: False},
|
||||||
@ -211,7 +221,7 @@ async def test_form_still_preview_cam_off(
|
|||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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"]
|
preview_url = result1["description_placeholders"]["preview_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()
|
||||||
@ -233,7 +243,7 @@ async def test_form_only_stillimage_gif(
|
|||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: True},
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
@ -258,7 +268,7 @@ async def test_form_only_svg_whitespace(
|
|||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: True},
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
@ -293,7 +303,7 @@ async def test_form_only_still_sample(
|
|||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: True},
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
@ -310,13 +320,13 @@ async def test_form_only_still_sample(
|
|||||||
(
|
(
|
||||||
"http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png",
|
"http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png",
|
||||||
"http://localhost:8123/static/icons/favicon-apple-180x180.png",
|
"http://localhost:8123/static/icons/favicon-apple-180x180.png",
|
||||||
"user_confirm_still",
|
"user_confirm",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"{% if 1 %}https://bla{% else %}https://yo{% endif %}",
|
"{% if 1 %}https://bla{% else %}https://yo{% endif %}",
|
||||||
"https://bla/",
|
"https://bla/",
|
||||||
"user_confirm_still",
|
"user_confirm",
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -385,7 +395,7 @@ async def test_form_rtsp_mode(
|
|||||||
user_flow["flow_id"], data
|
user_flow["flow_id"], data
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.FORM
|
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(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result1["flow_id"],
|
result1["flow_id"],
|
||||||
user_input={CONF_CONFIRMED_OK: True},
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
@ -399,13 +409,11 @@ async def test_form_rtsp_mode(
|
|||||||
CONF_RTSP_TRANSPORT: "tcp",
|
CONF_RTSP_TRANSPORT: "tcp",
|
||||||
CONF_USERNAME: "fred_flintstone",
|
CONF_USERNAME: "fred_flintstone",
|
||||||
CONF_PASSWORD: "bambam",
|
CONF_PASSWORD: "bambam",
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
|
||||||
CONF_CONTENT_TYPE: "image/png",
|
CONF_CONTENT_TYPE: "image/png",
|
||||||
CONF_FRAMERATE: 5,
|
CONF_FRAMERATE: 5.0,
|
||||||
CONF_VERIFY_SSL: False,
|
CONF_VERIFY_SSL: False,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert len(mock_setup.mock_calls) == 1
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
@ -419,33 +427,36 @@ async def test_form_only_stream(
|
|||||||
data = TESTDATA.copy()
|
data = TESTDATA.copy()
|
||||||
data.pop(CONF_STILL_IMAGE_URL)
|
data.pop(CONF_STILL_IMAGE_URL)
|
||||||
data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2"
|
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(
|
result1 = await hass.config_entries.flow.async_configure(
|
||||||
user_flow["flow_id"],
|
user_flow["flow_id"],
|
||||||
data,
|
data,
|
||||||
)
|
)
|
||||||
assert result1["type"] is FlowResultType.CREATE_ENTRY
|
|
||||||
assert result1["title"] == "127_0_0_1"
|
assert result1["type"] is FlowResultType.FORM
|
||||||
assert result1["options"] == {
|
with mock_create_stream:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result1["flow_id"],
|
||||||
|
user_input={CONF_CONFIRMED_OK: True},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2["title"] == "127_0_0_1"
|
||||||
|
assert result2["options"] == {
|
||||||
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2",
|
CONF_STREAM_SOURCE: "rtsp://user:pass@127.0.0.1/testurl/2",
|
||||||
CONF_USERNAME: "fred_flintstone",
|
CONF_USERNAME: "fred_flintstone",
|
||||||
CONF_PASSWORD: "bambam",
|
CONF_PASSWORD: "bambam",
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
|
||||||
CONF_CONTENT_TYPE: "image/jpeg",
|
CONF_CONTENT_TYPE: "image/jpeg",
|
||||||
CONF_FRAMERATE: 5,
|
CONF_FRAMERATE: 5.0,
|
||||||
CONF_VERIFY_SSL: False,
|
CONF_VERIFY_SSL: False,
|
||||||
}
|
}
|
||||||
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.camera._async_get_stream_image",
|
"homeassistant.components.camera._async_get_stream_image",
|
||||||
return_value=fakeimgbytes_jpg,
|
return_value=fakeimgbytes_jpg,
|
||||||
):
|
):
|
||||||
image_obj = await async_get_image(hass, "camera.127_0_0_1")
|
image_obj = await async_get_image(hass, "camera.127_0_0_1")
|
||||||
assert image_obj.content == fakeimgbytes_jpg
|
assert image_obj.content == fakeimgbytes_jpg
|
||||||
assert len(mock_setup.mock_calls) == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_form_still_and_stream_not_provided(
|
async def test_form_still_and_stream_not_provided(
|
||||||
@ -512,7 +523,6 @@ async def test_form_image_http_exceptions(
|
|||||||
user_flow["flow_id"],
|
user_flow["flow_id"],
|
||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.FORM
|
assert result2["type"] is FlowResultType.FORM
|
||||||
assert result2["errors"] == expected_message
|
assert result2["errors"] == expected_message
|
||||||
@ -531,7 +541,6 @@ async def test_form_stream_invalidimage(
|
|||||||
user_flow["flow_id"],
|
user_flow["flow_id"],
|
||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.FORM
|
assert result2["type"] is FlowResultType.FORM
|
||||||
assert result2["errors"] == {"still_image_url": "invalid_still_image"}
|
assert result2["errors"] == {"still_image_url": "invalid_still_image"}
|
||||||
@ -550,7 +559,6 @@ async def test_form_stream_invalidimage2(
|
|||||||
user_flow["flow_id"],
|
user_flow["flow_id"],
|
||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.FORM
|
assert result2["type"] is FlowResultType.FORM
|
||||||
assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"}
|
assert result2["errors"] == {"still_image_url": "unable_still_load_no_image"}
|
||||||
@ -569,7 +577,6 @@ async def test_form_stream_invalidimage3(
|
|||||||
user_flow["flow_id"],
|
user_flow["flow_id"],
|
||||||
TESTDATA,
|
TESTDATA,
|
||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert result2["type"] is FlowResultType.FORM
|
assert result2["type"] is FlowResultType.FORM
|
||||||
assert result2["errors"] == {"still_image_url": "invalid_still_image"}
|
assert result2["errors"] == {"still_image_url": "invalid_still_image"}
|
||||||
@ -585,6 +592,8 @@ async def test_form_stream_timeout(
|
|||||||
"homeassistant.components.generic.config_flow.create_stream"
|
"homeassistant.components.generic.config_flow.create_stream"
|
||||||
) as create_stream:
|
) as create_stream:
|
||||||
create_stream.return_value.start = AsyncMock()
|
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 = AsyncMock()
|
||||||
create_stream.return_value.add_provider.return_value.part_recv.return_value = (
|
create_stream.return_value.add_provider.return_value.part_recv.return_value = (
|
||||||
False
|
False
|
||||||
@ -727,6 +736,37 @@ 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()
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_options_template_error(
|
async def test_options_template_error(
|
||||||
hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock]
|
hass: HomeAssistant, fakeimgbytes_png: bytes, mock_create_stream: _patch[MagicMock]
|
||||||
@ -842,7 +882,6 @@ async def test_options_only_stream(
|
|||||||
)
|
)
|
||||||
mock_entry.add_to_hass(hass)
|
mock_entry.add_to_hass(hass)
|
||||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
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)
|
result = await hass.config_entries.options.async_init(mock_entry.entry_id)
|
||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
@ -864,6 +903,27 @@ async def test_options_only_stream(
|
|||||||
assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg"
|
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")
|
@pytest.mark.usefixtures("fakeimg_png")
|
||||||
async def test_unload_entry(hass: HomeAssistant) -> None:
|
async def test_unload_entry(hass: HomeAssistant) -> None:
|
||||||
"""Test unloading the generic IP Camera entry."""
|
"""Test unloading the generic IP Camera entry."""
|
||||||
|
Loading…
x
Reference in New Issue
Block a user