Create a motionEye integration (#48239)
This commit is contained in:
parent
a380632384
commit
bbe58091a8
13 changed files with 1387 additions and 0 deletions
|
@ -294,6 +294,7 @@ homeassistant/components/modbus/* @adamchengtkc @janiversen @vzahradnik
|
|||
homeassistant/components/monoprice/* @etsinko @OnFreund
|
||||
homeassistant/components/moon/* @fabaff
|
||||
homeassistant/components/motion_blinds/* @starkillerOG
|
||||
homeassistant/components/motioneye/* @dermotduffy
|
||||
homeassistant/components/mpd/* @fabaff
|
||||
homeassistant/components/mqtt/* @emontnemery
|
||||
homeassistant/components/msteams/* @peroyvind
|
||||
|
|
258
homeassistant/components/motioneye/__init__.py
Normal file
258
homeassistant/components/motioneye/__init__.py
Normal file
|
@ -0,0 +1,258 @@
|
|||
"""The motionEye integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from motioneye_client.client import (
|
||||
MotionEyeClient,
|
||||
MotionEyeClientError,
|
||||
MotionEyeClientInvalidAuthError,
|
||||
)
|
||||
from motioneye_client.const import KEY_CAMERAS, KEY_ID, KEY_NAME
|
||||
|
||||
from homeassistant.components.camera.const import DOMAIN as CAMERA_DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry
|
||||
from homeassistant.const import CONF_SOURCE, CONF_URL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import (
|
||||
async_dispatcher_connect,
|
||||
async_dispatcher_send,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
CONF_ADMIN_PASSWORD,
|
||||
CONF_ADMIN_USERNAME,
|
||||
CONF_CLIENT,
|
||||
CONF_CONFIG_ENTRY,
|
||||
CONF_COORDINATOR,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
MOTIONEYE_MANUFACTURER,
|
||||
SIGNAL_CAMERA_ADD,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [CAMERA_DOMAIN]
|
||||
|
||||
|
||||
def create_motioneye_client(
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> MotionEyeClient:
|
||||
"""Create a MotionEyeClient."""
|
||||
return MotionEyeClient(*args, **kwargs)
|
||||
|
||||
|
||||
def get_motioneye_device_identifier(
|
||||
config_entry_id: str, camera_id: int
|
||||
) -> tuple[str, str, int]:
|
||||
"""Get the identifiers for a motionEye device."""
|
||||
return (DOMAIN, config_entry_id, camera_id)
|
||||
|
||||
|
||||
def get_motioneye_entity_unique_id(
|
||||
config_entry_id: str, camera_id: int, entity_type: str
|
||||
) -> str:
|
||||
"""Get the unique_id for a motionEye entity."""
|
||||
return f"{config_entry_id}_{camera_id}_{entity_type}"
|
||||
|
||||
|
||||
def get_camera_from_cameras(
|
||||
camera_id: int, data: dict[str, Any]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get an individual camera dict from a multiple cameras data response."""
|
||||
for camera in data.get(KEY_CAMERAS) or []:
|
||||
if camera.get(KEY_ID) == camera_id:
|
||||
val: dict[str, Any] = camera
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def is_acceptable_camera(camera: dict[str, Any] | None) -> bool:
|
||||
"""Determine if a camera dict is acceptable."""
|
||||
return bool(camera and KEY_ID in camera and KEY_NAME in camera)
|
||||
|
||||
|
||||
@callback
|
||||
def listen_for_new_cameras(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
add_func: Callable,
|
||||
) -> None:
|
||||
"""Listen for new cameras."""
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass,
|
||||
SIGNAL_CAMERA_ADD.format(entry.entry_id),
|
||||
add_func,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _create_reauth_flow(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
) -> None:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
CONF_SOURCE: SOURCE_REAUTH,
|
||||
CONF_CONFIG_ENTRY: config_entry,
|
||||
},
|
||||
data=config_entry.data,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def _add_camera(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
client: MotionEyeClient,
|
||||
entry: ConfigEntry,
|
||||
camera_id: int,
|
||||
camera: dict[str, Any],
|
||||
device_identifier: tuple[str, str, int],
|
||||
) -> None:
|
||||
"""Add a motionEye camera to hass."""
|
||||
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={device_identifier},
|
||||
manufacturer=MOTIONEYE_MANUFACTURER,
|
||||
model=MOTIONEYE_MANUFACTURER,
|
||||
name=camera[KEY_NAME],
|
||||
)
|
||||
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
SIGNAL_CAMERA_ADD.format(entry.entry_id),
|
||||
camera,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up motionEye from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
client = create_motioneye_client(
|
||||
entry.data[CONF_URL],
|
||||
admin_username=entry.data.get(CONF_ADMIN_USERNAME),
|
||||
admin_password=entry.data.get(CONF_ADMIN_PASSWORD),
|
||||
surveillance_username=entry.data.get(CONF_SURVEILLANCE_USERNAME),
|
||||
surveillance_password=entry.data.get(CONF_SURVEILLANCE_PASSWORD),
|
||||
)
|
||||
|
||||
try:
|
||||
await client.async_client_login()
|
||||
except MotionEyeClientInvalidAuthError:
|
||||
await client.async_client_close()
|
||||
await _create_reauth_flow(hass, entry)
|
||||
return False
|
||||
except MotionEyeClientError as exc:
|
||||
await client.async_client_close()
|
||||
raise ConfigEntryNotReady from exc
|
||||
|
||||
@callback
|
||||
async def async_update_data() -> dict[str, Any] | None:
|
||||
try:
|
||||
return await client.async_get_cameras()
|
||||
except MotionEyeClientError as exc:
|
||||
raise UpdateFailed("Error communicating with API") from exc
|
||||
|
||||
coordinator = DataUpdateCoordinator(
|
||||
hass,
|
||||
_LOGGER,
|
||||
name=DOMAIN,
|
||||
update_method=async_update_data,
|
||||
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||
)
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
CONF_CLIENT: client,
|
||||
CONF_COORDINATOR: coordinator,
|
||||
}
|
||||
|
||||
current_cameras: set[tuple[str, str, int]] = set()
|
||||
device_registry = await dr.async_get_registry(hass)
|
||||
|
||||
@callback
|
||||
def _async_process_motioneye_cameras() -> None:
|
||||
"""Process motionEye camera additions and removals."""
|
||||
inbound_camera: set[tuple[str, str, int]] = set()
|
||||
if KEY_CAMERAS not in coordinator.data:
|
||||
return
|
||||
|
||||
for camera in coordinator.data[KEY_CAMERAS]:
|
||||
if not is_acceptable_camera(camera):
|
||||
return
|
||||
camera_id = camera[KEY_ID]
|
||||
device_identifier = get_motioneye_device_identifier(
|
||||
entry.entry_id, camera_id
|
||||
)
|
||||
inbound_camera.add(device_identifier)
|
||||
|
||||
if device_identifier in current_cameras:
|
||||
continue
|
||||
current_cameras.add(device_identifier)
|
||||
_add_camera(
|
||||
hass,
|
||||
device_registry,
|
||||
client,
|
||||
entry,
|
||||
camera_id,
|
||||
camera,
|
||||
device_identifier,
|
||||
)
|
||||
|
||||
# Ensure every device associated with this config entry is still in the list of
|
||||
# motionEye cameras, otherwise remove the device (and thus entities).
|
||||
for device_entry in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
for identifier in device_entry.identifiers:
|
||||
if identifier in inbound_camera:
|
||||
break
|
||||
else:
|
||||
device_registry.async_remove_device(device_entry.id)
|
||||
|
||||
async def setup_then_listen() -> None:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_setup(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
]
|
||||
)
|
||||
entry.async_on_unload(
|
||||
coordinator.async_add_listener(_async_process_motioneye_cameras)
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
hass.async_create_task(setup_then_listen())
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = all(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.config_entries.async_forward_entry_unload(entry, platform)
|
||||
for platform in PLATFORMS
|
||||
]
|
||||
)
|
||||
)
|
||||
if unload_ok:
|
||||
config_data = hass.data[DOMAIN].pop(entry.entry_id)
|
||||
await config_data[CONF_CLIENT].async_client_close()
|
||||
|
||||
return unload_ok
|
208
homeassistant/components/motioneye/camera.py
Normal file
208
homeassistant/components/motioneye/camera.py
Normal file
|
@ -0,0 +1,208 @@
|
|||
"""The motionEye integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
import aiohttp
|
||||
from motioneye_client.client import MotionEyeClient
|
||||
from motioneye_client.const import (
|
||||
DEFAULT_SURVEILLANCE_USERNAME,
|
||||
KEY_ID,
|
||||
KEY_MOTION_DETECTION,
|
||||
KEY_NAME,
|
||||
KEY_STREAMING_AUTH_MODE,
|
||||
)
|
||||
|
||||
from homeassistant.components.mjpeg.camera import (
|
||||
CONF_MJPEG_URL,
|
||||
CONF_STILL_IMAGE_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
MjpegCamera,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_AUTHENTICATION,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
HTTP_BASIC_AUTHENTICATION,
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
)
|
||||
|
||||
from . import (
|
||||
get_camera_from_cameras,
|
||||
get_motioneye_device_identifier,
|
||||
get_motioneye_entity_unique_id,
|
||||
is_acceptable_camera,
|
||||
listen_for_new_cameras,
|
||||
)
|
||||
from .const import (
|
||||
CONF_CLIENT,
|
||||
CONF_COORDINATOR,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
DOMAIN,
|
||||
MOTIONEYE_MANUFACTURER,
|
||||
TYPE_MOTIONEYE_MJPEG_CAMERA,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["camera"]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable
|
||||
) -> bool:
|
||||
"""Set up motionEye from a config entry."""
|
||||
entry_data = hass.data[DOMAIN][entry.entry_id]
|
||||
|
||||
@callback
|
||||
def camera_add(camera: dict[str, Any]) -> None:
|
||||
"""Add a new motionEye camera."""
|
||||
async_add_entities(
|
||||
[
|
||||
MotionEyeMjpegCamera(
|
||||
entry.entry_id,
|
||||
entry.data.get(
|
||||
CONF_SURVEILLANCE_USERNAME, DEFAULT_SURVEILLANCE_USERNAME
|
||||
),
|
||||
entry.data.get(CONF_SURVEILLANCE_PASSWORD, ""),
|
||||
camera,
|
||||
entry_data[CONF_CLIENT],
|
||||
entry_data[CONF_COORDINATOR],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
listen_for_new_cameras(hass, entry, camera_add)
|
||||
return True
|
||||
|
||||
|
||||
class MotionEyeMjpegCamera(MjpegCamera, CoordinatorEntity):
|
||||
"""motionEye mjpeg camera."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_entry_id: str,
|
||||
username: str,
|
||||
password: str,
|
||||
camera: dict[str, Any],
|
||||
client: MotionEyeClient,
|
||||
coordinator: DataUpdateCoordinator,
|
||||
):
|
||||
"""Initialize a MJPEG camera."""
|
||||
self._surveillance_username = username
|
||||
self._surveillance_password = password
|
||||
self._client = client
|
||||
self._camera_id = camera[KEY_ID]
|
||||
self._device_identifier = get_motioneye_device_identifier(
|
||||
config_entry_id, self._camera_id
|
||||
)
|
||||
self._unique_id = get_motioneye_entity_unique_id(
|
||||
config_entry_id, self._camera_id, TYPE_MOTIONEYE_MJPEG_CAMERA
|
||||
)
|
||||
self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False)
|
||||
self._available = MotionEyeMjpegCamera._is_acceptable_streaming_camera(camera)
|
||||
|
||||
# motionEye cameras are always streaming or unavailable.
|
||||
self.is_streaming = True
|
||||
|
||||
MjpegCamera.__init__(
|
||||
self,
|
||||
{
|
||||
CONF_VERIFY_SSL: False,
|
||||
**self._get_mjpeg_camera_properties_for_camera(camera),
|
||||
},
|
||||
)
|
||||
CoordinatorEntity.__init__(self, coordinator)
|
||||
|
||||
@callback
|
||||
def _get_mjpeg_camera_properties_for_camera(
|
||||
self, camera: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Convert a motionEye camera to MjpegCamera internal properties."""
|
||||
auth = None
|
||||
if camera.get(KEY_STREAMING_AUTH_MODE) in [
|
||||
HTTP_BASIC_AUTHENTICATION,
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
]:
|
||||
auth = camera[KEY_STREAMING_AUTH_MODE]
|
||||
|
||||
return {
|
||||
CONF_NAME: camera[KEY_NAME],
|
||||
CONF_USERNAME: self._surveillance_username if auth is not None else None,
|
||||
CONF_PASSWORD: self._surveillance_password if auth is not None else None,
|
||||
CONF_MJPEG_URL: self._client.get_camera_stream_url(camera) or "",
|
||||
CONF_STILL_IMAGE_URL: self._client.get_camera_snapshot_url(camera),
|
||||
CONF_AUTHENTICATION: auth,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _set_mjpeg_camera_state_for_camera(self, camera: dict[str, Any]) -> None:
|
||||
"""Set the internal state to match the given camera."""
|
||||
|
||||
# Sets the state of the underlying (inherited) MjpegCamera based on the updated
|
||||
# MotionEye camera dictionary.
|
||||
properties = self._get_mjpeg_camera_properties_for_camera(camera)
|
||||
self._name = properties[CONF_NAME]
|
||||
self._username = properties[CONF_USERNAME]
|
||||
self._password = properties[CONF_PASSWORD]
|
||||
self._mjpeg_url = properties[CONF_MJPEG_URL]
|
||||
self._still_image_url = properties[CONF_STILL_IMAGE_URL]
|
||||
self._authentication = properties[CONF_AUTHENTICATION]
|
||||
|
||||
if self._authentication == HTTP_BASIC_AUTHENTICATION:
|
||||
self._auth = aiohttp.BasicAuth(self._username, password=self._password)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return a unique id for this instance."""
|
||||
return self._unique_id
|
||||
|
||||
@classmethod
|
||||
def _is_acceptable_streaming_camera(cls, camera: dict[str, Any] | None) -> bool:
|
||||
"""Determine if a camera is streaming/usable."""
|
||||
return is_acceptable_camera(camera) and MotionEyeClient.is_camera_streaming(
|
||||
camera
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._available
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
available = False
|
||||
if self.coordinator.last_update_success:
|
||||
camera = get_camera_from_cameras(self._camera_id, self.coordinator.data)
|
||||
if MotionEyeMjpegCamera._is_acceptable_streaming_camera(camera):
|
||||
assert camera
|
||||
self._set_mjpeg_camera_state_for_camera(camera)
|
||||
self._motion_detection_enabled = camera.get(KEY_MOTION_DETECTION, False)
|
||||
available = True
|
||||
self._available = available
|
||||
CoordinatorEntity._handle_coordinator_update(self)
|
||||
|
||||
@property
|
||||
def brand(self) -> str:
|
||||
"""Return the camera brand."""
|
||||
return MOTIONEYE_MANUFACTURER
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self) -> bool:
|
||||
"""Return the camera motion detection status."""
|
||||
return self._motion_detection_enabled
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict[str, Any]:
|
||||
"""Return the device information."""
|
||||
return {"identifiers": {self._device_identifier}}
|
127
homeassistant/components/motioneye/config_flow.py
Normal file
127
homeassistant/components/motioneye/config_flow.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
"""Config flow for motionEye integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from motioneye_client.client import (
|
||||
MotionEyeClientConnectionError,
|
||||
MotionEyeClientInvalidAuthError,
|
||||
MotionEyeClientRequestError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
CONN_CLASS_LOCAL_POLL,
|
||||
SOURCE_REAUTH,
|
||||
ConfigFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_SOURCE, CONF_URL
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import create_motioneye_client
|
||||
from .const import ( # pylint:disable=unused-import
|
||||
CONF_ADMIN_PASSWORD,
|
||||
CONF_ADMIN_USERNAME,
|
||||
CONF_CONFIG_ENTRY,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for motionEye."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: ConfigType | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Handle the initial step."""
|
||||
out: dict[str, Any] = {}
|
||||
errors = {}
|
||||
if user_input is None:
|
||||
entry = self.context.get(CONF_CONFIG_ENTRY)
|
||||
user_input = entry.data if entry else {}
|
||||
else:
|
||||
try:
|
||||
# Cannot use cv.url validation in the schema itself, so
|
||||
# apply extra validation here.
|
||||
cv.url(user_input[CONF_URL])
|
||||
except vol.Invalid:
|
||||
errors["base"] = "invalid_url"
|
||||
else:
|
||||
client = create_motioneye_client(
|
||||
user_input[CONF_URL],
|
||||
admin_username=user_input.get(CONF_ADMIN_USERNAME),
|
||||
admin_password=user_input.get(CONF_ADMIN_PASSWORD),
|
||||
surveillance_username=user_input.get(CONF_SURVEILLANCE_USERNAME),
|
||||
surveillance_password=user_input.get(CONF_SURVEILLANCE_PASSWORD),
|
||||
)
|
||||
|
||||
try:
|
||||
await client.async_client_login()
|
||||
except MotionEyeClientConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except MotionEyeClientInvalidAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except MotionEyeClientRequestError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
entry = self.context.get(CONF_CONFIG_ENTRY)
|
||||
if (
|
||||
self.context.get(CONF_SOURCE) == SOURCE_REAUTH
|
||||
and entry is not None
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry, data=user_input
|
||||
)
|
||||
# Need to manually reload, as the listener won't have been
|
||||
# installed because the initial load did not succeed (the reauth
|
||||
# flow will not be initiated if the load succeeds).
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
out = self.async_abort(reason="reauth_successful")
|
||||
return out
|
||||
|
||||
out = self.async_create_entry(
|
||||
title=f"{user_input[CONF_URL]}",
|
||||
data=user_input,
|
||||
)
|
||||
return out
|
||||
|
||||
out = self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL, default=user_input.get(CONF_URL, "")): str,
|
||||
vol.Optional(
|
||||
CONF_ADMIN_USERNAME, default=user_input.get(CONF_ADMIN_USERNAME)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_ADMIN_PASSWORD, default=user_input.get(CONF_ADMIN_PASSWORD)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
default=user_input.get(CONF_SURVEILLANCE_USERNAME),
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
default=user_input.get(CONF_SURVEILLANCE_PASSWORD),
|
||||
): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
return out
|
||||
|
||||
async def async_step_reauth(
|
||||
self,
|
||||
config_data: ConfigType | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Handle a reauthentication flow."""
|
||||
return await self.async_step_user(config_data)
|
20
homeassistant/components/motioneye/const.py
Normal file
20
homeassistant/components/motioneye/const.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
"""Constants for the motionEye integration."""
|
||||
from datetime import timedelta
|
||||
|
||||
DOMAIN = "motioneye"
|
||||
|
||||
CONF_CONFIG_ENTRY = "config_entry"
|
||||
CONF_CLIENT = "client"
|
||||
CONF_COORDINATOR = "coordinator"
|
||||
CONF_ADMIN_PASSWORD = "admin_password"
|
||||
CONF_ADMIN_USERNAME = "admin_username"
|
||||
CONF_SURVEILLANCE_USERNAME = "surveillance_username"
|
||||
CONF_SURVEILLANCE_PASSWORD = "surveillance_password"
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
MOTIONEYE_MANUFACTURER = "motionEye"
|
||||
|
||||
SIGNAL_CAMERA_ADD = f"{DOMAIN}_camera_add_signal." "{}"
|
||||
SIGNAL_CAMERA_REMOVE = f"{DOMAIN}_camera_remove_signal." "{}"
|
||||
|
||||
TYPE_MOTIONEYE_MJPEG_CAMERA = "motioneye_mjpeg_camera"
|
13
homeassistant/components/motioneye/manifest.json
Normal file
13
homeassistant/components/motioneye/manifest.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"domain": "motioneye",
|
||||
"name": "motionEye",
|
||||
"documentation": "https://www.home-assistant.io/integrations/motioneye",
|
||||
"config_flow": true,
|
||||
"requirements": [
|
||||
"motioneye-client==0.3.2"
|
||||
],
|
||||
"codeowners": [
|
||||
"@dermotduffy"
|
||||
],
|
||||
"iot_class": "local_polling"
|
||||
}
|
25
homeassistant/components/motioneye/strings.json
Normal file
25
homeassistant/components/motioneye/strings.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"admin_username": "Admin [%key:common::config_flow::data::username%]",
|
||||
"admin_password": "Admin [%key:common::config_flow::data::password%]",
|
||||
"surveillance_username": "Surveillance [%key:common::config_flow::data::username%]",
|
||||
"surveillance_password": "Surveillance [%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"invalid_url": "Invalid URL"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -151,6 +151,7 @@ FLOWS = [
|
|||
"mobile_app",
|
||||
"monoprice",
|
||||
"motion_blinds",
|
||||
"motioneye",
|
||||
"mqtt",
|
||||
"mullvad",
|
||||
"myq",
|
||||
|
|
|
@ -953,6 +953,9 @@ mitemp_bt==0.0.3
|
|||
# homeassistant.components.motion_blinds
|
||||
motionblinds==0.4.10
|
||||
|
||||
# homeassistant.components.motioneye
|
||||
motioneye-client==0.3.2
|
||||
|
||||
# homeassistant.components.mullvad
|
||||
mullvad-api==1.0.0
|
||||
|
||||
|
|
|
@ -513,6 +513,9 @@ minio==4.0.9
|
|||
# homeassistant.components.motion_blinds
|
||||
motionblinds==0.4.10
|
||||
|
||||
# homeassistant.components.motioneye
|
||||
motioneye-client==0.3.2
|
||||
|
||||
# homeassistant.components.mullvad
|
||||
mullvad-api==1.0.0
|
||||
|
||||
|
|
180
tests/components/motioneye/__init__.py
Normal file
180
tests/components/motioneye/__init__.py
Normal file
|
@ -0,0 +1,180 @@
|
|||
"""Tests for the motionEye integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from motioneye_client.const import DEFAULT_PORT
|
||||
|
||||
from homeassistant.components.motioneye.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_CONFIG_ENTRY_ID = "74565ad414754616000674c87bdc876c"
|
||||
TEST_URL = f"http://test:{DEFAULT_PORT+1}"
|
||||
TEST_CAMERA_ID = 100
|
||||
TEST_CAMERA_NAME = "Test Camera"
|
||||
TEST_CAMERA_ENTITY_ID = "camera.test_camera"
|
||||
TEST_CAMERA_DEVICE_IDENTIFIER = (DOMAIN, TEST_CONFIG_ENTRY_ID, TEST_CAMERA_ID)
|
||||
TEST_CAMERA = {
|
||||
"show_frame_changes": False,
|
||||
"framerate": 25,
|
||||
"actions": [],
|
||||
"preserve_movies": 0,
|
||||
"auto_threshold_tuning": True,
|
||||
"recording_mode": "motion-triggered",
|
||||
"monday_to": "",
|
||||
"streaming_resolution": 100,
|
||||
"light_switch_detect": 0,
|
||||
"command_end_notifications_enabled": False,
|
||||
"smb_shares": False,
|
||||
"upload_server": "",
|
||||
"monday_from": "",
|
||||
"movie_passthrough": False,
|
||||
"auto_brightness": False,
|
||||
"frame_change_threshold": 3.0,
|
||||
"name": TEST_CAMERA_NAME,
|
||||
"movie_format": "mp4:h264_omx",
|
||||
"network_username": "",
|
||||
"preserve_pictures": 0,
|
||||
"event_gap": 30,
|
||||
"enabled": True,
|
||||
"upload_movie": True,
|
||||
"video_streaming": True,
|
||||
"upload_location": "",
|
||||
"max_movie_length": 0,
|
||||
"movie_file_name": "%Y-%m-%d/%H-%M-%S",
|
||||
"upload_authorization_key": "",
|
||||
"still_images": False,
|
||||
"upload_method": "post",
|
||||
"max_frame_change_threshold": 0,
|
||||
"device_url": "rtsp://localhost/live",
|
||||
"text_overlay": False,
|
||||
"right_text": "timestamp",
|
||||
"upload_picture": True,
|
||||
"email_notifications_enabled": False,
|
||||
"working_schedule_type": "during",
|
||||
"movie_quality": 75,
|
||||
"disk_total": 44527655808,
|
||||
"upload_service": "ftp",
|
||||
"upload_password": "",
|
||||
"wednesday_to": "",
|
||||
"mask_type": "smart",
|
||||
"command_storage_enabled": False,
|
||||
"disk_used": 11419704992,
|
||||
"streaming_motion": 0,
|
||||
"manual_snapshots": True,
|
||||
"noise_level": 12,
|
||||
"mask_lines": [],
|
||||
"upload_enabled": False,
|
||||
"root_directory": f"/var/lib/motioneye/{TEST_CAMERA_NAME}",
|
||||
"clean_cloud_enabled": False,
|
||||
"working_schedule": False,
|
||||
"pre_capture": 1,
|
||||
"command_notifications_enabled": False,
|
||||
"streaming_framerate": 25,
|
||||
"email_notifications_picture_time_span": 0,
|
||||
"thursday_to": "",
|
||||
"streaming_server_resize": False,
|
||||
"upload_subfolders": True,
|
||||
"sunday_to": "",
|
||||
"left_text": "",
|
||||
"image_file_name": "%Y-%m-%d/%H-%M-%S",
|
||||
"rotation": 0,
|
||||
"capture_mode": "manual",
|
||||
"movies": False,
|
||||
"motion_detection": True,
|
||||
"text_scale": 1,
|
||||
"upload_username": "",
|
||||
"upload_port": "",
|
||||
"available_disks": [],
|
||||
"network_smb_ver": "1.0",
|
||||
"streaming_auth_mode": "basic",
|
||||
"despeckle_filter": "",
|
||||
"snapshot_interval": 0,
|
||||
"minimum_motion_frames": 20,
|
||||
"auto_noise_detect": True,
|
||||
"network_share_name": "",
|
||||
"sunday_from": "",
|
||||
"friday_from": "",
|
||||
"web_hook_storage_enabled": False,
|
||||
"custom_left_text": "",
|
||||
"streaming_port": 8081,
|
||||
"id": TEST_CAMERA_ID,
|
||||
"post_capture": 1,
|
||||
"streaming_quality": 75,
|
||||
"wednesday_from": "",
|
||||
"proto": "netcam",
|
||||
"extra_options": [],
|
||||
"image_quality": 85,
|
||||
"create_debug_media": False,
|
||||
"friday_to": "",
|
||||
"custom_right_text": "",
|
||||
"web_hook_notifications_enabled": False,
|
||||
"saturday_from": "",
|
||||
"available_resolutions": [
|
||||
"1600x1200",
|
||||
"1920x1080",
|
||||
],
|
||||
"tuesday_from": "",
|
||||
"network_password": "",
|
||||
"saturday_to": "",
|
||||
"network_server": "",
|
||||
"smart_mask_sluggishness": 5,
|
||||
"mask": False,
|
||||
"tuesday_to": "",
|
||||
"thursday_from": "",
|
||||
"storage_device": "custom-path",
|
||||
"resolution": "1920x1080",
|
||||
}
|
||||
TEST_CAMERAS = {"cameras": [TEST_CAMERA]}
|
||||
TEST_SURVEILLANCE_USERNAME = "surveillance_username"
|
||||
|
||||
|
||||
def create_mock_motioneye_client() -> AsyncMock:
|
||||
"""Create mock motionEye client."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.async_client_login = AsyncMock(return_value={})
|
||||
mock_client.async_get_cameras = AsyncMock(return_value=TEST_CAMERAS)
|
||||
mock_client.async_client_close = AsyncMock(return_value=True)
|
||||
mock_client.get_camera_snapshot_url = Mock(return_value="")
|
||||
mock_client.get_camera_stream_url = Mock(return_value="")
|
||||
return mock_client
|
||||
|
||||
|
||||
def create_mock_motioneye_config_entry(
|
||||
hass: HomeAssistant,
|
||||
data: dict[str, Any] | None = None,
|
||||
options: dict[str, Any] | None = None,
|
||||
) -> ConfigEntry:
|
||||
"""Add a test config entry."""
|
||||
config_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call]
|
||||
entry_id=TEST_CONFIG_ENTRY_ID,
|
||||
domain=DOMAIN,
|
||||
data=data or {CONF_URL: TEST_URL},
|
||||
title=f"{TEST_URL}",
|
||||
options=options or {},
|
||||
)
|
||||
config_entry.add_to_hass(hass) # type: ignore[no-untyped-call]
|
||||
return config_entry
|
||||
|
||||
|
||||
async def setup_mock_motioneye_config_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry | None = None,
|
||||
client: Mock | None = None,
|
||||
) -> ConfigEntry:
|
||||
"""Add a mock MotionEye config entry to hass."""
|
||||
config_entry = config_entry or create_mock_motioneye_config_entry(hass)
|
||||
client = client or create_mock_motioneye_client()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=client,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return config_entry
|
315
tests/components/motioneye/test_camera.py
Normal file
315
tests/components/motioneye/test_camera.py
Normal file
|
@ -0,0 +1,315 @@
|
|||
"""Test the motionEye camera."""
|
||||
import copy
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from aiohttp import web # type: ignore
|
||||
from aiohttp.web_exceptions import HTTPBadGateway
|
||||
from motioneye_client.client import (
|
||||
MotionEyeClientError,
|
||||
MotionEyeClientInvalidAuthError,
|
||||
)
|
||||
from motioneye_client.const import (
|
||||
KEY_CAMERAS,
|
||||
KEY_MOTION_DETECTION,
|
||||
KEY_NAME,
|
||||
KEY_VIDEO_STREAMING,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.camera import async_get_image, async_get_mjpeg_stream
|
||||
from homeassistant.components.motioneye import get_motioneye_device_identifier
|
||||
from homeassistant.components.motioneye.const import (
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
MOTIONEYE_MANUFACTURER,
|
||||
)
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import async_get_registry
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import (
|
||||
TEST_CAMERA_DEVICE_IDENTIFIER,
|
||||
TEST_CAMERA_ENTITY_ID,
|
||||
TEST_CAMERA_ID,
|
||||
TEST_CAMERA_NAME,
|
||||
TEST_CAMERAS,
|
||||
TEST_CONFIG_ENTRY_ID,
|
||||
TEST_SURVEILLANCE_USERNAME,
|
||||
create_mock_motioneye_client,
|
||||
create_mock_motioneye_config_entry,
|
||||
setup_mock_motioneye_config_entry,
|
||||
)
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def test_setup_camera(hass: HomeAssistant) -> None:
|
||||
"""Test a basic camera."""
|
||||
client = create_mock_motioneye_client()
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state
|
||||
assert entity_state.state == "idle"
|
||||
assert entity_state.attributes.get("friendly_name") == TEST_CAMERA_NAME
|
||||
|
||||
|
||||
async def test_setup_camera_auth_fail(hass: HomeAssistant) -> None:
|
||||
"""Test a successful camera."""
|
||||
client = create_mock_motioneye_client()
|
||||
client.async_client_login = AsyncMock(side_effect=MotionEyeClientInvalidAuthError)
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
assert not hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
|
||||
|
||||
async def test_setup_camera_client_error(hass: HomeAssistant) -> None:
|
||||
"""Test a successful camera."""
|
||||
client = create_mock_motioneye_client()
|
||||
client.async_client_login = AsyncMock(side_effect=MotionEyeClientError)
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
assert not hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
|
||||
|
||||
async def test_setup_camera_empty_data(hass: HomeAssistant) -> None:
|
||||
"""Test a successful camera."""
|
||||
client = create_mock_motioneye_client()
|
||||
client.async_get_cameras = AsyncMock(return_value={})
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
assert not hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
|
||||
|
||||
async def test_setup_camera_bad_data(hass: HomeAssistant) -> None:
|
||||
"""Test bad camera data."""
|
||||
client = create_mock_motioneye_client()
|
||||
cameras = copy.deepcopy(TEST_CAMERAS)
|
||||
del cameras[KEY_CAMERAS][0][KEY_NAME]
|
||||
|
||||
client.async_get_cameras = AsyncMock(return_value=cameras)
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
assert not hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
|
||||
|
||||
async def test_setup_camera_without_streaming(hass: HomeAssistant) -> None:
|
||||
"""Test a camera without streaming enabled."""
|
||||
client = create_mock_motioneye_client()
|
||||
cameras = copy.deepcopy(TEST_CAMERAS)
|
||||
cameras[KEY_CAMERAS][0][KEY_VIDEO_STREAMING] = False
|
||||
|
||||
client.async_get_cameras = AsyncMock(return_value=cameras)
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state
|
||||
assert entity_state.state == "unavailable"
|
||||
|
||||
|
||||
async def test_setup_camera_new_data_same(hass: HomeAssistant) -> None:
|
||||
"""Test a data refresh with the same data."""
|
||||
client = create_mock_motioneye_client()
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
|
||||
|
||||
async def test_setup_camera_new_data_camera_removed(hass: HomeAssistant) -> None:
|
||||
"""Test a data refresh with a removed camera."""
|
||||
device_registry = await async_get_registry(hass)
|
||||
entity_registry = await er.async_get_registry(hass)
|
||||
|
||||
client = create_mock_motioneye_client()
|
||||
config_entry = await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
|
||||
# Create some random old devices/entity_ids and ensure they get cleaned up.
|
||||
old_device_id = "old-device-id"
|
||||
old_entity_unique_id = "old-entity-unique_id"
|
||||
old_device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, old_device_id)}
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
domain=DOMAIN,
|
||||
platform="camera",
|
||||
unique_id=old_entity_unique_id,
|
||||
config_entry=config_entry,
|
||||
device_id=old_device.id,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert device_registry.async_get_device({TEST_CAMERA_DEVICE_IDENTIFIER})
|
||||
|
||||
client.async_get_cameras = AsyncMock(return_value={KEY_CAMERAS: []})
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
await hass.async_block_till_done()
|
||||
assert not hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert not device_registry.async_get_device({TEST_CAMERA_DEVICE_IDENTIFIER})
|
||||
assert not device_registry.async_get_device({(DOMAIN, old_device_id)})
|
||||
assert not entity_registry.async_get_entity_id(
|
||||
DOMAIN, "camera", old_entity_unique_id
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_camera_new_data_error(hass: HomeAssistant) -> None:
|
||||
"""Test a data refresh that fails."""
|
||||
client = create_mock_motioneye_client()
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
assert hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
client.async_get_cameras = AsyncMock(side_effect=MotionEyeClientError)
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
await hass.async_block_till_done()
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state.state == "unavailable"
|
||||
|
||||
|
||||
async def test_setup_camera_new_data_without_streaming(hass: HomeAssistant) -> None:
|
||||
"""Test a data refresh without streaming."""
|
||||
client = create_mock_motioneye_client()
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state.state == "idle"
|
||||
|
||||
cameras = copy.deepcopy(TEST_CAMERAS)
|
||||
cameras[KEY_CAMERAS][0][KEY_VIDEO_STREAMING] = False
|
||||
client.async_get_cameras = AsyncMock(return_value=cameras)
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
await hass.async_block_till_done()
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state.state == "unavailable"
|
||||
|
||||
|
||||
async def test_unload_camera(hass: HomeAssistant) -> None:
|
||||
"""Test unloading camera."""
|
||||
client = create_mock_motioneye_client()
|
||||
entry = await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
assert hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert not client.async_client_close.called
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
assert client.async_client_close.called
|
||||
|
||||
|
||||
async def test_get_still_image_from_camera(
|
||||
aiohttp_server: Any, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Test getting a still image."""
|
||||
|
||||
image_handler = Mock(return_value="")
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes(
|
||||
[
|
||||
web.get(
|
||||
"/foo",
|
||||
image_handler,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
server = await aiohttp_server(app)
|
||||
client = create_mock_motioneye_client()
|
||||
client.get_camera_snapshot_url = Mock(
|
||||
return_value=f"http://localhost:{server.port}/foo"
|
||||
)
|
||||
config_entry = create_mock_motioneye_config_entry(
|
||||
hass,
|
||||
data={
|
||||
CONF_URL: f"http://localhost:{server.port}",
|
||||
CONF_SURVEILLANCE_USERNAME: TEST_SURVEILLANCE_USERNAME,
|
||||
},
|
||||
)
|
||||
|
||||
await setup_mock_motioneye_config_entry(
|
||||
hass, config_entry=config_entry, client=client
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# It won't actually get a stream from the dummy handler, so just catch
|
||||
# the expected exception, then verify the right handler was called.
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await async_get_image(hass, TEST_CAMERA_ENTITY_ID, timeout=None) # type: ignore[no-untyped-call]
|
||||
assert image_handler.called
|
||||
|
||||
|
||||
async def test_get_stream_from_camera(aiohttp_server: Any, hass: HomeAssistant) -> None:
|
||||
"""Test getting a stream."""
|
||||
|
||||
stream_handler = Mock(return_value="")
|
||||
app = web.Application()
|
||||
app.add_routes([web.get("/", stream_handler)])
|
||||
stream_server = await aiohttp_server(app)
|
||||
|
||||
client = create_mock_motioneye_client()
|
||||
client.get_camera_stream_url = Mock(
|
||||
return_value=f"http://localhost:{stream_server.port}/"
|
||||
)
|
||||
config_entry = create_mock_motioneye_config_entry(
|
||||
hass,
|
||||
data={
|
||||
CONF_URL: f"http://localhost:{stream_server.port}",
|
||||
# The port won't be used as the client is a mock.
|
||||
CONF_SURVEILLANCE_USERNAME: TEST_SURVEILLANCE_USERNAME,
|
||||
},
|
||||
)
|
||||
cameras = copy.deepcopy(TEST_CAMERAS)
|
||||
client.async_get_cameras = AsyncMock(return_value=cameras)
|
||||
await setup_mock_motioneye_config_entry(
|
||||
hass, config_entry=config_entry, client=client
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# It won't actually get a stream from the dummy handler, so just catch
|
||||
# the expected exception, then verify the right handler was called.
|
||||
with pytest.raises(HTTPBadGateway):
|
||||
await async_get_mjpeg_stream(hass, None, TEST_CAMERA_ENTITY_ID) # type: ignore[no-untyped-call]
|
||||
assert stream_handler.called
|
||||
|
||||
|
||||
async def test_state_attributes(hass: HomeAssistant) -> None:
|
||||
"""Test state attributes are set correctly."""
|
||||
client = create_mock_motioneye_client()
|
||||
await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state
|
||||
assert entity_state.attributes.get("brand") == MOTIONEYE_MANUFACTURER
|
||||
assert entity_state.attributes.get("motion_detection")
|
||||
|
||||
cameras = copy.deepcopy(TEST_CAMERAS)
|
||||
cameras[KEY_CAMERAS][0][KEY_MOTION_DETECTION] = False
|
||||
client.async_get_cameras = AsyncMock(return_value=cameras)
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_state = hass.states.get(TEST_CAMERA_ENTITY_ID)
|
||||
assert entity_state
|
||||
assert not entity_state.attributes.get("motion_detection")
|
||||
|
||||
|
||||
async def test_device_info(hass: HomeAssistant) -> None:
|
||||
"""Verify device information includes expected details."""
|
||||
client = create_mock_motioneye_client()
|
||||
entry = await setup_mock_motioneye_config_entry(hass, client=client)
|
||||
|
||||
device_identifier = get_motioneye_device_identifier(entry.entry_id, TEST_CAMERA_ID)
|
||||
device_registry = dr.async_get(hass)
|
||||
|
||||
device = device_registry.async_get_device({device_identifier})
|
||||
assert device
|
||||
assert device.config_entries == {TEST_CONFIG_ENTRY_ID}
|
||||
assert device.identifiers == {device_identifier}
|
||||
assert device.manufacturer == MOTIONEYE_MANUFACTURER
|
||||
assert device.model == MOTIONEYE_MANUFACTURER
|
||||
assert device.name == TEST_CAMERA_NAME
|
||||
|
||||
entity_registry = await er.async_get_registry(hass)
|
||||
entities_from_device = [
|
||||
entry.entity_id
|
||||
for entry in er.async_entries_for_device(entity_registry, device.id)
|
||||
]
|
||||
assert TEST_CAMERA_ENTITY_ID in entities_from_device
|
233
tests/components/motioneye/test_config_flow.py
Normal file
233
tests/components/motioneye/test_config_flow.py
Normal file
|
@ -0,0 +1,233 @@
|
|||
"""Test the motionEye config flow."""
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from motioneye_client.client import (
|
||||
MotionEyeClientConnectionError,
|
||||
MotionEyeClientInvalidAuthError,
|
||||
MotionEyeClientRequestError,
|
||||
)
|
||||
|
||||
from homeassistant import config_entries, data_entry_flow, setup
|
||||
from homeassistant.components.motioneye.const import (
|
||||
CONF_ADMIN_PASSWORD,
|
||||
CONF_ADMIN_USERNAME,
|
||||
CONF_CONFIG_ENTRY,
|
||||
CONF_SURVEILLANCE_PASSWORD,
|
||||
CONF_SURVEILLANCE_USERNAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import TEST_URL, create_mock_motioneye_client, create_mock_motioneye_config_entry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def test_user_success(hass: HomeAssistant) -> None:
|
||||
"""Test successful 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"] == {}
|
||||
|
||||
mock_client = create_mock_motioneye_client()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=mock_client,
|
||||
), patch(
|
||||
"homeassistant.components.motioneye.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == "create_entry"
|
||||
assert result["title"] == f"{TEST_URL}"
|
||||
assert result["data"] == {
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_user_invalid_auth(hass: HomeAssistant) -> None:
|
||||
"""Test invalid auth is handled correctly."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_client = create_mock_motioneye_client()
|
||||
mock_client.async_client_login = AsyncMock(
|
||||
side_effect=MotionEyeClientInvalidAuthError
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
},
|
||||
)
|
||||
await mock_client.async_client_close()
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_user_invalid_url(hass: HomeAssistant) -> None:
|
||||
"""Test invalid url is handled correctly."""
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=create_mock_motioneye_client(),
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: "not a url",
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "invalid_url"}
|
||||
|
||||
|
||||
async def test_user_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test connection failure is handled correctly."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_client = create_mock_motioneye_client()
|
||||
mock_client.async_client_login = AsyncMock(
|
||||
side_effect=MotionEyeClientConnectionError,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
},
|
||||
)
|
||||
await mock_client.async_client_close()
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_user_request_error(hass: HomeAssistant) -> None:
|
||||
"""Test a request error is handled correctly."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
mock_client = create_mock_motioneye_client()
|
||||
mock_client.async_client_login = AsyncMock(side_effect=MotionEyeClientRequestError)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=mock_client,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
},
|
||||
)
|
||||
await mock_client.async_client_close()
|
||||
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {"base": "unknown"}
|
||||
|
||||
|
||||
async def test_reauth(hass: HomeAssistant) -> None:
|
||||
"""Test a reauth."""
|
||||
config_data = {
|
||||
CONF_URL: TEST_URL,
|
||||
}
|
||||
|
||||
config_entry = create_mock_motioneye_config_entry(hass, data=config_data)
|
||||
|
||||
await setup.async_setup_component(hass, "persistent_notification", {})
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
CONF_CONFIG_ENTRY: config_entry,
|
||||
},
|
||||
)
|
||||
assert result["type"] == "form"
|
||||
assert result["errors"] == {}
|
||||
|
||||
mock_client = create_mock_motioneye_client()
|
||||
|
||||
new_data = {
|
||||
CONF_URL: TEST_URL,
|
||||
CONF_ADMIN_USERNAME: "admin-username",
|
||||
CONF_ADMIN_PASSWORD: "admin-password",
|
||||
CONF_SURVEILLANCE_USERNAME: "surveillance-username",
|
||||
CONF_SURVEILLANCE_PASSWORD: "surveillance-password",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.motioneye.MotionEyeClient",
|
||||
return_value=mock_client,
|
||||
), patch(
|
||||
"homeassistant.components.motioneye.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
new_data,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
assert config_entry.data == new_data
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
Loading…
Add table
Add a link
Reference in a new issue