Generic IP Camera configflow 2 (#52360)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
parent
de130d3b28
commit
c1a2be72fc
14 changed files with 1296 additions and 190 deletions
|
@ -359,6 +359,8 @@ homeassistant/components/garages_amsterdam/* @klaasnicolaas
|
||||||
tests/components/garages_amsterdam/* @klaasnicolaas
|
tests/components/garages_amsterdam/* @klaasnicolaas
|
||||||
homeassistant/components/gdacs/* @exxamalte
|
homeassistant/components/gdacs/* @exxamalte
|
||||||
tests/components/gdacs/* @exxamalte
|
tests/components/gdacs/* @exxamalte
|
||||||
|
homeassistant/components/generic/* @davet2001
|
||||||
|
tests/components/generic/* @davet2001
|
||||||
homeassistant/components/generic_hygrostat/* @Shulyaka
|
homeassistant/components/generic_hygrostat/* @Shulyaka
|
||||||
tests/components/generic_hygrostat/* @Shulyaka
|
tests/components/generic_hygrostat/* @Shulyaka
|
||||||
homeassistant/components/geniushub/* @zxdavb
|
homeassistant/components/geniushub/* @zxdavb
|
||||||
|
|
|
@ -1,6 +1,28 @@
|
||||||
"""The generic component."""
|
"""The generic component."""
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
DOMAIN = "generic"
|
DOMAIN = "generic"
|
||||||
PLATFORMS = [Platform.CAMERA]
|
PLATFORMS = [Platform.CAMERA]
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Handle options update."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Set up generic IP camera from a config entry."""
|
||||||
|
|
||||||
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
|
|
|
@ -12,6 +12,7 @@ from homeassistant.components.camera import (
|
||||||
SUPPORT_STREAM,
|
SUPPORT_STREAM,
|
||||||
Camera,
|
Camera,
|
||||||
)
|
)
|
||||||
|
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_AUTHENTICATION,
|
CONF_AUTHENTICATION,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
|
@ -23,15 +24,13 @@ from homeassistant.const import (
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import TemplateError
|
from homeassistant.exceptions import TemplateError
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv, template as template_helper
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.httpx_client import get_async_client
|
from homeassistant.helpers.httpx_client import get_async_client
|
||||||
from homeassistant.helpers.reload import async_setup_reload_service
|
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
from . import DOMAIN, PLATFORMS
|
from . import DOMAIN
|
||||||
from .const import (
|
from .const import (
|
||||||
ALLOWED_RTSP_TRANSPORT_PROTOCOLS,
|
|
||||||
CONF_CONTENT_TYPE,
|
CONF_CONTENT_TYPE,
|
||||||
CONF_FRAMERATE,
|
CONF_FRAMERATE,
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||||
|
@ -41,6 +40,7 @@ from .const import (
|
||||||
DEFAULT_NAME,
|
DEFAULT_NAME,
|
||||||
FFMPEG_OPTION_MAP,
|
FFMPEG_OPTION_MAP,
|
||||||
GET_IMAGE_TIMEOUT,
|
GET_IMAGE_TIMEOUT,
|
||||||
|
RTSP_TRANSPORTS,
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -62,7 +62,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||||
cv.small_float, cv.positive_int
|
cv.small_float, cv.positive_int
|
||||||
),
|
),
|
||||||
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
|
||||||
vol.Optional(CONF_RTSP_TRANSPORT): vol.In(ALLOWED_RTSP_TRANSPORT_PROTOCOLS),
|
vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS.keys()),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -75,25 +75,78 @@ async def async_setup_platform(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up a generic IP Camera."""
|
"""Set up a generic IP Camera."""
|
||||||
|
|
||||||
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
|
_LOGGER.warning(
|
||||||
|
"Loading generic IP camera via configuration.yaml is deprecated, "
|
||||||
|
"it will be automatically imported. Once you have confirmed correct "
|
||||||
|
"operation, please remove 'generic' (IP camera) section(s) from "
|
||||||
|
"configuration.yaml"
|
||||||
|
)
|
||||||
|
image = config.get(CONF_STILL_IMAGE_URL)
|
||||||
|
stream = config.get(CONF_STREAM_SOURCE)
|
||||||
|
config_new = {
|
||||||
|
CONF_NAME: config[CONF_NAME],
|
||||||
|
CONF_STILL_IMAGE_URL: image.template if image is not None else None,
|
||||||
|
CONF_STREAM_SOURCE: stream.template if stream is not None else None,
|
||||||
|
CONF_AUTHENTICATION: config.get(CONF_AUTHENTICATION),
|
||||||
|
CONF_USERNAME: config.get(CONF_USERNAME),
|
||||||
|
CONF_PASSWORD: config.get(CONF_PASSWORD),
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE: config.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE),
|
||||||
|
CONF_CONTENT_TYPE: config.get(CONF_CONTENT_TYPE),
|
||||||
|
CONF_FRAMERATE: config.get(CONF_FRAMERATE),
|
||||||
|
CONF_VERIFY_SSL: config.get(CONF_VERIFY_SSL),
|
||||||
|
}
|
||||||
|
|
||||||
async_add_entities([GenericCamera(hass, config)])
|
hass.async_create_task(
|
||||||
|
hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": SOURCE_IMPORT}, data=config_new
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
"""Set up a generic IP Camera."""
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
[GenericCamera(hass, entry.options, entry.unique_id, entry.title)]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_auth(device_info) -> httpx.Auth | None:
|
||||||
|
"""Generate httpx.Auth object from credentials."""
|
||||||
|
username = device_info.get(CONF_USERNAME)
|
||||||
|
password = device_info.get(CONF_PASSWORD)
|
||||||
|
authentication = device_info.get(CONF_AUTHENTICATION)
|
||||||
|
if username:
|
||||||
|
if authentication == HTTP_DIGEST_AUTHENTICATION:
|
||||||
|
return httpx.DigestAuth(username=username, password=password)
|
||||||
|
return httpx.BasicAuth(username=username, password=password)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class GenericCamera(Camera):
|
class GenericCamera(Camera):
|
||||||
"""A generic implementation of an IP camera."""
|
"""A generic implementation of an IP camera."""
|
||||||
|
|
||||||
def __init__(self, hass, device_info):
|
def __init__(self, hass, device_info, identifier, title):
|
||||||
"""Initialize a generic camera."""
|
"""Initialize a generic camera."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
|
self._attr_unique_id = identifier
|
||||||
self._authentication = device_info.get(CONF_AUTHENTICATION)
|
self._authentication = device_info.get(CONF_AUTHENTICATION)
|
||||||
self._name = device_info.get(CONF_NAME)
|
self._name = device_info.get(CONF_NAME, title)
|
||||||
self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL)
|
self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL)
|
||||||
if self._still_image_url:
|
if (
|
||||||
|
not isinstance(self._still_image_url, template_helper.Template)
|
||||||
|
and self._still_image_url
|
||||||
|
):
|
||||||
|
self._still_image_url = cv.template(self._still_image_url)
|
||||||
|
if self._still_image_url not in [None, ""]:
|
||||||
self._still_image_url.hass = hass
|
self._still_image_url.hass = hass
|
||||||
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
|
self._stream_source = device_info.get(CONF_STREAM_SOURCE)
|
||||||
if self._stream_source is not None:
|
if self._stream_source not in (None, ""):
|
||||||
|
if not isinstance(self._stream_source, template_helper.Template):
|
||||||
|
self._stream_source = cv.template(self._stream_source)
|
||||||
self._stream_source.hass = hass
|
self._stream_source.hass = hass
|
||||||
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
|
||||||
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
|
self._attr_frame_interval = 1 / device_info[CONF_FRAMERATE]
|
||||||
|
@ -104,17 +157,7 @@ class GenericCamera(Camera):
|
||||||
self.stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = device_info[
|
self.stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = device_info[
|
||||||
CONF_RTSP_TRANSPORT
|
CONF_RTSP_TRANSPORT
|
||||||
]
|
]
|
||||||
|
self._auth = generate_auth(device_info)
|
||||||
username = device_info.get(CONF_USERNAME)
|
|
||||||
password = device_info.get(CONF_PASSWORD)
|
|
||||||
|
|
||||||
if username and password:
|
|
||||||
if self._authentication == HTTP_DIGEST_AUTHENTICATION:
|
|
||||||
self._auth = httpx.DigestAuth(username=username, password=password)
|
|
||||||
else:
|
|
||||||
self._auth = httpx.BasicAuth(username=username, password=password)
|
|
||||||
else:
|
|
||||||
self._auth = None
|
|
||||||
|
|
||||||
self._last_url = None
|
self._last_url = None
|
||||||
self._last_image = None
|
self._last_image = None
|
||||||
|
|
338
homeassistant/components/generic/config_flow.py
Normal file
338
homeassistant/components/generic/config_flow.py
Normal file
|
@ -0,0 +1,338 @@
|
||||||
|
"""Config flow for generic (IP Camera)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
from errno import EHOSTUNREACH, EIO
|
||||||
|
from functools import partial
|
||||||
|
import imghdr
|
||||||
|
import logging
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
from async_timeout import timeout
|
||||||
|
import av
|
||||||
|
from httpx import HTTPStatusError, RequestError, TimeoutException
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.components.stream.const import SOURCE_TIMEOUT
|
||||||
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_AUTHENTICATION,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
HTTP_BASIC_AUTHENTICATION,
|
||||||
|
HTTP_DIGEST_AUTHENTICATION,
|
||||||
|
)
|
||||||
|
from homeassistant.data_entry_flow import FlowResult
|
||||||
|
from homeassistant.exceptions import TemplateError
|
||||||
|
from homeassistant.helpers import config_validation as cv, template as template_helper
|
||||||
|
from homeassistant.helpers.httpx_client import get_async_client
|
||||||
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
|
from .camera import generate_auth
|
||||||
|
from .const import (
|
||||||
|
CONF_CONTENT_TYPE,
|
||||||
|
CONF_FRAMERATE,
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||||
|
CONF_RTSP_TRANSPORT,
|
||||||
|
CONF_STILL_IMAGE_URL,
|
||||||
|
CONF_STREAM_SOURCE,
|
||||||
|
DEFAULT_NAME,
|
||||||
|
DOMAIN,
|
||||||
|
FFMPEG_OPTION_MAP,
|
||||||
|
GET_IMAGE_TIMEOUT,
|
||||||
|
RTSP_TRANSPORTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_DATA = {
|
||||||
|
CONF_NAME: DEFAULT_NAME,
|
||||||
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||||
|
CONF_FRAMERATE: 2,
|
||||||
|
CONF_VERIFY_SSL: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_IMAGE_TYPES = ["png", "jpeg", "svg+xml"]
|
||||||
|
|
||||||
|
|
||||||
|
def build_schema(
|
||||||
|
user_input: dict[str, Any] | MappingProxyType[str, Any],
|
||||||
|
is_options_flow: bool = False,
|
||||||
|
):
|
||||||
|
"""Create schema for camera config setup."""
|
||||||
|
spec = {
|
||||||
|
vol.Optional(
|
||||||
|
CONF_STILL_IMAGE_URL,
|
||||||
|
description={"suggested_value": user_input.get(CONF_STILL_IMAGE_URL, "")},
|
||||||
|
): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_STREAM_SOURCE,
|
||||||
|
description={"suggested_value": user_input.get(CONF_STREAM_SOURCE, "")},
|
||||||
|
): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_RTSP_TRANSPORT,
|
||||||
|
description={"suggested_value": user_input.get(CONF_RTSP_TRANSPORT)},
|
||||||
|
): vol.In(RTSP_TRANSPORTS),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_AUTHENTICATION,
|
||||||
|
description={"suggested_value": user_input.get(CONF_AUTHENTICATION)},
|
||||||
|
): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
|
||||||
|
vol.Optional(
|
||||||
|
CONF_USERNAME,
|
||||||
|
description={"suggested_value": user_input.get(CONF_USERNAME, "")},
|
||||||
|
): str,
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PASSWORD,
|
||||||
|
description={"suggested_value": user_input.get(CONF_PASSWORD, "")},
|
||||||
|
): str,
|
||||||
|
vol.Required(
|
||||||
|
CONF_FRAMERATE,
|
||||||
|
description={"suggested_value": user_input.get(CONF_FRAMERATE, 2)},
|
||||||
|
): int,
|
||||||
|
vol.Required(
|
||||||
|
CONF_VERIFY_SSL, default=user_input.get(CONF_VERIFY_SSL, True)
|
||||||
|
): bool,
|
||||||
|
}
|
||||||
|
if is_options_flow:
|
||||||
|
spec[
|
||||||
|
vol.Required(
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||||
|
default=user_input.get(CONF_LIMIT_REFETCH_TO_URL_CHANGE, False),
|
||||||
|
)
|
||||||
|
] = bool
|
||||||
|
return vol.Schema(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_type(image):
|
||||||
|
"""Get the format of downloaded bytes that could be an image."""
|
||||||
|
fmt = imghdr.what(None, h=image)
|
||||||
|
if fmt is None:
|
||||||
|
# if imghdr can't figure it out, could be svg.
|
||||||
|
with contextlib.suppress(UnicodeDecodeError):
|
||||||
|
if image.decode("utf-8").startswith("<svg"):
|
||||||
|
return "svg+xml"
|
||||||
|
return fmt
|
||||||
|
|
||||||
|
|
||||||
|
async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]:
|
||||||
|
"""Verify that the still image is valid before we create an entity."""
|
||||||
|
fmt = None
|
||||||
|
if not (url := info.get(CONF_STILL_IMAGE_URL)):
|
||||||
|
return {}, None
|
||||||
|
if not isinstance(url, template_helper.Template) and url:
|
||||||
|
url = cv.template(url)
|
||||||
|
url.hass = hass
|
||||||
|
try:
|
||||||
|
url = url.async_render(parse_result=False)
|
||||||
|
except TemplateError as err:
|
||||||
|
_LOGGER.error("Error parsing template %s: %s", url, err)
|
||||||
|
return {CONF_STILL_IMAGE_URL: "template_error"}, None
|
||||||
|
verify_ssl = info.get(CONF_VERIFY_SSL)
|
||||||
|
auth = generate_auth(info)
|
||||||
|
try:
|
||||||
|
async_client = get_async_client(hass, verify_ssl=verify_ssl)
|
||||||
|
async with timeout(GET_IMAGE_TIMEOUT):
|
||||||
|
response = await async_client.get(url, auth=auth, timeout=GET_IMAGE_TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
image = response.content
|
||||||
|
except (
|
||||||
|
TimeoutError,
|
||||||
|
RequestError,
|
||||||
|
HTTPStatusError,
|
||||||
|
TimeoutException,
|
||||||
|
) as err:
|
||||||
|
_LOGGER.error("Error getting camera image from %s: %s", url, type(err).__name__)
|
||||||
|
return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None
|
||||||
|
|
||||||
|
if not image:
|
||||||
|
return {CONF_STILL_IMAGE_URL: "unable_still_load"}, None
|
||||||
|
fmt = get_image_type(image)
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Still image at '%s' detected format: %s",
|
||||||
|
info[CONF_STILL_IMAGE_URL],
|
||||||
|
fmt,
|
||||||
|
)
|
||||||
|
if fmt not in SUPPORTED_IMAGE_TYPES:
|
||||||
|
return {CONF_STILL_IMAGE_URL: "invalid_still_image"}, None
|
||||||
|
return {}, f"image/{fmt}"
|
||||||
|
|
||||||
|
|
||||||
|
def slug_url(url) -> str | None:
|
||||||
|
"""Convert a camera url into a string suitable for a camera name."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
url_no_scheme = urlparse(url)._replace(scheme="")
|
||||||
|
return slugify(urlunparse(url_no_scheme).strip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_test_stream(hass, info) -> dict[str, str]:
|
||||||
|
"""Verify that the stream is valid before we create an entity."""
|
||||||
|
if not (stream_source := info.get(CONF_STREAM_SOURCE)):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
# For RTSP streams, prefer TCP. This code is duplicated from
|
||||||
|
# homeassistant.components.stream.__init__.py:create_stream()
|
||||||
|
# It may be possible & better to call create_stream() directly.
|
||||||
|
stream_options: dict[str, str] = {}
|
||||||
|
if isinstance(stream_source, str) and stream_source[:7] == "rtsp://":
|
||||||
|
stream_options = {
|
||||||
|
"rtsp_flags": "prefer_tcp",
|
||||||
|
"stimeout": "5000000",
|
||||||
|
}
|
||||||
|
if rtsp_transport := info.get(CONF_RTSP_TRANSPORT):
|
||||||
|
stream_options[FFMPEG_OPTION_MAP[CONF_RTSP_TRANSPORT]] = rtsp_transport
|
||||||
|
_LOGGER.debug("Attempting to open stream %s", stream_source)
|
||||||
|
container = await hass.async_add_executor_job(
|
||||||
|
partial(
|
||||||
|
av.open,
|
||||||
|
stream_source,
|
||||||
|
options=stream_options,
|
||||||
|
timeout=SOURCE_TIMEOUT,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = container.streams.video[0]
|
||||||
|
except (av.error.FileNotFoundError): # pylint: disable=c-extension-no-member
|
||||||
|
return {CONF_STREAM_SOURCE: "stream_file_not_found"}
|
||||||
|
except (av.error.HTTPNotFoundError): # pylint: disable=c-extension-no-member
|
||||||
|
return {CONF_STREAM_SOURCE: "stream_http_not_found"}
|
||||||
|
except (av.error.TimeoutError): # pylint: disable=c-extension-no-member
|
||||||
|
return {CONF_STREAM_SOURCE: "timeout"}
|
||||||
|
except av.error.HTTPUnauthorizedError: # pylint: disable=c-extension-no-member
|
||||||
|
return {CONF_STREAM_SOURCE: "stream_unauthorised"}
|
||||||
|
except (KeyError, IndexError):
|
||||||
|
return {CONF_STREAM_SOURCE: "stream_no_video"}
|
||||||
|
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"}
|
||||||
|
if err.errno == EIO: # input/output error
|
||||||
|
return {CONF_STREAM_SOURCE: "stream_io_error"}
|
||||||
|
raise err
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Config flow for generic IP camera."""
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def async_get_options_flow(
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> GenericOptionsFlowHandler:
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return GenericOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
def check_for_existing(self, options):
|
||||||
|
"""Check whether an existing entry is using the same URLs."""
|
||||||
|
return any(
|
||||||
|
entry.options[CONF_STILL_IMAGE_URL] == options[CONF_STILL_IMAGE_URL]
|
||||||
|
and entry.options[CONF_STREAM_SOURCE] == options[CONF_STREAM_SOURCE]
|
||||||
|
for entry in self._async_current_entries()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Handle the start of the config flow."""
|
||||||
|
errors = {}
|
||||||
|
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(self.hass, user_input)
|
||||||
|
errors = errors | await async_test_stream(self.hass, user_input)
|
||||||
|
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||||
|
stream_url = user_input.get(CONF_STREAM_SOURCE)
|
||||||
|
name = slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME
|
||||||
|
|
||||||
|
if not errors:
|
||||||
|
user_input[CONF_CONTENT_TYPE] = still_format
|
||||||
|
user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
|
||||||
|
await self.async_set_unique_id(self.flow_id)
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=name, data={}, options=user_input
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
user_input = DEFAULT_DATA.copy()
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=build_schema(user_input),
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_import(self, import_config) -> FlowResult:
|
||||||
|
"""Handle config import from yaml."""
|
||||||
|
# abort if we've already got this one.
|
||||||
|
if self.check_for_existing(import_config):
|
||||||
|
return self.async_abort(reason="already_exists")
|
||||||
|
errors, still_format = await async_test_still(self.hass, import_config)
|
||||||
|
errors = errors | await async_test_stream(self.hass, import_config)
|
||||||
|
still_url = import_config.get(CONF_STILL_IMAGE_URL)
|
||||||
|
stream_url = import_config.get(CONF_STREAM_SOURCE)
|
||||||
|
name = import_config.get(
|
||||||
|
CONF_NAME, slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME
|
||||||
|
)
|
||||||
|
if CONF_LIMIT_REFETCH_TO_URL_CHANGE not in import_config:
|
||||||
|
import_config[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False
|
||||||
|
if not errors:
|
||||||
|
import_config[CONF_CONTENT_TYPE] = still_format
|
||||||
|
await self.async_set_unique_id(self.flow_id)
|
||||||
|
return self.async_create_entry(title=name, data={}, options=import_config)
|
||||||
|
_LOGGER.error(
|
||||||
|
"Error importing generic IP camera platform config: unexpected error '%s'",
|
||||||
|
list(errors.values()),
|
||||||
|
)
|
||||||
|
return self.async_abort(reason="unknown")
|
||||||
|
|
||||||
|
|
||||||
|
class GenericOptionsFlowHandler(OptionsFlow):
|
||||||
|
"""Handle Generic IP Camera options."""
|
||||||
|
|
||||||
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
|
"""Initialize Generic IP Camera options flow."""
|
||||||
|
self.config_entry = config_entry
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> FlowResult:
|
||||||
|
"""Manage Generic IP Camera options."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
errors, still_format = await async_test_still(self.hass, user_input)
|
||||||
|
errors = errors | await async_test_stream(self.hass, user_input)
|
||||||
|
still_url = user_input.get(CONF_STILL_IMAGE_URL)
|
||||||
|
stream_url = user_input.get(CONF_STREAM_SOURCE)
|
||||||
|
if not errors:
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=slug_url(still_url) or slug_url(stream_url) or DEFAULT_NAME,
|
||||||
|
data={
|
||||||
|
CONF_AUTHENTICATION: user_input.get(CONF_AUTHENTICATION),
|
||||||
|
CONF_STREAM_SOURCE: user_input.get(CONF_STREAM_SOURCE),
|
||||||
|
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
|
||||||
|
CONF_STILL_IMAGE_URL: user_input.get(CONF_STILL_IMAGE_URL),
|
||||||
|
CONF_CONTENT_TYPE: still_format,
|
||||||
|
CONF_USERNAME: user_input.get(CONF_USERNAME),
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE: user_input[
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE
|
||||||
|
],
|
||||||
|
CONF_FRAMERATE: user_input[CONF_FRAMERATE],
|
||||||
|
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="init",
|
||||||
|
data_schema=build_schema(user_input or self.config_entry.options, True),
|
||||||
|
errors=errors,
|
||||||
|
)
|
|
@ -1,5 +1,6 @@
|
||||||
"""Constants for the generic (IP Camera) integration."""
|
"""Constants for the generic (IP Camera) integration."""
|
||||||
|
|
||||||
|
DOMAIN = "generic"
|
||||||
DEFAULT_NAME = "Generic Camera"
|
DEFAULT_NAME = "Generic Camera"
|
||||||
CONF_CONTENT_TYPE = "content_type"
|
CONF_CONTENT_TYPE = "content_type"
|
||||||
CONF_LIMIT_REFETCH_TO_URL_CHANGE = "limit_refetch_to_url_change"
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE = "limit_refetch_to_url_change"
|
||||||
|
@ -8,6 +9,15 @@ CONF_STREAM_SOURCE = "stream_source"
|
||||||
CONF_FRAMERATE = "framerate"
|
CONF_FRAMERATE = "framerate"
|
||||||
CONF_RTSP_TRANSPORT = "rtsp_transport"
|
CONF_RTSP_TRANSPORT = "rtsp_transport"
|
||||||
FFMPEG_OPTION_MAP = {CONF_RTSP_TRANSPORT: "rtsp_transport"}
|
FFMPEG_OPTION_MAP = {CONF_RTSP_TRANSPORT: "rtsp_transport"}
|
||||||
ALLOWED_RTSP_TRANSPORT_PROTOCOLS = {"tcp", "udp", "udp_multicast", "http"}
|
RTSP_TRANSPORTS = {
|
||||||
|
"tcp": "TCP",
|
||||||
|
"udp": "UDP",
|
||||||
|
"udp_multicast": "UDP Multicast",
|
||||||
|
"http": "HTTP",
|
||||||
|
}
|
||||||
GET_IMAGE_TIMEOUT = 10
|
GET_IMAGE_TIMEOUT = 10
|
||||||
|
|
||||||
|
DEFAULT_USERNAME = None
|
||||||
|
DEFAULT_PASSWORD = None
|
||||||
|
DEFAULT_IMAGE_URL = None
|
||||||
|
DEFAULT_STREAM_SOURCE = None
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
{
|
{
|
||||||
"domain": "generic",
|
"domain": "generic",
|
||||||
"name": "Generic Camera",
|
"name": "Generic Camera",
|
||||||
|
"config_flow": true,
|
||||||
|
"requirements": ["av==9.0.0"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/generic",
|
"documentation": "https://www.home-assistant.io/integrations/generic",
|
||||||
"codeowners": [],
|
"codeowners": [
|
||||||
|
"@davet2001"
|
||||||
|
],
|
||||||
"iot_class": "local_push"
|
"iot_class": "local_push"
|
||||||
}
|
}
|
||||||
|
|
76
homeassistant/components/generic/strings.json
Normal file
76
homeassistant/components/generic/strings.json
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"error": {
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||||
|
"already_exists": "A camera with these URL settings already exists.",
|
||||||
|
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
|
||||||
|
"no_still_image_or_stream_url": "You must specify at least a still image or stream URL",
|
||||||
|
"invalid_still_image": "URL did not return a valid still image",
|
||||||
|
"stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)",
|
||||||
|
"stream_http_not_found": "HTTP 404 Not found while trying to connect to stream",
|
||||||
|
"timeout": "Timeout while loading URL",
|
||||||
|
"stream_no_route_to_host": "Could not find host while trying to connect to stream",
|
||||||
|
"stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?",
|
||||||
|
"stream_unauthorised": "Authorisation failed while trying to connect to stream",
|
||||||
|
"stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?",
|
||||||
|
"stream_no_video": "Stream has no video"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
||||||
|
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Enter the settings to connect to the camera.",
|
||||||
|
"data": {
|
||||||
|
"still_image_url": "Still Image URL (e.g. http://...)",
|
||||||
|
"stream_source": "Stream Source URL (e.g. rtsp://...)",
|
||||||
|
"rtsp_transport": "RTSP transport protocol",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"limit_refetch_to_url_change": "Limit refetch to url change",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"content_type": "Content Type",
|
||||||
|
"framerate": "Frame Rate (Hz)",
|
||||||
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"confirm": {
|
||||||
|
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"still_image_url": "[%key:component::generic::config::step::user::data::still_image_url%]",
|
||||||
|
"stream_source": "[%key:component::generic::config::step::user::data::stream_source%]",
|
||||||
|
"rtsp_transport": "[%key:component::generic::config::step::user::data::rtsp_transport%]",
|
||||||
|
"authentication": "[%key:component::generic::config::step::user::data::authentication%]",
|
||||||
|
"limit_refetch_to_url_change": "[%key:component::generic::config::step::user::data::limit_refetch_to_url_change%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"content_type": "[%key:component::generic::config::step::user::data::content_type%]",
|
||||||
|
"framerate": "[%key:component::generic::config::step::user::data::framerate%]",
|
||||||
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||||
|
"already_exists": "[%key:component::generic::config::error::already_exists%]",
|
||||||
|
"unable_still_load": "[%key:component::generic::config::error::unable_still_load%]",
|
||||||
|
"no_still_image_or_stream_url": "[%key:component::generic::config::error::no_still_image_or_stream_url%]",
|
||||||
|
"invalid_still_image": "[%key:component::generic::config::error::invalid_still_image%]",
|
||||||
|
"stream_file_not_found": "[%key:component::generic::config::error::stream_file_not_found%]",
|
||||||
|
"stream_http_not_found": "[%key:component::generic::config::error::stream_http_not_found%]",
|
||||||
|
"timeout": "[%key:component::generic::config::error::timeout%]",
|
||||||
|
"stream_no_route_to_host": "[%key:component::generic::config::error::stream_no_route_to_host%]",
|
||||||
|
"stream_io_error": "[%key:component::generic::config::error::stream_io_error%]",
|
||||||
|
"stream_unauthorised": "[%key:component::generic::config::error::stream_unauthorised%]",
|
||||||
|
"stream_not_permitted": "[%key:component::generic::config::error::stream_not_permitted%]",
|
||||||
|
"stream_no_video": "[%key:component::generic::config::error::stream_no_video%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
76
homeassistant/components/generic/translations/en.json
Normal file
76
homeassistant/components/generic/translations/en.json
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"no_devices_found": "No devices found on the network",
|
||||||
|
"single_instance_allowed": "Already configured. Only a single configuration possible."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"already_exists": "A camera with these URL settings already exists.",
|
||||||
|
"invalid_still_image": "URL did not return a valid still image",
|
||||||
|
"no_still_image_or_stream_url": "You must specify at least a still image or stream URL",
|
||||||
|
"stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)",
|
||||||
|
"stream_http_not_found": "HTTP 404 Not found while trying to connect to stream",
|
||||||
|
"stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?",
|
||||||
|
"stream_no_route_to_host": "Could not find host while trying to connect to stream",
|
||||||
|
"stream_no_video": "Stream has no video",
|
||||||
|
"stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?",
|
||||||
|
"stream_unauthorised": "Authorisation failed while trying to connect to stream",
|
||||||
|
"timeout": "Timeout while loading URL",
|
||||||
|
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"confirm": {
|
||||||
|
"description": "Do you want to start set up?"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"data": {
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"content_type": "Content Type",
|
||||||
|
"framerate": "Frame Rate (Hz)",
|
||||||
|
"limit_refetch_to_url_change": "Limit refetch to url change",
|
||||||
|
"password": "Password",
|
||||||
|
"rtsp_transport": "RTSP transport protocol",
|
||||||
|
"still_image_url": "Still Image URL (e.g. http://...)",
|
||||||
|
"stream_source": "Stream Source URL (e.g. rtsp://...)",
|
||||||
|
"username": "Username",
|
||||||
|
"verify_ssl": "Verify SSL certificate"
|
||||||
|
},
|
||||||
|
"description": "Enter the settings to connect to the camera."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"error": {
|
||||||
|
"already_exists": "A camera with these URL settings already exists.",
|
||||||
|
"invalid_still_image": "URL did not return a valid still image",
|
||||||
|
"no_still_image_or_stream_url": "You must specify at least a still image or stream URL",
|
||||||
|
"stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)",
|
||||||
|
"stream_http_not_found": "HTTP 404 Not found while trying to connect to stream",
|
||||||
|
"stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?",
|
||||||
|
"stream_no_route_to_host": "Could not find host while trying to connect to stream",
|
||||||
|
"stream_no_video": "Stream has no video",
|
||||||
|
"stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?",
|
||||||
|
"stream_unauthorised": "Authorisation failed while trying to connect to stream",
|
||||||
|
"timeout": "Timeout while loading URL",
|
||||||
|
"unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
},
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"content_type": "Content Type",
|
||||||
|
"framerate": "Frame Rate (Hz)",
|
||||||
|
"limit_refetch_to_url_change": "Limit refetch to url change",
|
||||||
|
"password": "Password",
|
||||||
|
"rtsp_transport": "RTSP transport protocol",
|
||||||
|
"still_image_url": "Still Image URL (e.g. http://...)",
|
||||||
|
"stream_source": "Stream Source URL (e.g. rtsp://...)",
|
||||||
|
"username": "Username",
|
||||||
|
"verify_ssl": "Verify SSL certificate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -119,6 +119,7 @@ FLOWS = {
|
||||||
"fronius",
|
"fronius",
|
||||||
"garages_amsterdam",
|
"garages_amsterdam",
|
||||||
"gdacs",
|
"gdacs",
|
||||||
|
"generic",
|
||||||
"geofency",
|
"geofency",
|
||||||
"geonetnz_quakes",
|
"geonetnz_quakes",
|
||||||
"geonetnz_volcano",
|
"geonetnz_volcano",
|
||||||
|
|
|
@ -347,6 +347,7 @@ auroranoaa==0.0.2
|
||||||
# homeassistant.components.aurora_abb_powerone
|
# homeassistant.components.aurora_abb_powerone
|
||||||
aurorapy==0.2.6
|
aurorapy==0.2.6
|
||||||
|
|
||||||
|
# homeassistant.components.generic
|
||||||
# homeassistant.components.stream
|
# homeassistant.components.stream
|
||||||
av==9.0.0
|
av==9.0.0
|
||||||
|
|
||||||
|
|
|
@ -274,6 +274,7 @@ auroranoaa==0.0.2
|
||||||
# homeassistant.components.aurora_abb_powerone
|
# homeassistant.components.aurora_abb_powerone
|
||||||
aurorapy==0.2.6
|
aurorapy==0.2.6
|
||||||
|
|
||||||
|
# homeassistant.components.generic
|
||||||
# homeassistant.components.stream
|
# homeassistant.components.stream
|
||||||
av==9.0.0
|
av==9.0.0
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
"""Test fixtures for the generic component."""
|
"""Test fixtures for the generic component."""
|
||||||
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import pytest
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from homeassistant import config_entries, setup
|
||||||
|
from homeassistant.components.generic.const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="package")
|
@pytest.fixture(scope="package")
|
||||||
|
@ -29,3 +34,34 @@ def fakeimgbytes_svg():
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>',
|
'<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>',
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fakeimg_png(fakeimgbytes_png):
|
||||||
|
"""Set up respx to respond to test url with fake image bytes."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="package")
|
||||||
|
def mock_av_open():
|
||||||
|
"""Fake container object with .streams.video[0] != None."""
|
||||||
|
fake = Mock()
|
||||||
|
fake.streams.video = ["fakevid"]
|
||||||
|
return patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
return_value=fake,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_flow(hass):
|
||||||
|
"""Initiate a user flow."""
|
||||||
|
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
|
@ -8,47 +8,47 @@ import httpx
|
||||||
import pytest
|
import pytest
|
||||||
import respx
|
import respx
|
||||||
|
|
||||||
from homeassistant import config as hass_config
|
|
||||||
from homeassistant.components.camera import async_get_mjpeg_stream
|
from homeassistant.components.camera import async_get_mjpeg_stream
|
||||||
from homeassistant.components.generic import DOMAIN
|
|
||||||
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
from homeassistant.components.websocket_api.const import TYPE_RESULT
|
||||||
from homeassistant.const import SERVICE_RELOAD
|
from homeassistant.config_entries import SOURCE_IMPORT
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from tests.common import AsyncMock, Mock, get_fixture_path
|
from tests.common import AsyncMock, Mock
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_fetching_url(hass, hass_client, fakeimgbytes_png):
|
async def test_fetching_url(hass, hass_client, fakeimgbytes_png, mock_av_open):
|
||||||
"""Test that it fetches the given url."""
|
"""Test that it fetches the given url."""
|
||||||
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
||||||
|
|
||||||
await async_setup_component(
|
with mock_av_open:
|
||||||
hass,
|
await async_setup_component(
|
||||||
"camera",
|
hass,
|
||||||
{
|
"camera",
|
||||||
"camera": {
|
{
|
||||||
"name": "config_test",
|
"camera": {
|
||||||
"platform": "generic",
|
"name": "config_test",
|
||||||
"still_image_url": "http://example.com",
|
"platform": "generic",
|
||||||
"username": "user",
|
"still_image_url": "http://example.com",
|
||||||
"password": "pass",
|
"username": "user",
|
||||||
}
|
"password": "pass",
|
||||||
},
|
"authentication": "basic",
|
||||||
)
|
}
|
||||||
await hass.async_block_till_done()
|
},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
|
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
assert respx.calls.call_count == 1
|
assert respx.calls.call_count == 2
|
||||||
body = await resp.read()
|
body = await resp.read()
|
||||||
assert body == fakeimgbytes_png
|
assert body == fakeimgbytes_png
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == 2
|
assert respx.calls.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
|
@ -110,11 +110,14 @@ async def test_fetching_url_with_verify_ssl(hass, hass_client, fakeimgbytes_png)
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_jpg):
|
async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_jpg):
|
||||||
"""Test that it fetches the given url."""
|
"""Test that it fetches the given url."""
|
||||||
|
respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
|
||||||
respx.get("http://example.com/5a").respond(stream=fakeimgbytes_png)
|
respx.get("http://example.com/5a").respond(stream=fakeimgbytes_png)
|
||||||
respx.get("http://example.com/10a").respond(stream=fakeimgbytes_png)
|
respx.get("http://example.com/10a").respond(stream=fakeimgbytes_png)
|
||||||
respx.get("http://example.com/15a").respond(stream=fakeimgbytes_jpg)
|
respx.get("http://example.com/15a").respond(stream=fakeimgbytes_jpg)
|
||||||
respx.get("http://example.com/20a").respond(status_code=HTTPStatus.NOT_FOUND)
|
respx.get("http://example.com/20a").respond(status_code=HTTPStatus.NOT_FOUND)
|
||||||
|
|
||||||
|
hass.states.async_set("sensor.temp", "0")
|
||||||
|
|
||||||
await async_setup_component(
|
await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
"camera",
|
"camera",
|
||||||
|
@ -140,19 +143,19 @@ async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_j
|
||||||
):
|
):
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
|
|
||||||
assert respx.calls.call_count == 0
|
assert respx.calls.call_count == 2
|
||||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
assert resp.status == HTTPStatus.OK
|
||||||
|
|
||||||
hass.states.async_set("sensor.temp", "10")
|
hass.states.async_set("sensor.temp", "10")
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == 1
|
assert respx.calls.call_count == 3
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
body = await resp.read()
|
body = await resp.read()
|
||||||
assert body == fakeimgbytes_png
|
assert body == fakeimgbytes_png
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == 1
|
assert respx.calls.call_count == 3
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
body = await resp.read()
|
body = await resp.read()
|
||||||
assert body == fakeimgbytes_png
|
assert body == fakeimgbytes_png
|
||||||
|
@ -161,7 +164,7 @@ async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_j
|
||||||
|
|
||||||
# Url change = fetch new image
|
# Url change = fetch new image
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == 2
|
assert respx.calls.call_count == 4
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
body = await resp.read()
|
body = await resp.read()
|
||||||
assert body == fakeimgbytes_jpg
|
assert body == fakeimgbytes_jpg
|
||||||
|
@ -169,31 +172,37 @@ async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_j
|
||||||
# Cause a template render error
|
# Cause a template render error
|
||||||
hass.states.async_remove("sensor.temp")
|
hass.states.async_remove("sensor.temp")
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == 2
|
assert respx.calls.call_count == 4
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
body = await resp.read()
|
body = await resp.read()
|
||||||
assert body == fakeimgbytes_jpg
|
assert body == fakeimgbytes_jpg
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png):
|
@respx.mock
|
||||||
|
async def test_stream_source(
|
||||||
|
hass, hass_client, hass_ws_client, fakeimgbytes_png, mock_av_open
|
||||||
|
):
|
||||||
"""Test that the stream source is rendered."""
|
"""Test that the stream source is rendered."""
|
||||||
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
||||||
|
respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png)
|
||||||
|
|
||||||
assert await async_setup_component(
|
hass.states.async_set("sensor.temp", "0")
|
||||||
hass,
|
with mock_av_open:
|
||||||
"camera",
|
assert await async_setup_component(
|
||||||
{
|
hass,
|
||||||
"camera": {
|
"camera",
|
||||||
"name": "config_test",
|
{
|
||||||
"platform": "generic",
|
"camera": {
|
||||||
"still_image_url": "https://example.com",
|
"name": "config_test",
|
||||||
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
|
"platform": "generic",
|
||||||
"limit_refetch_to_url_change": True,
|
"still_image_url": "http://example.com",
|
||||||
|
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
|
||||||
|
"limit_refetch_to_url_change": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
)
|
assert await async_setup_component(hass, "stream", {})
|
||||||
assert await async_setup_component(hass, "stream", {})
|
await hass.async_block_till_done()
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
hass.states.async_set("sensor.temp", "5")
|
hass.states.async_set("sensor.temp", "5")
|
||||||
|
|
||||||
|
@ -217,26 +226,30 @@ async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png
|
||||||
assert msg["result"]["url"][-13:] == "playlist.m3u8"
|
assert msg["result"]["url"][-13:] == "playlist.m3u8"
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_source_error(hass, hass_client, hass_ws_client, fakeimgbytes_png):
|
@respx.mock
|
||||||
|
async def test_stream_source_error(
|
||||||
|
hass, hass_client, hass_ws_client, fakeimgbytes_png, mock_av_open
|
||||||
|
):
|
||||||
"""Test that the stream source has an error."""
|
"""Test that the stream source has an error."""
|
||||||
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
||||||
|
|
||||||
assert await async_setup_component(
|
with mock_av_open:
|
||||||
hass,
|
assert await async_setup_component(
|
||||||
"camera",
|
hass,
|
||||||
{
|
"camera",
|
||||||
"camera": {
|
{
|
||||||
"name": "config_test",
|
"camera": {
|
||||||
"platform": "generic",
|
"name": "config_test",
|
||||||
"still_image_url": "https://example.com",
|
"platform": "generic",
|
||||||
# Does not exist
|
"still_image_url": "http://example.com",
|
||||||
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
|
# Does not exist
|
||||||
"limit_refetch_to_url_change": True,
|
"stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}',
|
||||||
|
"limit_refetch_to_url_change": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
)
|
assert await async_setup_component(hass, "stream", {})
|
||||||
assert await async_setup_component(hass, "stream", {})
|
await hass.async_block_till_done()
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.camera.Stream.endpoint_url",
|
"homeassistant.components.camera.Stream.endpoint_url",
|
||||||
|
@ -261,31 +274,38 @@ async def test_stream_source_error(hass, hass_client, hass_ws_client, fakeimgbyt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_alternative_options(hass, hass_ws_client):
|
@respx.mock
|
||||||
|
async def test_setup_alternative_options(
|
||||||
|
hass, hass_ws_client, fakeimgbytes_png, mock_av_open
|
||||||
|
):
|
||||||
"""Test that the stream source is setup with different config options."""
|
"""Test that the stream source is setup with different config options."""
|
||||||
assert await async_setup_component(
|
respx.get("https://example.com").respond(stream=fakeimgbytes_png)
|
||||||
hass,
|
|
||||||
"camera",
|
with mock_av_open:
|
||||||
{
|
assert await async_setup_component(
|
||||||
"camera": {
|
hass,
|
||||||
"name": "config_test",
|
"camera",
|
||||||
"platform": "generic",
|
{
|
||||||
"still_image_url": "https://example.com",
|
"camera": {
|
||||||
"authentication": "digest",
|
"name": "config_test",
|
||||||
"username": "user",
|
"platform": "generic",
|
||||||
"password": "pass",
|
"still_image_url": "https://example.com",
|
||||||
"stream_source": "rtsp://example.com:554/rtsp/",
|
"authentication": "digest",
|
||||||
"rtsp_transport": "udp",
|
"username": "user",
|
||||||
|
"password": "pass",
|
||||||
|
"stream_source": "rtsp://example.com:554/rtsp/",
|
||||||
|
"rtsp_transport": "udp",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
)
|
await hass.async_block_till_done()
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert hass.states.get("camera.config_test")
|
assert hass.states.get("camera.config_test")
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
async def test_no_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png):
|
async def test_no_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png):
|
||||||
"""Test a stream request without stream source option set."""
|
"""Test a stream request without stream source option set."""
|
||||||
respx.get("http://example.com").respond(stream=fakeimgbytes_png)
|
respx.get("https://example.com").respond(stream=fakeimgbytes_png)
|
||||||
|
|
||||||
assert await async_setup_component(
|
assert await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
|
@ -326,7 +346,7 @@ async def test_no_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_camera_content_type(
|
async def test_camera_content_type(
|
||||||
hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg
|
hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg, mock_av_open
|
||||||
):
|
):
|
||||||
"""Test generic camera with custom content_type."""
|
"""Test generic camera with custom content_type."""
|
||||||
urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg"
|
urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg"
|
||||||
|
@ -338,90 +358,54 @@ async def test_camera_content_type(
|
||||||
"platform": "generic",
|
"platform": "generic",
|
||||||
"still_image_url": urlsvg,
|
"still_image_url": urlsvg,
|
||||||
"content_type": "image/svg+xml",
|
"content_type": "image/svg+xml",
|
||||||
|
"limit_refetch_to_url_change": False,
|
||||||
|
"framerate": 2,
|
||||||
|
"verify_ssl": True,
|
||||||
}
|
}
|
||||||
cam_config_jpg = {
|
cam_config_jpg = {
|
||||||
"name": "config_test_jpg",
|
"name": "config_test_jpg",
|
||||||
"platform": "generic",
|
"platform": "generic",
|
||||||
"still_image_url": urljpg,
|
"still_image_url": urljpg,
|
||||||
"content_type": "image/jpeg",
|
"content_type": "image/jpeg",
|
||||||
|
"limit_refetch_to_url_change": False,
|
||||||
|
"framerate": 2,
|
||||||
|
"verify_ssl": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
await async_setup_component(
|
with mock_av_open:
|
||||||
hass, "camera", {"camera": [cam_config_svg, cam_config_jpg]}
|
result1 = await hass.config_entries.flow.async_init(
|
||||||
)
|
"generic",
|
||||||
await hass.async_block_till_done()
|
data=cam_config_jpg,
|
||||||
|
context={"source": SOURCE_IMPORT, "unique_id": 12345},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
with mock_av_open:
|
||||||
|
result2 = await hass.config_entries.flow.async_init(
|
||||||
|
"generic",
|
||||||
|
data=cam_config_svg,
|
||||||
|
context={"source": SOURCE_IMPORT, "unique_id": 54321},
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result1["type"] == "create_entry"
|
||||||
|
assert result2["type"] == "create_entry"
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
|
|
||||||
resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg")
|
resp_1 = await client.get("/api/camera_proxy/camera.config_test_svg")
|
||||||
assert respx.calls.call_count == 1
|
assert respx.calls.call_count == 3
|
||||||
assert resp_1.status == HTTPStatus.OK
|
assert resp_1.status == HTTPStatus.OK
|
||||||
assert resp_1.content_type == "image/svg+xml"
|
assert resp_1.content_type == "image/svg+xml"
|
||||||
body = await resp_1.read()
|
body = await resp_1.read()
|
||||||
assert body == fakeimgbytes_svg
|
assert body == fakeimgbytes_svg
|
||||||
|
|
||||||
resp_2 = await client.get("/api/camera_proxy/camera.config_test_jpg")
|
resp_2 = await client.get("/api/camera_proxy/camera.config_test_jpg")
|
||||||
assert respx.calls.call_count == 2
|
assert respx.calls.call_count == 4
|
||||||
assert resp_2.status == HTTPStatus.OK
|
assert resp_2.status == HTTPStatus.OK
|
||||||
assert resp_2.content_type == "image/jpeg"
|
assert resp_2.content_type == "image/jpeg"
|
||||||
body = await resp_2.read()
|
body = await resp_2.read()
|
||||||
assert body == fakeimgbytes_jpg
|
assert body == fakeimgbytes_jpg
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
|
||||||
async def test_reloading(hass, hass_client):
|
|
||||||
"""Test we can cleanly reload."""
|
|
||||||
respx.get("http://example.com").respond(text="hello world")
|
|
||||||
|
|
||||||
await async_setup_component(
|
|
||||||
hass,
|
|
||||||
"camera",
|
|
||||||
{
|
|
||||||
"camera": {
|
|
||||||
"name": "config_test",
|
|
||||||
"platform": "generic",
|
|
||||||
"still_image_url": "http://example.com",
|
|
||||||
"username": "user",
|
|
||||||
"password": "pass",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
client = await hass_client()
|
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
|
||||||
|
|
||||||
assert resp.status == HTTPStatus.OK
|
|
||||||
assert respx.calls.call_count == 1
|
|
||||||
body = await resp.text()
|
|
||||||
assert body == "hello world"
|
|
||||||
|
|
||||||
yaml_path = get_fixture_path("configuration.yaml", "generic")
|
|
||||||
|
|
||||||
with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
|
|
||||||
await hass.services.async_call(
|
|
||||||
DOMAIN,
|
|
||||||
SERVICE_RELOAD,
|
|
||||||
{},
|
|
||||||
blocking=True,
|
|
||||||
)
|
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
assert len(hass.states.async_all()) == 1
|
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
|
||||||
|
|
||||||
assert resp.status == HTTPStatus.NOT_FOUND
|
|
||||||
|
|
||||||
resp = await client.get("/api/camera_proxy/camera.reload")
|
|
||||||
|
|
||||||
assert resp.status == HTTPStatus.OK
|
|
||||||
assert respx.calls.call_count == 2
|
|
||||||
body = await resp.text()
|
|
||||||
assert body == "hello world"
|
|
||||||
|
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbytes_jpg):
|
async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbytes_jpg):
|
||||||
"""Test that timeouts and cancellations return last image."""
|
"""Test that timeouts and cancellations return last image."""
|
||||||
|
@ -448,7 +432,7 @@ async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbyt
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
|
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
assert respx.calls.call_count == 1
|
assert respx.calls.call_count == 2
|
||||||
assert await resp.read() == fakeimgbytes_png
|
assert await resp.read() == fakeimgbytes_png
|
||||||
|
|
||||||
respx.get("http://example.com").respond(stream=fakeimgbytes_jpg)
|
respx.get("http://example.com").respond(stream=fakeimgbytes_jpg)
|
||||||
|
@ -458,7 +442,7 @@ async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbyt
|
||||||
side_effect=asyncio.CancelledError(),
|
side_effect=asyncio.CancelledError(),
|
||||||
):
|
):
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == 1
|
assert respx.calls.call_count == 2
|
||||||
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||||
|
|
||||||
respx.get("http://example.com").side_effect = [
|
respx.get("http://example.com").side_effect = [
|
||||||
|
@ -466,27 +450,28 @@ async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbyt
|
||||||
httpx.TimeoutException,
|
httpx.TimeoutException,
|
||||||
]
|
]
|
||||||
|
|
||||||
for total_calls in range(2, 3):
|
for total_calls in range(3, 5):
|
||||||
resp = await client.get("/api/camera_proxy/camera.config_test")
|
resp = await client.get("/api/camera_proxy/camera.config_test")
|
||||||
assert respx.calls.call_count == total_calls
|
assert respx.calls.call_count == total_calls
|
||||||
assert resp.status == HTTPStatus.OK
|
assert resp.status == HTTPStatus.OK
|
||||||
assert await resp.read() == fakeimgbytes_png
|
assert await resp.read() == fakeimgbytes_png
|
||||||
|
|
||||||
|
|
||||||
async def test_no_still_image_url(hass, hass_client):
|
async def test_no_still_image_url(hass, hass_client, mock_av_open):
|
||||||
"""Test that the component can grab images from stream with no still_image_url."""
|
"""Test that the component can grab images from stream with no still_image_url."""
|
||||||
assert await async_setup_component(
|
with mock_av_open:
|
||||||
hass,
|
assert await async_setup_component(
|
||||||
"camera",
|
hass,
|
||||||
{
|
"camera",
|
||||||
"camera": {
|
{
|
||||||
"name": "config_test",
|
"camera": {
|
||||||
"platform": "generic",
|
"name": "config_test",
|
||||||
"stream_source": "rtsp://example.com:554/rtsp/",
|
"platform": "generic",
|
||||||
|
"stream_source": "rtsp://example.com:554/rtsp/",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
)
|
await hass.async_block_till_done()
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
client = await hass_client()
|
client = await hass_client()
|
||||||
|
|
||||||
|
@ -518,22 +503,23 @@ async def test_no_still_image_url(hass, hass_client):
|
||||||
assert await resp.read() == b"stream_keyframe_image"
|
assert await resp.read() == b"stream_keyframe_image"
|
||||||
|
|
||||||
|
|
||||||
async def test_frame_interval_property(hass):
|
async def test_frame_interval_property(hass, mock_av_open):
|
||||||
"""Test that the frame interval is calculated and returned correctly."""
|
"""Test that the frame interval is calculated and returned correctly."""
|
||||||
|
|
||||||
await async_setup_component(
|
with mock_av_open:
|
||||||
hass,
|
await async_setup_component(
|
||||||
"camera",
|
hass,
|
||||||
{
|
"camera",
|
||||||
"camera": {
|
{
|
||||||
"name": "config_test",
|
"camera": {
|
||||||
"platform": "generic",
|
"name": "config_test",
|
||||||
"stream_source": "rtsp://example.com:554/rtsp/",
|
"platform": "generic",
|
||||||
"framerate": 5,
|
"stream_source": "rtsp://example.com:554/rtsp/",
|
||||||
|
"framerate": 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
)
|
await hass.async_block_till_done()
|
||||||
await hass.async_block_till_done()
|
|
||||||
|
|
||||||
request = Mock()
|
request = Mock()
|
||||||
with patch(
|
with patch(
|
||||||
|
|
510
tests/components/generic/test_config_flow.py
Normal file
510
tests/components/generic/test_config_flow.py
Normal file
|
@ -0,0 +1,510 @@
|
||||||
|
"""Test The generic (IP Camera) config flow."""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import av
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import respx
|
||||||
|
|
||||||
|
from homeassistant import config_entries, data_entry_flow, setup
|
||||||
|
from homeassistant.components.generic.const import (
|
||||||
|
CONF_CONTENT_TYPE,
|
||||||
|
CONF_FRAMERATE,
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE,
|
||||||
|
CONF_RTSP_TRANSPORT,
|
||||||
|
CONF_STILL_IMAGE_URL,
|
||||||
|
CONF_STREAM_SOURCE,
|
||||||
|
DOMAIN,
|
||||||
|
)
|
||||||
|
from homeassistant.const import (
|
||||||
|
CONF_AUTHENTICATION,
|
||||||
|
CONF_NAME,
|
||||||
|
CONF_PASSWORD,
|
||||||
|
CONF_USERNAME,
|
||||||
|
CONF_VERIFY_SSL,
|
||||||
|
HTTP_BASIC_AUTHENTICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
TESTDATA = {
|
||||||
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
|
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||||
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
|
CONF_USERNAME: "fred_flintstone",
|
||||||
|
CONF_PASSWORD: "bambam",
|
||||||
|
CONF_FRAMERATE: 5,
|
||||||
|
CONF_VERIFY_SSL: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
TESTDATA_OPTIONS = {
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||||
|
**TESTDATA,
|
||||||
|
}
|
||||||
|
|
||||||
|
TESTDATA_YAML = {
|
||||||
|
CONF_NAME: "Yaml Defined Name",
|
||||||
|
**TESTDATA,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form(hass, fakeimg_png, mock_av_open, user_flow):
|
||||||
|
"""Test the form with a normal set of settings."""
|
||||||
|
|
||||||
|
with mock_av_open as mock_setup:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result2["title"] == "127_0_0_1_testurl_1"
|
||||||
|
assert result2["options"] == {
|
||||||
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
|
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||||
|
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_VERIFY_SSL: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_only_stillimage(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we complete ok if the user wants still images only."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result["type"] == "form"
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
data = TESTDATA.copy()
|
||||||
|
data.pop(CONF_STREAM_SOURCE)
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result2["title"] == "127_0_0_1_testurl_1"
|
||||||
|
assert result2["options"] == {
|
||||||
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
|
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_VERIFY_SSL: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert respx.calls.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow):
|
||||||
|
"""Test we complete ok if the user enters a stream url."""
|
||||||
|
with mock_av_open as mock_setup:
|
||||||
|
data = TESTDATA
|
||||||
|
data[CONF_RTSP_TRANSPORT] = "tcp"
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"], data
|
||||||
|
)
|
||||||
|
assert "errors" not in result2, f"errors={result2['errors']}"
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result2["title"] == "127_0_0_1_testurl_1"
|
||||||
|
assert result2["options"] == {
|
||||||
|
CONF_STILL_IMAGE_URL: "http://127.0.0.1/testurl/1",
|
||||||
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
|
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||||
|
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_VERIFY_SSL: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_only_stream(hass, mock_av_open):
|
||||||
|
"""Test we complete ok if the user wants stream only."""
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
data = TESTDATA.copy()
|
||||||
|
data.pop(CONF_STILL_IMAGE_URL)
|
||||||
|
with mock_av_open as mock_setup:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result2["title"] == "127_0_0_1_testurl_2"
|
||||||
|
assert result2["options"] == {
|
||||||
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
|
CONF_STREAM_SOURCE: "http://127.0.0.1/testurl/2",
|
||||||
|
CONF_RTSP_TRANSPORT: "tcp",
|
||||||
|
CONF_USERNAME: "fred_flintstone",
|
||||||
|
CONF_PASSWORD: "bambam",
|
||||||
|
CONF_LIMIT_REFETCH_TO_URL_CHANGE: False,
|
||||||
|
CONF_CONTENT_TYPE: None,
|
||||||
|
CONF_FRAMERATE: 5,
|
||||||
|
CONF_VERIFY_SSL: False,
|
||||||
|
}
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(mock_setup.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_form_still_and_stream_not_provided(hass, user_flow):
|
||||||
|
"""Test we show a suitable error if neither still or stream URL are provided."""
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
{
|
||||||
|
CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION,
|
||||||
|
CONF_FRAMERATE: 5,
|
||||||
|
CONF_VERIFY_SSL: False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result2["errors"] == {"base": "no_still_image_or_stream_url"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_image_timeout(hass, mock_av_open, user_flow):
|
||||||
|
"""Test we handle invalid image timeout."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").side_effect = [
|
||||||
|
httpx.TimeoutException,
|
||||||
|
]
|
||||||
|
|
||||||
|
with mock_av_open:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"still_image_url": "unable_still_load"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_invalidimage(hass, mock_av_open, user_flow):
|
||||||
|
"""Test we handle invalid image when a stream is specified."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid")
|
||||||
|
with mock_av_open:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"still_image_url": "invalid_still_image"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_invalidimage2(hass, mock_av_open, user_flow):
|
||||||
|
"""Test we handle invalid image when a stream is specified."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(content=None)
|
||||||
|
with mock_av_open:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"still_image_url": "unable_still_load"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_invalidimage3(hass, mock_av_open, user_flow):
|
||||||
|
"""Test we handle invalid image when a stream is specified."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF]))
|
||||||
|
with mock_av_open:
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"still_image_url": "invalid_still_image"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_file_not_found(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle file not found."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=av.error.FileNotFoundError(0, 0),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_file_not_found"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_http_not_found(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle invalid auth."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=av.error.HTTPNotFoundError(0, 0),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_http_not_found"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_timeout(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle invalid auth."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=av.error.TimeoutError(0, 0),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "timeout"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_unauthorised(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle invalid auth."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=av.error.HTTPUnauthorizedError(0, 0),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_unauthorised"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_novideo(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle invalid stream."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open", side_effect=KeyError()
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_no_video"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_permission_error(hass, fakeimgbytes_png, user_flow):
|
||||||
|
"""Test we handle permission error."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=PermissionError(),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_not_permitted"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_no_route_to_host(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle no route to host."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=OSError(errno.EHOSTUNREACH, "No route to host"),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_no_route_to_host"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_stream_io_error(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle no io error when setting up stream."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=OSError(errno.EIO, "Input/output error"),
|
||||||
|
):
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == "form"
|
||||||
|
assert result2["errors"] == {"stream_source": "stream_io_error"}
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_form_oserror(hass, fakeimg_png, user_flow):
|
||||||
|
"""Test we handle OS error when setting up stream."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=OSError("Some other OSError"),
|
||||||
|
), pytest.raises(OSError):
|
||||||
|
await hass.config_entries.flow.async_configure(
|
||||||
|
user_flow["flow_id"],
|
||||||
|
TESTDATA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open):
|
||||||
|
"""Test the options flow with a template error."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
|
||||||
|
respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png)
|
||||||
|
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||||
|
|
||||||
|
mock_entry = MockConfigEntry(
|
||||||
|
title="Test Camera",
|
||||||
|
domain=DOMAIN,
|
||||||
|
data={},
|
||||||
|
options=TESTDATA,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock_av_open:
|
||||||
|
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"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result["step_id"] == "init"
|
||||||
|
|
||||||
|
# try updating the still image url
|
||||||
|
data = TESTDATA.copy()
|
||||||
|
data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2"
|
||||||
|
result2 = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input=data,
|
||||||
|
)
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
|
||||||
|
result3 = await hass.config_entries.options.async_init(mock_entry.entry_id)
|
||||||
|
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result3["step_id"] == "init"
|
||||||
|
|
||||||
|
# verify that an invalid template reports the correct UI error.
|
||||||
|
data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/{{1/0}}"
|
||||||
|
result4 = await hass.config_entries.options.async_configure(
|
||||||
|
result3["flow_id"],
|
||||||
|
user_input=data,
|
||||||
|
)
|
||||||
|
assert result4.get("type") == data_entry_flow.RESULT_TYPE_FORM
|
||||||
|
assert result4["errors"] == {"still_image_url": "template_error"}
|
||||||
|
|
||||||
|
|
||||||
|
# These below can be deleted after deprecation period is finished.
|
||||||
|
@respx.mock
|
||||||
|
async def test_import(hass, fakeimg_png, mock_av_open):
|
||||||
|
"""Test configuration.yaml import used during migration."""
|
||||||
|
with mock_av_open:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML
|
||||||
|
)
|
||||||
|
# duplicate import should be aborted
|
||||||
|
result2 = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
|
||||||
|
assert result["title"] == "Yaml Defined Name"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
# Any name defined in yaml should end up as the entity id.
|
||||||
|
assert hass.states.get("camera.yaml_defined_name")
|
||||||
|
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_import_invalid_still_image(hass, mock_av_open):
|
||||||
|
"""Test configuration.yaml import used during migration."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid")
|
||||||
|
with mock_av_open:
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML
|
||||||
|
)
|
||||||
|
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||||
|
assert result["reason"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_import_other_error(hass, fakeimgbytes_png):
|
||||||
|
"""Test that non-specific import errors are raised."""
|
||||||
|
respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png)
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.generic.config_flow.av.open",
|
||||||
|
side_effect=OSError("other error"),
|
||||||
|
), pytest.raises(OSError):
|
||||||
|
await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# These above can be deleted after deprecation period is finished.
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unload_entry(hass, fakeimg_png, mock_av_open):
|
||||||
|
"""Test unloading the generic IP Camera entry."""
|
||||||
|
mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA)
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_entry.state is config_entries.ConfigEntryState.LOADED
|
||||||
|
|
||||||
|
await hass.config_entries.async_unload(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_entry.state is config_entries.ConfigEntryState.NOT_LOADED
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reload_on_title_change(hass) -> None:
|
||||||
|
"""Test the integration gets reloaded when the title is updated."""
|
||||||
|
|
||||||
|
test_data = TESTDATA_OPTIONS
|
||||||
|
test_data[CONF_CONTENT_TYPE] = "image/png"
|
||||||
|
mock_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN, unique_id="54321", options=test_data, title="My Title"
|
||||||
|
)
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert mock_entry.state is config_entries.ConfigEntryState.LOADED
|
||||||
|
assert hass.states.get("camera.my_title").attributes["friendly_name"] == "My Title"
|
||||||
|
|
||||||
|
hass.config_entries.async_update_entry(mock_entry, title="New Title")
|
||||||
|
assert mock_entry.title == "New Title"
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert hass.states.get("camera.my_title").attributes["friendly_name"] == "New Title"
|
Loading…
Add table
Add a link
Reference in a new issue