From e26488b1caf41aa820ac0db5c6b17dbce4928e5e Mon Sep 17 00:00:00 2001 From: Franck Nijhof <git@frenck.dev> Date: Fri, 18 Feb 2022 09:03:41 +0100 Subject: [PATCH] Add config flow to MJPEG IP Camera (#66607) --- .coveragerc | 1 + homeassistant/components/agent_dvr/camera.py | 2 +- .../components/android_ip_webcam/__init__.py | 2 +- homeassistant/components/axis/camera.py | 2 +- homeassistant/components/mjpeg/__init__.py | 43 +- homeassistant/components/mjpeg/camera.py | 76 +-- homeassistant/components/mjpeg/config_flow.py | 240 ++++++++++ homeassistant/components/mjpeg/const.py | 14 + homeassistant/components/mjpeg/manifest.json | 3 +- homeassistant/components/mjpeg/strings.json | 42 ++ .../components/mjpeg/translations/en.json | 42 ++ homeassistant/components/mjpeg/util.py | 18 + homeassistant/components/motioneye/camera.py | 2 +- homeassistant/components/zoneminder/camera.py | 2 +- homeassistant/generated/config_flows.py | 1 + tests/components/mjpeg/__init__.py | 1 + tests/components/mjpeg/conftest.py | 79 ++++ tests/components/mjpeg/test_config_flow.py | 441 ++++++++++++++++++ tests/components/mjpeg/test_init.py | 99 ++++ 19 files changed, 1073 insertions(+), 37 deletions(-) create mode 100644 homeassistant/components/mjpeg/config_flow.py create mode 100644 homeassistant/components/mjpeg/const.py create mode 100644 homeassistant/components/mjpeg/strings.json create mode 100644 homeassistant/components/mjpeg/translations/en.json create mode 100644 homeassistant/components/mjpeg/util.py create mode 100644 tests/components/mjpeg/__init__.py create mode 100644 tests/components/mjpeg/conftest.py create mode 100644 tests/components/mjpeg/test_config_flow.py create mode 100644 tests/components/mjpeg/test_init.py diff --git a/.coveragerc b/.coveragerc index d5c9f48ee47..8783dd64ab8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -722,6 +722,7 @@ omit = homeassistant/components/minio/* homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py + homeassistant/components/mjpeg/util.py homeassistant/components/mochad/* homeassistant/components/modbus/climate.py homeassistant/components/modem_callerid/sensor.py diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py index bad90efee8f..e82bbeaea1b 100644 --- a/homeassistant/components/agent_dvr/camera.py +++ b/homeassistant/components/agent_dvr/camera.py @@ -5,7 +5,7 @@ import logging from agent import AgentError from homeassistant.components.camera import SUPPORT_ON_OFF -from homeassistant.components.mjpeg.camera import MjpegCamera, filter_urllib3_logging +from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers import entity_platform from homeassistant.helpers.entity import DeviceInfo diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py index d5bc3db45f8..ca4af7fd68a 100644 --- a/homeassistant/components/android_ip_webcam/__init__.py +++ b/homeassistant/components/android_ip_webcam/__init__.py @@ -5,7 +5,7 @@ from datetime import timedelta from pydroid_ipcam import PyDroidIPCam import voluptuous as vol -from homeassistant.components.mjpeg.camera import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL +from homeassistant.components.mjpeg import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL from homeassistant.const import ( CONF_HOST, CONF_NAME, diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index c52aac37a02..2c9a6a52b9e 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -2,7 +2,7 @@ from urllib.parse import urlencode from homeassistant.components.camera import SUPPORT_STREAM -from homeassistant.components.mjpeg.camera import MjpegCamera, filter_urllib3_logging +from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import HTTP_DIGEST_AUTHENTICATION from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 3e7469cff00..632156b7adc 100644 --- a/homeassistant/components/mjpeg/__init__.py +++ b/homeassistant/components/mjpeg/__init__.py @@ -1 +1,42 @@ -"""The mjpeg component.""" +"""The MJPEG IP Camera integration.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .camera import MjpegCamera +from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, PLATFORMS +from .util import filter_urllib3_logging + +__all__ = [ + "CONF_MJPEG_URL", + "CONF_STILL_IMAGE_URL", + "MjpegCamera", + "filter_urllib3_logging", +] + + +def setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the MJPEG IP Camera integration.""" + filter_urllib3_logging() + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + # Reload entry when its updated. + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + 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) + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the config entry when it changed.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index 3a9c1a6cf91..69588c1b670 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Iterable from contextlib import closing -import logging import aiohttp from aiohttp import web @@ -14,6 +13,7 @@ from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -29,13 +29,12 @@ from homeassistant.helpers.aiohttp_client import ( async_aiohttp_proxy_web, async_get_clientsession, ) +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER -CONF_MJPEG_URL = "mjpeg_url" -CONF_STILL_IMAGE_URL = "still_image_url" CONTENT_TYPE_HEADER = "Content-Type" DEFAULT_NAME = "Mjpeg Camera" @@ -62,34 +61,52 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up a MJPEG IP Camera.""" - filter_urllib3_logging() + """Set up the MJPEG IP camera from platform.""" + LOGGER.warning( + "Configuration of the MJPEG IP Camera platform in YAML is deprecated " + "and will be removed in Home Assistant 2022.5; Your existing " + "configuration has been imported into the UI automatically and can be " + "safely removed from your configuration.yaml file" + ) if discovery_info: config = PLATFORM_SCHEMA(discovery_info) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up a MJPEG IP Camera based on a config entry.""" async_add_entities( [ MjpegCamera( - name=config[CONF_NAME], - authentication=config[CONF_AUTHENTICATION], - username=config.get(CONF_USERNAME), - password=config[CONF_PASSWORD], - mjpeg_url=config[CONF_MJPEG_URL], - still_image_url=config.get(CONF_STILL_IMAGE_URL), - verify_ssl=config[CONF_VERIFY_SSL], + name=entry.title, + authentication=entry.options[CONF_AUTHENTICATION], + username=entry.options.get(CONF_USERNAME), + password=entry.options[CONF_PASSWORD], + mjpeg_url=entry.options[CONF_MJPEG_URL], + still_image_url=entry.options.get(CONF_STILL_IMAGE_URL), + verify_ssl=entry.options[CONF_VERIFY_SSL], + unique_id=entry.entry_id, + device_info=DeviceInfo( + name=entry.title, + identifiers={(DOMAIN, entry.entry_id)}, + ), ) ] ) -def filter_urllib3_logging() -> None: - """Filter header errors from urllib3 due to a urllib3 bug.""" - urllib3_logger = logging.getLogger("urllib3.connectionpool") - if not any(isinstance(x, NoHeaderErrorFilter) for x in urllib3_logger.filters): - urllib3_logger.addFilter(NoHeaderErrorFilter()) - - def extract_image_from_mjpeg(stream: Iterable[bytes]) -> bytes | None: """Take in a MJPEG stream object, return the jpg from it.""" data = b"" @@ -124,6 +141,8 @@ class MjpegCamera(Camera): username: str | None = None, password: str = "", verify_ssl: bool = True, + unique_id: str | None = None, + device_info: DeviceInfo | None = None, ) -> None: """Initialize a MJPEG camera.""" super().__init__() @@ -143,6 +162,11 @@ class MjpegCamera(Camera): self._auth = aiohttp.BasicAuth(self._username, password=self._password) self._verify_ssl = verify_ssl + if unique_id is not None: + self._attr_unique_id = unique_id + if device_info is not None: + self._attr_device_info = device_info + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: @@ -164,10 +188,10 @@ class MjpegCamera(Camera): return image except asyncio.TimeoutError: - _LOGGER.error("Timeout getting camera image from %s", self.name) + LOGGER.error("Timeout getting camera image from %s", self.name) except aiohttp.ClientError as err: - _LOGGER.error("Error getting new camera image from %s: %s", self.name, err) + LOGGER.error("Error getting new camera image from %s: %s", self.name, err) return None @@ -208,11 +232,3 @@ class MjpegCamera(Camera): stream_coro = websession.get(self._mjpeg_url, auth=self._auth) return await async_aiohttp_proxy_web(self.hass, request, stream_coro) - - -class NoHeaderErrorFilter(logging.Filter): - """Filter out urllib3 Header Parsing Errors due to a urllib3 bug.""" - - def filter(self, record: logging.LogRecord) -> bool: - """Filter out Header Parsing Errors.""" - return "Failed to parse headers" not in record.getMessage() diff --git a/homeassistant/components/mjpeg/config_flow.py b/homeassistant/components/mjpeg/config_flow.py new file mode 100644 index 00000000000..2eecdd84d78 --- /dev/null +++ b/homeassistant/components/mjpeg/config_flow.py @@ -0,0 +1,240 @@ +"""Config flow to configure the MJPEG IP Camera integration.""" +from __future__ import annotations + +from http import HTTPStatus +from types import MappingProxyType +from typing import Any + +import requests +from requests.auth import HTTPBasicAuth, HTTPDigestAuth +from requests.exceptions import HTTPError, Timeout +import voluptuous as vol + +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.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER + + +@callback +def async_get_schema( + defaults: dict[str, Any] | MappingProxyType[str, Any], show_name: bool = False +) -> vol.Schema: + """Return MJPEG IP Camera schema.""" + schema = { + vol.Required(CONF_MJPEG_URL, default=defaults.get(CONF_MJPEG_URL)): str, + vol.Optional( + CONF_STILL_IMAGE_URL, + description={"suggested_value": defaults.get(CONF_STILL_IMAGE_URL)}, + ): str, + vol.Optional( + CONF_USERNAME, + description={"suggested_value": defaults.get(CONF_USERNAME)}, + ): str, + vol.Optional( + CONF_PASSWORD, + default=defaults.get(CONF_PASSWORD, ""), + ): str, + vol.Optional( + CONF_VERIFY_SSL, + default=defaults.get(CONF_VERIFY_SSL, True), + ): bool, + } + + if show_name: + schema = { + vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME)): str, + **schema, + } + + return vol.Schema(schema) + + +def validate_url( + url: str, + username: str | None, + password: str, + verify_ssl: bool, + authentication: str = HTTP_BASIC_AUTHENTICATION, +) -> str: + """Test if the given setting works as expected.""" + auth: HTTPDigestAuth | HTTPBasicAuth | None = None + if username and password: + if authentication == HTTP_DIGEST_AUTHENTICATION: + auth = HTTPDigestAuth(username, password) + else: + auth = HTTPBasicAuth(username, password) + + response = requests.get( + url, + auth=auth, + stream=True, + timeout=10, + verify=verify_ssl, + ) + + if response.status_code == HTTPStatus.UNAUTHORIZED: + # If unauthorized, try again using digest auth + if authentication == HTTP_BASIC_AUTHENTICATION: + return validate_url( + url, username, password, verify_ssl, HTTP_DIGEST_AUTHENTICATION + ) + raise InvalidAuth + + response.raise_for_status() + response.close() + + return authentication + + +async def async_validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> tuple[dict[str, str], str]: + """Manage MJPEG IP Camera options.""" + errors = {} + field = "base" + authentication = HTTP_BASIC_AUTHENTICATION + try: + for field in (CONF_MJPEG_URL, CONF_STILL_IMAGE_URL): + if not (url := user_input.get(field)): + continue + authentication = await hass.async_add_executor_job( + validate_url, + url, + user_input.get(CONF_USERNAME), + user_input[CONF_PASSWORD], + user_input[CONF_VERIFY_SSL], + ) + except InvalidAuth: + errors["username"] = "invalid_auth" + except (OSError, HTTPError, Timeout): + LOGGER.exception("Cannot connect to %s", user_input[CONF_MJPEG_URL]) + errors[field] = "cannot_connect" + + return (errors, authentication) + + +class MJPEGFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for MJPEG IP Camera.""" + + VERSION = 1 + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> MJPEGOptionsFlowHandler: + """Get the options flow for this handler.""" + return MJPEGOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, authentication = await async_validate_input(self.hass, user_input) + if not errors: + self._async_abort_entries_match( + {CONF_MJPEG_URL: user_input[CONF_MJPEG_URL]} + ) + + # Storing data in option, to allow for changing them later + # using an options flow. + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_MJPEG_URL]), + data={}, + options={ + CONF_AUTHENTICATION: authentication, + CONF_MJPEG_URL: user_input[CONF_MJPEG_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_STILL_IMAGE_URL: user_input.get(CONF_STILL_IMAGE_URL), + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=async_get_schema(user_input, show_name=True), + errors=errors, + ) + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Handle a flow initialized by importing a config.""" + self._async_abort_entries_match({CONF_MJPEG_URL: config[CONF_MJPEG_URL]}) + return self.async_create_entry( + title=config[CONF_NAME], + data={}, + options={ + CONF_AUTHENTICATION: config[CONF_AUTHENTICATION], + CONF_MJPEG_URL: config[CONF_MJPEG_URL], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_STILL_IMAGE_URL: config.get(CONF_STILL_IMAGE_URL), + CONF_USERNAME: config.get(CONF_USERNAME), + CONF_VERIFY_SSL: config[CONF_VERIFY_SSL], + }, + ) + + +class MJPEGOptionsFlowHandler(OptionsFlow): + """Handle MJPEG IP Camera options.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize MJPEG IP Camera options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage MJPEG IP Camera options.""" + errors: dict[str, str] = {} + + if user_input is not None: + errors, authentication = await async_validate_input(self.hass, user_input) + if not errors: + for entry in self.hass.config_entries.async_entries(DOMAIN): + if ( + entry.entry_id != self.config_entry.entry_id + and entry.options[CONF_MJPEG_URL] == user_input[CONF_MJPEG_URL] + ): + errors = {CONF_MJPEG_URL: "already_configured"} + + if not errors: + return self.async_create_entry( + title=user_input.get(CONF_NAME, user_input[CONF_MJPEG_URL]), + data={ + CONF_AUTHENTICATION: authentication, + CONF_MJPEG_URL: user_input[CONF_MJPEG_URL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_STILL_IMAGE_URL: user_input.get(CONF_STILL_IMAGE_URL), + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + }, + ) + else: + user_input = {} + + return self.async_show_form( + step_id="init", + data_schema=async_get_schema(user_input or self.config_entry.options), + errors=errors, + ) + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/mjpeg/const.py b/homeassistant/components/mjpeg/const.py new file mode 100644 index 00000000000..94cd01676ec --- /dev/null +++ b/homeassistant/components/mjpeg/const.py @@ -0,0 +1,14 @@ +"""Constants for the MJPEG integration.""" + +import logging +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "mjpeg" +PLATFORMS: Final = [Platform.CAMERA] + +LOGGER = logging.getLogger(__package__) + +CONF_MJPEG_URL: Final = "mjpeg_url" +CONF_STILL_IMAGE_URL: Final = "still_image_url" diff --git a/homeassistant/components/mjpeg/manifest.json b/homeassistant/components/mjpeg/manifest.json index 88e4cdba356..02726c3bb3f 100644 --- a/homeassistant/components/mjpeg/manifest.json +++ b/homeassistant/components/mjpeg/manifest.json @@ -3,5 +3,6 @@ "name": "MJPEG IP Camera", "documentation": "https://www.home-assistant.io/integrations/mjpeg", "codeowners": [], - "iot_class": "local_push" + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/mjpeg/strings.json b/homeassistant/components/mjpeg/strings.json new file mode 100644 index 00000000000..73e6a150a09 --- /dev/null +++ b/homeassistant/components/mjpeg/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mjpeg_url": "MJPEG URL", + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::password%]", + "still_image_url": "Still Image URL", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "mjpeg_url": "MJPEG URL", + "name": "[%key:common::config_flow::data::name%]", + "password": "[%key:common::config_flow::data::password%]", + "still_image_url": "Still Image URL", + "username": "[%key:common::config_flow::data::username%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + } + } +} diff --git a/homeassistant/components/mjpeg/translations/en.json b/homeassistant/components/mjpeg/translations/en.json new file mode 100644 index 00000000000..e389850a360 --- /dev/null +++ b/homeassistant/components/mjpeg/translations/en.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "mjpeg_url": "MJPEG URL", + "name": "Name", + "password": "Password", + "still_image_url": "Still Image URL", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + } + } + } + }, + "options": { + "error": { + "already_configured": "Device is already configured", + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "init": { + "data": { + "mjpeg_url": "MJPEG URL", + "name": "Name", + "password": "Password", + "still_image_url": "Still Image URL", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mjpeg/util.py b/homeassistant/components/mjpeg/util.py new file mode 100644 index 00000000000..068d0aafc7e --- /dev/null +++ b/homeassistant/components/mjpeg/util.py @@ -0,0 +1,18 @@ +"""Utilities for MJPEG IP Camera.""" + +import logging + + +class NoHeaderErrorFilter(logging.Filter): + """Filter out urllib3 Header Parsing Errors due to a urllib3 bug.""" + + def filter(self, record: logging.LogRecord) -> bool: + """Filter out Header Parsing Errors.""" + return "Failed to parse headers" not in record.getMessage() + + +def filter_urllib3_logging() -> None: + """Filter header errors from urllib3 due to a urllib3 bug.""" + urllib3_logger = logging.getLogger("urllib3.connectionpool") + if not any(isinstance(x, NoHeaderErrorFilter) for x in urllib3_logger.filters): + urllib3_logger.addFilter(NoHeaderErrorFilter()) diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index acf170472da..e5e4f224fe6 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -24,7 +24,7 @@ from motioneye_client.const import ( ) import voluptuous as vol -from homeassistant.components.mjpeg.camera import ( +from homeassistant.components.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py index 5e262ff6903..a7f0cf1d7fa 100644 --- a/homeassistant/components/zoneminder/camera.py +++ b/homeassistant/components/zoneminder/camera.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from homeassistant.components.mjpeg.camera import MjpegCamera, filter_urllib3_logging +from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 72037428e68..a4f774e64a7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -196,6 +196,7 @@ FLOWS = [ "mikrotik", "mill", "minecraft_server", + "mjpeg", "mobile_app", "modem_callerid", "modern_forms", diff --git a/tests/components/mjpeg/__init__.py b/tests/components/mjpeg/__init__.py new file mode 100644 index 00000000000..b3b796dc3b1 --- /dev/null +++ b/tests/components/mjpeg/__init__.py @@ -0,0 +1 @@ +"""Tests for the MJPEG IP Camera integration.""" diff --git a/tests/components/mjpeg/conftest.py b/tests/components/mjpeg/conftest.py new file mode 100644 index 00000000000..c09bbde2f1d --- /dev/null +++ b/tests/components/mjpeg/conftest.py @@ -0,0 +1,79 @@ +"""Fixtures for MJPEG IP Camera integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from requests_mock import Mocker + +from homeassistant.components.mjpeg.const import ( + CONF_MJPEG_URL, + CONF_STILL_IMAGE_URL, + DOMAIN, +) +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My MJPEG Camera", + domain=DOMAIN, + data={}, + options={ + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "supersecret", + CONF_STILL_IMAGE_URL: "http://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: True, + }, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.mjpeg.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_reload_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.mjpeg.async_reload_entry") as mock_reload: + yield mock_reload + + +@pytest.fixture +def mock_mjpeg_requests(requests_mock: Mocker) -> Generator[Mocker, None, None]: + """Fixture to provide a requests mocker.""" + requests_mock.get("https://example.com/mjpeg", text="resp") + requests_mock.get("https://example.com/still", text="resp") + yield requests_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_mjpeg_requests: Mocker +) -> MockConfigEntry: + """Set up the MJPEG IP Camera integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/mjpeg/test_config_flow.py b/tests/components/mjpeg/test_config_flow.py new file mode 100644 index 00000000000..0678353cf6d --- /dev/null +++ b/tests/components/mjpeg/test_config_flow.py @@ -0,0 +1,441 @@ +"""Tests for the MJPEG IP Camera config flow.""" + +from unittest.mock import AsyncMock + +import requests +from requests_mock import Mocker + +from homeassistant.components.mjpeg.const import ( + CONF_MJPEG_URL, + CONF_STILL_IMAGE_URL, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_mjpeg_requests: Mocker, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Spy cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_STILL_IMAGE_URL: "https://example.com/still", + CONF_USERNAME: "frenck", + CONF_PASSWORD: "omgpuppies", + CONF_VERIFY_SSL: False, + }, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Spy cam" + assert result2.get("data") == {} + assert result2.get("options") == { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "omgpuppies", + CONF_STILL_IMAGE_URL: "https://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: False, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_mjpeg_requests.call_count == 2 + + +async def test_full_flow_with_authentication_error( + hass: HomeAssistant, + mock_mjpeg_requests: Mocker, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full user configuration flow with invalid credentials. + + This tests tests a full config flow, with a case the user enters an invalid + credentials, but recovers by entering the correct ones. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + mock_mjpeg_requests.get( + "https://example.com/mjpeg", text="Access Denied!", status_code=401 + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "Sky cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "omgpuppies", + CONF_USERNAME: "frenck", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"username": "invalid_auth"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert mock_mjpeg_requests.call_count == 2 + + mock_mjpeg_requests.get("https://example.com/mjpeg", text="resp") + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_NAME: "Sky cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "supersecret", + CONF_USERNAME: "frenck", + }, + ) + + assert result3.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result3.get("title") == "Sky cam" + assert result3.get("data") == {} + assert result3.get("options") == { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "supersecret", + CONF_STILL_IMAGE_URL: None, + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: True, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_mjpeg_requests.call_count == 3 + + +async def test_connection_error( + hass: HomeAssistant, + mock_mjpeg_requests: Mocker, + mock_setup_entry: AsyncMock, +) -> None: + """Test connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + # Test connectione error on MJPEG url + mock_mjpeg_requests.get( + "https://example.com/mjpeg", exc=requests.exceptions.ConnectionError + ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "My cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_STILL_IMAGE_URL: "https://example.com/still", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == SOURCE_USER + assert result2.get("errors") == {"mjpeg_url": "cannot_connect"} + assert "flow_id" in result2 + + assert len(mock_setup_entry.mock_calls) == 0 + assert mock_mjpeg_requests.call_count == 1 + + # Reset + mock_mjpeg_requests.get("https://example.com/mjpeg", text="resp") + + # Test connectione error on still url + mock_mjpeg_requests.get( + "https://example.com/still", exc=requests.exceptions.ConnectionError + ) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={ + CONF_NAME: "My cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_STILL_IMAGE_URL: "https://example.com/still", + }, + ) + + assert result3.get("type") == RESULT_TYPE_FORM + assert result3.get("step_id") == SOURCE_USER + assert result3.get("errors") == {"still_image_url": "cannot_connect"} + assert "flow_id" in result3 + + assert len(mock_setup_entry.mock_calls) == 0 + assert mock_mjpeg_requests.call_count == 3 + + # Reset + mock_mjpeg_requests.get("https://example.com/still", text="resp") + + # Finish + result4 = await hass.config_entries.flow.async_configure( + result3["flow_id"], + user_input={ + CONF_NAME: "My cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_STILL_IMAGE_URL: "https://example.com/still", + }, + ) + + assert result4.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result4.get("title") == "My cam" + assert result4.get("data") == {} + assert result4.get("options") == { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "", + CONF_STILL_IMAGE_URL: "https://example.com/still", + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_mjpeg_requests.call_count == 5 + + +async def test_already_configured( + hass: HomeAssistant, + mock_mjpeg_requests: Mocker, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if the MJPEG IP Camera is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "My cam", + CONF_MJPEG_URL: "https://example.com/mjpeg", + }, + ) + + assert result2.get("type") == RESULT_TYPE_ABORT + assert result2.get("reason") == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_mjpeg_requests: Mocker, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + CONF_MJPEG_URL: "http://example.com/mjpeg", + CONF_NAME: "Imported Camera", + CONF_PASSWORD: "omgpuppies", + CONF_STILL_IMAGE_URL: "http://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: False, + }, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "Imported Camera" + assert result.get("data") == {} + assert result.get("options") == { + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + CONF_MJPEG_URL: "http://example.com/mjpeg", + CONF_PASSWORD: "omgpuppies", + CONF_STILL_IMAGE_URL: "http://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: False, + } + + assert len(mock_setup_entry.mock_calls) == 1 + assert mock_mjpeg_requests.call_count == 0 + + +async def test_import_flow_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test the import configuration flow for an already configured entry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_NAME: "Imported Camera", + CONF_PASSWORD: "omgpuppies", + CONF_STILL_IMAGE_URL: "https://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: False, + }, + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_options_flow( + hass: HomeAssistant, + mock_mjpeg_requests: Mocker, + init_integration: MockConfigEntry, +) -> None: + """Test options config flow.""" + result = await hass.config_entries.options.async_init(init_integration.entry_id) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == "init" + assert "flow_id" in result + + # Register a second camera + mock_mjpeg_requests.get("https://example.com/second_camera", text="resp") + mock_second_config_entry = MockConfigEntry( + title="Another Camera", + domain=DOMAIN, + data={}, + options={ + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/second_camera", + CONF_PASSWORD: "", + CONF_STILL_IMAGE_URL: None, + CONF_USERNAME: None, + CONF_VERIFY_SSL: True, + }, + ) + mock_second_config_entry.add_to_hass(hass) + + # Try updating options to already existing secondary camera + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MJPEG_URL: "https://example.com/second_camera", + }, + ) + + assert result2.get("type") == RESULT_TYPE_FORM + assert result2.get("step_id") == "init" + assert result2.get("errors") == {"mjpeg_url": "already_configured"} + assert "flow_id" in result2 + + assert mock_mjpeg_requests.call_count == 1 + + # Test connectione error on MJPEG url + mock_mjpeg_requests.get( + "https://example.com/invalid_mjpeg", exc=requests.exceptions.ConnectionError + ) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], + user_input={ + CONF_MJPEG_URL: "https://example.com/invalid_mjpeg", + CONF_STILL_IMAGE_URL: "https://example.com/still", + }, + ) + + assert result3.get("type") == RESULT_TYPE_FORM + assert result3.get("step_id") == "init" + assert result3.get("errors") == {"mjpeg_url": "cannot_connect"} + assert "flow_id" in result3 + + assert mock_mjpeg_requests.call_count == 2 + + # Test connectione error on still url + mock_mjpeg_requests.get( + "https://example.com/invalid_still", exc=requests.exceptions.ConnectionError + ) + result4 = await hass.config_entries.options.async_configure( + result3["flow_id"], + user_input={ + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_STILL_IMAGE_URL: "https://example.com/invalid_still", + }, + ) + + assert result4.get("type") == RESULT_TYPE_FORM + assert result4.get("step_id") == "init" + assert result4.get("errors") == {"still_image_url": "cannot_connect"} + assert "flow_id" in result4 + + assert mock_mjpeg_requests.call_count == 4 + + # Invalid credentials + mock_mjpeg_requests.get( + "https://example.com/invalid_auth", text="Access Denied!", status_code=401 + ) + result5 = await hass.config_entries.options.async_configure( + result4["flow_id"], + user_input={ + CONF_MJPEG_URL: "https://example.com/invalid_auth", + CONF_PASSWORD: "omgpuppies", + CONF_USERNAME: "frenck", + }, + ) + + assert result5.get("type") == RESULT_TYPE_FORM + assert result5.get("step_id") == "init" + assert result5.get("errors") == {"username": "invalid_auth"} + assert "flow_id" in result5 + + assert mock_mjpeg_requests.call_count == 6 + + # Finish + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input={ + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "evenmorepuppies", + CONF_USERNAME: "newuser", + }, + ) + + assert result6.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result6.get("data") == { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "https://example.com/mjpeg", + CONF_PASSWORD: "evenmorepuppies", + CONF_STILL_IMAGE_URL: None, + CONF_USERNAME: "newuser", + CONF_VERIFY_SSL: True, + } + + assert mock_mjpeg_requests.call_count == 7 diff --git a/tests/components/mjpeg/test_init.py b/tests/components/mjpeg/test_init.py new file mode 100644 index 00000000000..853e0feb687 --- /dev/null +++ b/tests/components/mjpeg/test_init.py @@ -0,0 +1,99 @@ +"""Tests for the MJPEG IP Camera integration.""" +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.mjpeg.const import ( + CONF_MJPEG_URL, + CONF_STILL_IMAGE_URL, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_mjpeg_requests: MagicMock, +) -> None: + """Test the MJPEG IP Camera configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_reload_config_entry( + hass: HomeAssistant, + mock_reload_entry: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Test the MJPEG IP Camera configuration entry is reloaded on change.""" + assert len(mock_reload_entry.mock_calls) == 0 + hass.config_entries.async_update_entry( + init_integration, options={"something": "else"} + ) + assert len(mock_reload_entry.mock_calls) == 1 + + +async def test_import_config( + hass: HomeAssistant, + mock_mjpeg_requests: MagicMock, + mock_setup_entry: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test MJPEG IP Camera being set up from config via import.""" + assert await async_setup_component( + hass, + CAMERA_DOMAIN, + { + CAMERA_DOMAIN: { + "platform": DOMAIN, + CONF_MJPEG_URL: "http://example.com/mjpeg", + CONF_NAME: "Random Camera", + CONF_PASSWORD: "supersecret", + CONF_STILL_IMAGE_URL: "http://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: False, + } + }, + ) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + + assert "the MJPEG IP Camera platform in YAML is deprecated" in caplog.text + + entry = config_entries[0] + assert entry.title == "Random Camera" + assert entry.unique_id is None + assert entry.data == {} + assert entry.options == { + CONF_AUTHENTICATION: HTTP_BASIC_AUTHENTICATION, + CONF_MJPEG_URL: "http://example.com/mjpeg", + CONF_PASSWORD: "supersecret", + CONF_STILL_IMAGE_URL: "http://example.com/still", + CONF_USERNAME: "frenck", + CONF_VERIFY_SSL: False, + } -- GitLab