Add unifiprotect integration (#62697)
Co-authored-by: J. Nick Koston <nick@koston.org>pull/62811/head
parent
c54439ef42
commit
e982e7403a
homeassistant
components/unifiprotect
generated
tests/components/unifiprotect
|
@ -143,6 +143,7 @@ homeassistant.components.tractive.*
|
|||
homeassistant.components.tradfri.*
|
||||
homeassistant.components.tts.*
|
||||
homeassistant.components.twentemilieu.*
|
||||
homeassistant.components.unifiprotect.*
|
||||
homeassistant.components.upcloud.*
|
||||
homeassistant.components.uptime.*
|
||||
homeassistant.components.uptimerobot.*
|
||||
|
|
|
@ -966,6 +966,8 @@ homeassistant/components/ubus/* @noltari
|
|||
homeassistant/components/unifi/* @Kane610
|
||||
tests/components/unifi/* @Kane610
|
||||
homeassistant/components/unifiled/* @florisvdk
|
||||
homeassistant/components/unifiprotect/* @briis @AngellusMortis @bdraco
|
||||
tests/components/unifiprotect/* @briis @AngellusMortis @bdraco
|
||||
homeassistant/components/upb/* @gwww
|
||||
tests/components/upb/* @gwww
|
||||
homeassistant/components/upc_connect/* @pvizeli @fabaff
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
"""UniFi Protect Platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from aiohttp.client_exceptions import ServerDisconnectedError
|
||||
from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ALL_UPDATES,
|
||||
CONF_OVERRIDE_CHOST,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEVICES_FOR_SUBSCRIBE,
|
||||
DOMAIN,
|
||||
MIN_REQUIRED_PROTECT_V,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .data import ProtectData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up the UniFi Protect config entries."""
|
||||
|
||||
session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True))
|
||||
protect = ProtectApiClient(
|
||||
host=entry.data[CONF_HOST],
|
||||
port=entry.data[CONF_PORT],
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
verify_ssl=entry.data[CONF_VERIFY_SSL],
|
||||
session=session,
|
||||
subscribed_models=DEVICES_FOR_SUBSCRIBE,
|
||||
override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False),
|
||||
ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False),
|
||||
)
|
||||
_LOGGER.debug("Connect to UniFi Protect")
|
||||
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
|
||||
|
||||
try:
|
||||
nvr_info = await protect.get_nvr()
|
||||
except NotAuthorized as err:
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
except (asyncio.TimeoutError, NvrError, ServerDisconnectedError) as notreadyerror:
|
||||
raise ConfigEntryNotReady from notreadyerror
|
||||
|
||||
if nvr_info.version < MIN_REQUIRED_PROTECT_V:
|
||||
_LOGGER.error(
|
||||
(
|
||||
"You are running v%s of UniFi Protect. Minimum required version is v%s. "
|
||||
"Please upgrade UniFi Protect and then retry"
|
||||
),
|
||||
nvr_info.version,
|
||||
MIN_REQUIRED_PROTECT_V,
|
||||
)
|
||||
return False
|
||||
|
||||
if entry.unique_id is None:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac)
|
||||
|
||||
await data_service.async_setup()
|
||||
if not data_service.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_service
|
||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload UniFi Protect config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
|
||||
await data.async_stop()
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
|
@ -0,0 +1,169 @@
|
|||
"""Support for Ubiquiti's UniFi Protect NVR."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Generator, Sequence
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyunifiprotect.api import ProtectApiClient
|
||||
from pyunifiprotect.data import Camera as UFPCamera
|
||||
from pyunifiprotect.data.devices import CameraChannel
|
||||
|
||||
from homeassistant.components.camera import SUPPORT_STREAM, Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import (
|
||||
ATTR_BITRATE,
|
||||
ATTR_CHANNEL_ID,
|
||||
ATTR_FPS,
|
||||
ATTR_HEIGHT,
|
||||
ATTR_WIDTH,
|
||||
DOMAIN,
|
||||
)
|
||||
from .data import ProtectData
|
||||
from .entity import ProtectDeviceEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_camera_channels(
|
||||
protect: ProtectApiClient,
|
||||
) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]:
|
||||
"""Get all the camera channels."""
|
||||
for camera in protect.bootstrap.cameras.values():
|
||||
if len(camera.channels) == 0:
|
||||
_LOGGER.warning(
|
||||
"Camera does not have any channels: %s (id: %s)", camera.name, camera.id
|
||||
)
|
||||
continue
|
||||
|
||||
is_default = True
|
||||
for channel in camera.channels:
|
||||
if channel.is_rtsp_enabled:
|
||||
yield camera, channel, is_default
|
||||
is_default = False
|
||||
|
||||
# no RTSP enabled use first channel with no stream
|
||||
if is_default:
|
||||
yield camera, camera.channels[0], True
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: Callable[[Sequence[Entity]], None],
|
||||
) -> None:
|
||||
"""Discover cameras on a UniFi Protect NVR."""
|
||||
data: ProtectData = hass.data[DOMAIN][entry.entry_id]
|
||||
disable_stream = data.disable_stream
|
||||
|
||||
entities = []
|
||||
for camera, channel, is_default in get_camera_channels(data.api):
|
||||
entities.append(
|
||||
ProtectCamera(
|
||||
data,
|
||||
camera,
|
||||
channel,
|
||||
is_default,
|
||||
True,
|
||||
disable_stream,
|
||||
)
|
||||
)
|
||||
|
||||
if channel.is_rtsp_enabled:
|
||||
entities.append(
|
||||
ProtectCamera(
|
||||
data,
|
||||
camera,
|
||||
channel,
|
||||
is_default,
|
||||
False,
|
||||
disable_stream,
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ProtectCamera(ProtectDeviceEntity, Camera):
|
||||
"""A Ubiquiti UniFi Protect Camera."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: ProtectData,
|
||||
camera: UFPCamera,
|
||||
channel: CameraChannel,
|
||||
is_default: bool,
|
||||
secure: bool,
|
||||
disable_stream: bool,
|
||||
) -> None:
|
||||
"""Initialize an UniFi camera."""
|
||||
self.device: UFPCamera = camera
|
||||
self.channel = channel
|
||||
self._secure = secure
|
||||
self._disable_stream = disable_stream
|
||||
self._last_image: bytes | None = None
|
||||
super().__init__(data)
|
||||
|
||||
if self._secure:
|
||||
self._attr_unique_id = f"{self.device.id}_{self.channel.id}"
|
||||
self._attr_name = f"{self.device.name} {self.channel.name}"
|
||||
else:
|
||||
self._attr_unique_id = f"{self.device.id}_{self.channel.id}_insecure"
|
||||
self._attr_name = f"{self.device.name} {self.channel.name} Insecure"
|
||||
# only the default (first) channel is enabled by default
|
||||
self._attr_entity_registry_enabled_default = is_default and secure
|
||||
|
||||
@callback
|
||||
def _async_set_stream_source(self) -> None:
|
||||
disable_stream = self._disable_stream
|
||||
if not self.channel.is_rtsp_enabled:
|
||||
disable_stream = False
|
||||
|
||||
rtsp_url = self.channel.rtsp_url
|
||||
if self._secure:
|
||||
rtsp_url = self.channel.rtsps_url
|
||||
|
||||
# _async_set_stream_source called by __init__
|
||||
self._stream_source = ( # pylint: disable=attribute-defined-outside-init
|
||||
None if disable_stream else rtsp_url
|
||||
)
|
||||
self._attr_supported_features: int = (
|
||||
SUPPORT_STREAM if self._stream_source else 0
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_device_from_protect(self) -> None:
|
||||
super()._async_update_device_from_protect()
|
||||
self.channel = self.device.channels[self.channel.id]
|
||||
self._attr_motion_detection_enabled = (
|
||||
self.device.is_connected and self.device.feature_flags.has_motion_zones
|
||||
)
|
||||
self._attr_is_recording = self.device.is_connected and self.device.is_recording
|
||||
|
||||
self._async_set_stream_source()
|
||||
|
||||
@callback
|
||||
def _async_update_extra_attrs_from_protect(self) -> dict[str, Any]:
|
||||
"""Add additional Attributes to Camera."""
|
||||
return {
|
||||
**super()._async_update_extra_attrs_from_protect(),
|
||||
ATTR_WIDTH: self.channel.width,
|
||||
ATTR_HEIGHT: self.channel.height,
|
||||
ATTR_FPS: self.channel.fps,
|
||||
ATTR_BITRATE: self.channel.bitrate,
|
||||
ATTR_CHANNEL_ID: self.channel.id,
|
||||
}
|
||||
|
||||
async def async_camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return the Camera Image."""
|
||||
last_image = await self.device.get_snapshot(width, height)
|
||||
self._last_image = last_image
|
||||
return self._last_image
|
||||
|
||||
async def stream_source(self) -> str | None:
|
||||
"""Return the Stream Source."""
|
||||
return self._stream_source
|
|
@ -0,0 +1,218 @@
|
|||
"""Config Flow to configure UniFi Protect Integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient
|
||||
from pyunifiprotect.data.nvr import NVR
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
CONF_ID,
|
||||
CONF_PASSWORD,
|
||||
CONF_PORT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ALL_UPDATES,
|
||||
CONF_DISABLE_RTSP,
|
||||
CONF_OVERRIDE_CHOST,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DOMAIN,
|
||||
MIN_REQUIRED_PROTECT_V,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a UniFi Protect config flow."""
|
||||
|
||||
VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Init the config flow."""
|
||||
super().__init__()
|
||||
|
||||
self.entry: config_entries.ConfigEntry | None = None
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
) -> config_entries.OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return OptionsFlowHandler(config_entry)
|
||||
|
||||
@callback
|
||||
async def _async_create_entry(self, title: str, data: dict[str, Any]) -> FlowResult:
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={**data, CONF_ID: title},
|
||||
options={
|
||||
CONF_DISABLE_RTSP: False,
|
||||
CONF_ALL_UPDATES: False,
|
||||
CONF_OVERRIDE_CHOST: False,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
async def _async_get_nvr_data(
|
||||
self,
|
||||
user_input: dict[str, Any],
|
||||
) -> tuple[NVR | None, dict[str, str]]:
|
||||
session = async_create_clientsession(
|
||||
self.hass, cookie_jar=CookieJar(unsafe=True)
|
||||
)
|
||||
|
||||
host = user_input[CONF_HOST]
|
||||
port = user_input.get(CONF_PORT, DEFAULT_PORT)
|
||||
verify_ssl = user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL)
|
||||
|
||||
protect = ProtectApiClient(
|
||||
session=session,
|
||||
host=host,
|
||||
port=port,
|
||||
username=user_input[CONF_USERNAME],
|
||||
password=user_input[CONF_PASSWORD],
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
errors = {}
|
||||
nvr_data = None
|
||||
try:
|
||||
nvr_data = await protect.get_nvr()
|
||||
except NotAuthorized as ex:
|
||||
_LOGGER.debug(ex)
|
||||
errors[CONF_PASSWORD] = "invalid_auth"
|
||||
except NvrError as ex:
|
||||
_LOGGER.debug(ex)
|
||||
errors["base"] = "nvr_error"
|
||||
else:
|
||||
if nvr_data.version < MIN_REQUIRED_PROTECT_V:
|
||||
_LOGGER.debug("UniFi Protect Version not supported")
|
||||
errors["base"] = "protect_version"
|
||||
|
||||
return nvr_data, errors
|
||||
|
||||
async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult:
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
|
||||
self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"])
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm reauth."""
|
||||
errors: dict[str, str] = {}
|
||||
assert self.entry is not None
|
||||
|
||||
# prepopulate fields
|
||||
form_data = {**self.entry.data}
|
||||
if user_input is not None:
|
||||
form_data.update(user_input)
|
||||
|
||||
# validate login data
|
||||
_, errors = await self._async_get_nvr_data(form_data)
|
||||
if not errors:
|
||||
self.hass.config_entries.async_update_entry(self.entry, data=form_data)
|
||||
await self.hass.config_entries.async_reload(self.entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_USERNAME, default=form_data.get(CONF_USERNAME)
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
nvr_data, errors = await self._async_get_nvr_data(user_input)
|
||||
|
||||
if nvr_data and not errors:
|
||||
await self.async_set_unique_id(nvr_data.mac)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return await self._async_create_entry(nvr_data.name, user_input)
|
||||
|
||||
user_input = user_input or {}
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str,
|
||||
vol.Required(
|
||||
CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT)
|
||||
): int,
|
||||
vol.Required(
|
||||
CONF_VERIFY_SSL,
|
||||
default=user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
): bool,
|
||||
vol.Required(
|
||||
CONF_USERNAME, default=user_input.get(CONF_USERNAME)
|
||||
): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class OptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Handle options."""
|
||||
|
||||
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_DISABLE_RTSP,
|
||||
default=self.config_entry.options.get(CONF_DISABLE_RTSP, False),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_ALL_UPDATES,
|
||||
default=self.config_entry.options.get(CONF_ALL_UPDATES, False),
|
||||
): bool,
|
||||
vol.Optional(
|
||||
CONF_OVERRIDE_CHOST,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_OVERRIDE_CHOST, False
|
||||
),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
|
@ -0,0 +1,54 @@
|
|||
"""Constant definitions for UniFi Protect Integration."""
|
||||
|
||||
# from typing_extensions import Required
|
||||
from datetime import timedelta
|
||||
|
||||
from pyunifiprotect.data.types import ModelType, Version
|
||||
|
||||
DOMAIN = "unifiprotect"
|
||||
|
||||
ATTR_EVENT_SCORE = "event_score"
|
||||
ATTR_EVENT_OBJECT = "event_object"
|
||||
ATTR_EVENT_THUMB = "event_thumbnail"
|
||||
ATTR_WIDTH = "width"
|
||||
ATTR_HEIGHT = "height"
|
||||
ATTR_FPS = "fps"
|
||||
ATTR_BITRATE = "bitrate"
|
||||
ATTR_CHANNEL_ID = "channel_id"
|
||||
|
||||
CONF_DOORBELL_TEXT = "doorbell_text"
|
||||
CONF_DISABLE_RTSP = "disable_rtsp"
|
||||
CONF_MESSAGE = "message"
|
||||
CONF_DURATION = "duration"
|
||||
CONF_ALL_UPDATES = "all_updates"
|
||||
CONF_OVERRIDE_CHOST = "override_connection_host"
|
||||
|
||||
CONFIG_OPTIONS = [
|
||||
CONF_ALL_UPDATES,
|
||||
CONF_DISABLE_RTSP,
|
||||
CONF_OVERRIDE_CHOST,
|
||||
]
|
||||
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_ATTRIBUTION = "Powered by UniFi Protect Server"
|
||||
DEFAULT_BRAND = "Ubiquiti"
|
||||
DEFAULT_SCAN_INTERVAL = 2
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
|
||||
RING_INTERVAL = timedelta(seconds=3)
|
||||
|
||||
DEVICE_TYPE_CAMERA = "camera"
|
||||
DEVICES_THAT_ADOPT = {
|
||||
ModelType.CAMERA,
|
||||
ModelType.LIGHT,
|
||||
ModelType.VIEWPORT,
|
||||
ModelType.SENSOR,
|
||||
}
|
||||
DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR}
|
||||
DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}
|
||||
|
||||
MIN_REQUIRED_PROTECT_V = Version("1.20.0")
|
||||
|
||||
PLATFORMS = [
|
||||
"camera",
|
||||
]
|
|
@ -0,0 +1,148 @@
|
|||
"""Base class for protect data."""
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient
|
||||
from pyunifiprotect.data import Bootstrap, WSSubscriptionMessage
|
||||
from pyunifiprotect.data.base import ProtectDeviceModel
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProtectData:
|
||||
"""Coordinate updates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
protect: ProtectApiClient,
|
||||
update_interval: timedelta,
|
||||
entry: ConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize an subscriber."""
|
||||
super().__init__()
|
||||
|
||||
self._hass = hass
|
||||
self._entry = entry
|
||||
self._hass = hass
|
||||
self._update_interval = update_interval
|
||||
self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {}
|
||||
self._unsub_interval: CALLBACK_TYPE | None = None
|
||||
self._unsub_websocket: CALLBACK_TYPE | None = None
|
||||
|
||||
self.last_update_success = False
|
||||
self.access_tokens: dict[str, collections.deque] = {}
|
||||
self.api = protect
|
||||
|
||||
@property
|
||||
def disable_stream(self) -> bool:
|
||||
"""Check if RTSP is disabled."""
|
||||
return self._entry.options.get(CONF_DISABLE_RTSP, False)
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Subscribe and do the refresh."""
|
||||
self._unsub_websocket = self.api.subscribe_websocket(
|
||||
self._async_process_ws_message
|
||||
)
|
||||
await self.async_refresh()
|
||||
|
||||
async def async_stop(self, *args: Any) -> None:
|
||||
"""Stop processing data."""
|
||||
if self._unsub_websocket:
|
||||
self._unsub_websocket()
|
||||
self._unsub_websocket = None
|
||||
if self._unsub_interval:
|
||||
self._unsub_interval()
|
||||
self._unsub_interval = None
|
||||
await self.api.async_disconnect_ws()
|
||||
|
||||
async def async_refresh(self, *_: Any, force: bool = False) -> None:
|
||||
"""Update the data."""
|
||||
|
||||
# if last update was failure, force until success
|
||||
if not self.last_update_success:
|
||||
force = True
|
||||
|
||||
try:
|
||||
updates = await self.api.update(force=force)
|
||||
except NvrError:
|
||||
if self.last_update_success:
|
||||
_LOGGER.exception("Error while updating")
|
||||
self.last_update_success = False
|
||||
# manually trigger update to mark entities unavailable
|
||||
self._async_process_updates(self.api.bootstrap)
|
||||
except NotAuthorized:
|
||||
await self.async_stop()
|
||||
_LOGGER.exception("Reauthentication required")
|
||||
self._entry.async_start_reauth(self._hass)
|
||||
self.last_update_success = False
|
||||
else:
|
||||
self.last_update_success = True
|
||||
self._async_process_updates(updates)
|
||||
|
||||
@callback
|
||||
def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None:
|
||||
if message.new_obj.model in DEVICES_WITH_ENTITIES:
|
||||
self.async_signal_device_id_update(message.new_obj.id)
|
||||
|
||||
@callback
|
||||
def _async_process_updates(self, updates: Bootstrap | None) -> None:
|
||||
"""Process update from the protect data."""
|
||||
|
||||
# Websocket connected, use data from it
|
||||
if updates is None:
|
||||
return
|
||||
|
||||
self.async_signal_device_id_update(self.api.bootstrap.nvr.id)
|
||||
for device_type in DEVICES_THAT_ADOPT:
|
||||
attr = f"{device_type.value}s"
|
||||
devices: dict[str, ProtectDeviceModel] = getattr(self.api.bootstrap, attr)
|
||||
for device_id in devices.keys():
|
||||
self.async_signal_device_id_update(device_id)
|
||||
|
||||
@callback
|
||||
def async_subscribe_device_id(
|
||||
self, device_id: str, update_callback: CALLBACK_TYPE
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Add an callback subscriber."""
|
||||
if not self._subscriptions:
|
||||
self._unsub_interval = async_track_time_interval(
|
||||
self._hass, self.async_refresh, self._update_interval
|
||||
)
|
||||
self._subscriptions.setdefault(device_id, []).append(update_callback)
|
||||
|
||||
def _unsubscribe() -> None:
|
||||
self.async_unsubscribe_device_id(device_id, update_callback)
|
||||
|
||||
return _unsubscribe
|
||||
|
||||
@callback
|
||||
def async_unsubscribe_device_id(
|
||||
self, device_id: str, update_callback: CALLBACK_TYPE
|
||||
) -> None:
|
||||
"""Remove a callback subscriber."""
|
||||
self._subscriptions[device_id].remove(update_callback)
|
||||
if not self._subscriptions[device_id]:
|
||||
del self._subscriptions[device_id]
|
||||
if not self._subscriptions and self._unsub_interval:
|
||||
self._unsub_interval()
|
||||
self._unsub_interval = None
|
||||
|
||||
@callback
|
||||
def async_signal_device_id_update(self, device_id: str) -> None:
|
||||
"""Call the callbacks for a device_id."""
|
||||
if not self._subscriptions.get(device_id):
|
||||
return
|
||||
|
||||
for update_callback in self._subscriptions[device_id]:
|
||||
update_callback()
|
|
@ -0,0 +1,113 @@
|
|||
"""Shared Entity definition for UniFi Protect Integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyunifiprotect.data import ProtectAdoptableDeviceModel
|
||||
|
||||
from homeassistant.const import ATTR_ATTRIBUTION
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
||||
|
||||
from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
|
||||
from .data import ProtectData
|
||||
|
||||
|
||||
class ProtectDeviceEntity(Entity):
|
||||
"""Base class for UniFi protect entities."""
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: ProtectData,
|
||||
device: ProtectAdoptableDeviceModel | None = None,
|
||||
description: EntityDescription | None = None,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__()
|
||||
self.data: ProtectData = data
|
||||
|
||||
if device and not hasattr(self, "device"):
|
||||
self.device: ProtectAdoptableDeviceModel = device
|
||||
|
||||
if description and not hasattr(self, "entity_description"):
|
||||
self.entity_description = description
|
||||
elif hasattr(self, "entity_description"):
|
||||
description = self.entity_description
|
||||
|
||||
if description is None:
|
||||
self._attr_unique_id = f"{self.device.id}"
|
||||
self._attr_name = f"{self.device.name}"
|
||||
else:
|
||||
self._attr_unique_id = f"{self.device.id}_{description.key}"
|
||||
name = description.name or ""
|
||||
self._attr_name = f"{self.device.name} {name.title()}"
|
||||
|
||||
self._async_set_device_info()
|
||||
self._async_update_device_from_protect()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the entity.
|
||||
|
||||
Only used by the generic entity update service.
|
||||
"""
|
||||
await self.data.async_refresh()
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return UniFi Protect device attributes."""
|
||||
attrs = super().extra_state_attributes or {}
|
||||
return {
|
||||
**attrs,
|
||||
ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION,
|
||||
**self._extra_state_attributes,
|
||||
}
|
||||
|
||||
@callback
|
||||
def _async_set_device_info(self) -> None:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=self.device.name,
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
model=self.device.type,
|
||||
via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac),
|
||||
sw_version=self.device.firmware_version,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)},
|
||||
configuration_url=self.device.protect_url,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_extra_attrs_from_protect( # pylint: disable=no-self-use
|
||||
self,
|
||||
) -> dict[str, Any]:
|
||||
"""Calculate extra state attributes. Primarily for subclass to override."""
|
||||
return {}
|
||||
|
||||
@callback
|
||||
def _async_update_device_from_protect(self) -> None:
|
||||
"""Update Entity object from Protect device."""
|
||||
if self.data.last_update_success:
|
||||
assert self.device.model
|
||||
devices = getattr(self.data.api.bootstrap, f"{self.device.model.value}s")
|
||||
self.device = devices[self.device.id]
|
||||
|
||||
self._attr_available = (
|
||||
self.data.last_update_success and self.device.is_connected
|
||||
)
|
||||
self._extra_state_attributes = self._async_update_extra_attrs_from_protect()
|
||||
|
||||
@callback
|
||||
def _async_updated_event(self) -> None:
|
||||
"""Call back for incoming data."""
|
||||
self._async_update_device_from_protect()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_device_id(
|
||||
self.device.id, self._async_updated_event
|
||||
)
|
||||
)
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"domain": "unifiprotect",
|
||||
"name": "UniFi Protect",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/unifiprotect",
|
||||
"requirements": [
|
||||
"pyunifiprotect==1.4.4"
|
||||
],
|
||||
"codeowners": [
|
||||
"@briis",
|
||||
"@AngellusMortis",
|
||||
"@bdraco"
|
||||
],
|
||||
"quality_scale": "platinum",
|
||||
"iot_class": "local_push"
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%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%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "UniFi Protect Options",
|
||||
"description": "Realtime metrics option should only be enabled if you have enabled the diagnostics sensors and want them updated in realtime. If if not enabled, they will only update once every 15 minutes.",
|
||||
"data": {
|
||||
"disable_rtsp": "Disable the RTSP stream",
|
||||
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
|
||||
"override_connection_host": "Override Connection Host"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Device is already configured"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect",
|
||||
"invalid_auth": "Invalid authentication",
|
||||
"unknown": "Unexpected error"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"password": "Password",
|
||||
"username": "Username"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
|
||||
"disable_rtsp": "Disable the RTSP stream",
|
||||
"override_connection_host": "Override Connection Host"
|
||||
},
|
||||
"description": "Realtime metrics option should only be enabled if you have enabled the diagnostics sensors and want them updated in realtime. If if not enabled, they will only update once every 15 minutes.",
|
||||
"title": "UniFi Protect Options"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -323,6 +323,7 @@ FLOWS = [
|
|||
"twilio",
|
||||
"twinkly",
|
||||
"unifi",
|
||||
"unifiprotect",
|
||||
"upb",
|
||||
"upcloud",
|
||||
"upnp",
|
||||
|
|
11
mypy.ini
11
mypy.ini
|
@ -1584,6 +1584,17 @@ no_implicit_optional = true
|
|||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.unifiprotect.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.upcloud.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
|
|
@ -1999,6 +1999,9 @@ pytrafikverket==0.1.6.2
|
|||
# homeassistant.components.usb
|
||||
pyudev==0.22.0
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
pyunifiprotect==1.4.4
|
||||
|
||||
# homeassistant.components.uptimerobot
|
||||
pyuptimerobot==21.11.0
|
||||
|
||||
|
|
|
@ -1206,6 +1206,9 @@ pytrafikverket==0.1.6.2
|
|||
# homeassistant.components.usb
|
||||
pyudev==0.22.0
|
||||
|
||||
# homeassistant.components.unifiprotect
|
||||
pyunifiprotect==1.4.4
|
||||
|
||||
# homeassistant.components.uptimerobot
|
||||
pyuptimerobot==21.11.0
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
"""Tests for the UniFi Protect integration."""
|
|
@ -0,0 +1,168 @@
|
|||
"""Fixtures and test data for UniFi Protect methods."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from ipaddress import IPv4Address
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from pyunifiprotect.data import Camera, Version
|
||||
from pyunifiprotect.data.websocket import WSSubscriptionMessage
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DOMAIN, MIN_REQUIRED_PROTECT_V
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
MAC_ADDR = "aa:bb:cc:dd:ee:ff"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockPortData:
|
||||
"""Mock Port information."""
|
||||
|
||||
rtsp: int = 7441
|
||||
rtsps: int = 7447
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockNvrData:
|
||||
"""Mock for NVR."""
|
||||
|
||||
version: Version
|
||||
mac: str
|
||||
name: str
|
||||
id: str
|
||||
ports: MockPortData = MockPortData()
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockBootstrap:
|
||||
"""Mock for Bootstrap."""
|
||||
|
||||
nvr: MockNvrData
|
||||
cameras: dict[str, Any]
|
||||
lights: dict[str, Any]
|
||||
sensors: dict[str, Any]
|
||||
viewers: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockEntityFixture:
|
||||
"""Mock for NVR."""
|
||||
|
||||
entry: MockConfigEntry
|
||||
api: Mock
|
||||
|
||||
|
||||
MOCK_NVR_DATA = MockNvrData(
|
||||
version=MIN_REQUIRED_PROTECT_V, mac=MAC_ADDR, name="UnifiProtect", id="test_id"
|
||||
)
|
||||
MOCK_OLD_NVR_DATA = MockNvrData(
|
||||
version=Version("1.19.0"), mac=MAC_ADDR, name="UnifiProtect", id="test_id"
|
||||
)
|
||||
|
||||
MOCK_BOOTSTRAP = MockBootstrap(
|
||||
nvr=MOCK_NVR_DATA, cameras={}, lights={}, sensors={}, viewers={}
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Mock ProtectApiClient for testing."""
|
||||
client = Mock()
|
||||
client.bootstrap = MOCK_BOOTSTRAP
|
||||
|
||||
client.base_url = "https://127.0.0.1"
|
||||
client.connection_host = IPv4Address("127.0.0.1")
|
||||
client.get_nvr = AsyncMock(return_value=MOCK_NVR_DATA)
|
||||
client.update = AsyncMock(return_value=MOCK_BOOTSTRAP)
|
||||
client.async_disconnect_ws = AsyncMock()
|
||||
|
||||
def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any:
|
||||
client.ws_subscription = ws_callback
|
||||
|
||||
return Mock()
|
||||
|
||||
client.subscribe_websocket = subscribe
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_entry(hass: HomeAssistant, mock_client):
|
||||
"""Mock ProtectApiClient for testing."""
|
||||
|
||||
with patch("homeassistant.components.unifiprotect.ProtectApiClient") as mock_api:
|
||||
mock_config = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"id": "UnifiProtect",
|
||||
"port": 443,
|
||||
"verify_ssl": False,
|
||||
},
|
||||
version=2,
|
||||
)
|
||||
mock_config.add_to_hass(hass)
|
||||
|
||||
mock_api.return_value = mock_client
|
||||
|
||||
yield MockEntityFixture(mock_config, mock_client)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_camera():
|
||||
"""Mock UniFi Protect Camera device."""
|
||||
|
||||
path = Path(__file__).parent / "sample_data" / "sample_camera.json"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
yield Camera.from_unifi_dict(**data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def simple_camera(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera
|
||||
):
|
||||
"""Fixture for a single camera, no extra setup."""
|
||||
|
||||
camera = mock_camera.copy(deep=True)
|
||||
camera._api = mock_entry.api
|
||||
camera.channels[0]._api = mock_entry.api
|
||||
camera.channels[1]._api = mock_entry.api
|
||||
camera.channels[2]._api = mock_entry.api
|
||||
camera.name = "Test Camera"
|
||||
camera.channels[0].is_rtsp_enabled = True
|
||||
camera.channels[0].name = "High"
|
||||
camera.channels[1].is_rtsp_enabled = False
|
||||
camera.channels[2].is_rtsp_enabled = False
|
||||
|
||||
mock_entry.api.bootstrap.cameras = {
|
||||
camera.id: camera,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 1
|
||||
assert len(entity_registry.entities) == 2
|
||||
|
||||
yield (camera, "camera.test_camera_high")
|
||||
|
||||
|
||||
async def time_changed(hass, seconds):
|
||||
"""Trigger time changed."""
|
||||
next_update = dt_util.utcnow() + timedelta(seconds)
|
||||
async_fire_time_changed(hass, next_update)
|
||||
await hass.async_block_till_done()
|
|
@ -0,0 +1,482 @@
|
|||
{
|
||||
"isDeleting": false,
|
||||
"mac": "72C7836A47DC",
|
||||
"host": "192.168.6.90",
|
||||
"connectionHost": "192.168.178.217",
|
||||
"type": "UVC G4 Instant",
|
||||
"name": "Fufail Qqjx",
|
||||
"upSince": 1640020678036,
|
||||
"uptime": 3203,
|
||||
"lastSeen": 1640023881036,
|
||||
"connectedSince": 1640020710448,
|
||||
"state": "CONNECTED",
|
||||
"hardwareRevision": "11",
|
||||
"firmwareVersion": "4.47.13",
|
||||
"latestFirmwareVersion": "4.47.13",
|
||||
"firmwareBuild": "0a55423.211124.718",
|
||||
"isUpdating": false,
|
||||
"isAdopting": false,
|
||||
"isAdopted": true,
|
||||
"isAdoptedByOther": false,
|
||||
"isProvisioned": true,
|
||||
"isRebooting": false,
|
||||
"isSshEnabled": true,
|
||||
"canAdopt": false,
|
||||
"isAttemptingToConnect": false,
|
||||
"lastMotion": 1640021213927,
|
||||
"micVolume": 100,
|
||||
"isMicEnabled": true,
|
||||
"isRecording": false,
|
||||
"isWirelessUplinkEnabled": true,
|
||||
"isMotionDetected": false,
|
||||
"isSmartDetected": false,
|
||||
"phyRate": 72,
|
||||
"hdrMode": true,
|
||||
"videoMode": "default",
|
||||
"isProbingForWifi": false,
|
||||
"apMac": null,
|
||||
"apRssi": null,
|
||||
"elementInfo": null,
|
||||
"chimeDuration": 0,
|
||||
"isDark": false,
|
||||
"lastPrivacyZonePositionId": null,
|
||||
"lastRing": null,
|
||||
"isLiveHeatmapEnabled": false,
|
||||
"anonymousDeviceId": "7722b5e7-ecfa-468c-a385-3eafea917b0c",
|
||||
"eventStats": {
|
||||
"motion": {
|
||||
"today": 10,
|
||||
"average": 39,
|
||||
"lastDays": [
|
||||
48,
|
||||
45,
|
||||
33,
|
||||
41,
|
||||
44,
|
||||
60,
|
||||
6
|
||||
],
|
||||
"recentHours": [
|
||||
0,
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"smart": {
|
||||
"today": 0,
|
||||
"average": 0,
|
||||
"lastDays": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"videoReconfigurationInProgress": false,
|
||||
"voltage": null,
|
||||
"wiredConnectionState": {
|
||||
"phyRate": null
|
||||
},
|
||||
"channels": [
|
||||
{
|
||||
"id": 0,
|
||||
"videoId": "video1",
|
||||
"name": "Jzi Bftu",
|
||||
"enabled": true,
|
||||
"isRtspEnabled": true,
|
||||
"rtspAlias": "ANOAPfoKMW7VixG1",
|
||||
"width": 2688,
|
||||
"height": 1512,
|
||||
"fps": 30,
|
||||
"bitrate": 10000000,
|
||||
"minBitrate": 32000,
|
||||
"maxBitrate": 10000000,
|
||||
"minClientAdaptiveBitRate": 0,
|
||||
"minMotionAdaptiveBitRate": 2000000,
|
||||
"fpsValues": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
18,
|
||||
20,
|
||||
24,
|
||||
25,
|
||||
30
|
||||
],
|
||||
"idrInterval": 5
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"videoId": "video2",
|
||||
"name": "Rgcpxsf Xfwt",
|
||||
"enabled": true,
|
||||
"isRtspEnabled": true,
|
||||
"rtspAlias": "XHXAdHVKGVEzMNTP",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30,
|
||||
"bitrate": 1500000,
|
||||
"minBitrate": 32000,
|
||||
"maxBitrate": 2000000,
|
||||
"minClientAdaptiveBitRate": 150000,
|
||||
"minMotionAdaptiveBitRate": 750000,
|
||||
"fpsValues": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
18,
|
||||
20,
|
||||
24,
|
||||
25,
|
||||
30
|
||||
],
|
||||
"idrInterval": 5
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"videoId": "video3",
|
||||
"name": "Umefvk Fug",
|
||||
"enabled": true,
|
||||
"isRtspEnabled": false,
|
||||
"rtspAlias": null,
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"fps": 30,
|
||||
"bitrate": 200000,
|
||||
"minBitrate": 32000,
|
||||
"maxBitrate": 1000000,
|
||||
"minClientAdaptiveBitRate": 0,
|
||||
"minMotionAdaptiveBitRate": 200000,
|
||||
"fpsValues": [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
12,
|
||||
15,
|
||||
16,
|
||||
18,
|
||||
20,
|
||||
24,
|
||||
25,
|
||||
30
|
||||
],
|
||||
"idrInterval": 5
|
||||
}
|
||||
],
|
||||
"ispSettings": {
|
||||
"aeMode": "auto",
|
||||
"irLedMode": "auto",
|
||||
"irLedLevel": 255,
|
||||
"wdr": 1,
|
||||
"icrSensitivity": 0,
|
||||
"brightness": 50,
|
||||
"contrast": 50,
|
||||
"hue": 50,
|
||||
"saturation": 50,
|
||||
"sharpness": 50,
|
||||
"denoise": 50,
|
||||
"isFlippedVertical": false,
|
||||
"isFlippedHorizontal": false,
|
||||
"isAutoRotateEnabled": true,
|
||||
"isLdcEnabled": true,
|
||||
"is3dnrEnabled": true,
|
||||
"isExternalIrEnabled": false,
|
||||
"isAggressiveAntiFlickerEnabled": false,
|
||||
"isPauseMotionEnabled": false,
|
||||
"dZoomCenterX": 50,
|
||||
"dZoomCenterY": 50,
|
||||
"dZoomScale": 0,
|
||||
"dZoomStreamId": 4,
|
||||
"focusMode": "ztrig",
|
||||
"focusPosition": 0,
|
||||
"touchFocusX": 1001,
|
||||
"touchFocusY": 1001,
|
||||
"zoomPosition": 0,
|
||||
"mountPosition": "wall"
|
||||
},
|
||||
"talkbackSettings": {
|
||||
"typeFmt": "aac",
|
||||
"typeIn": "serverudp",
|
||||
"bindAddr": "0.0.0.0",
|
||||
"bindPort": 7004,
|
||||
"filterAddr": "",
|
||||
"filterPort": 0,
|
||||
"channels": 1,
|
||||
"samplingRate": 22050,
|
||||
"bitsPerSample": 16,
|
||||
"quality": 100
|
||||
},
|
||||
"osdSettings": {
|
||||
"isNameEnabled": true,
|
||||
"isDateEnabled": true,
|
||||
"isLogoEnabled": false,
|
||||
"isDebugEnabled": false
|
||||
},
|
||||
"ledSettings": {
|
||||
"isEnabled": false,
|
||||
"blinkRate": 0
|
||||
},
|
||||
"speakerSettings": {
|
||||
"isEnabled": true,
|
||||
"areSystemSoundsEnabled": false,
|
||||
"volume": 100
|
||||
},
|
||||
"recordingSettings": {
|
||||
"prePaddingSecs": 10,
|
||||
"postPaddingSecs": 10,
|
||||
"minMotionEventTrigger": 1000,
|
||||
"endMotionEventDelay": 3000,
|
||||
"suppressIlluminationSurge": false,
|
||||
"mode": "detections",
|
||||
"geofencing": "off",
|
||||
"motionAlgorithm": "enhanced",
|
||||
"enablePirTimelapse": false,
|
||||
"useNewMotionAlgorithm": true
|
||||
},
|
||||
"smartDetectSettings": {
|
||||
"objectTypes": []
|
||||
},
|
||||
"recordingSchedules": [],
|
||||
"motionZones": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default",
|
||||
"color": "#AB46BC",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
1,
|
||||
0
|
||||
],
|
||||
[
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
0,
|
||||
1
|
||||
]
|
||||
],
|
||||
"sensitivity": 50
|
||||
}
|
||||
],
|
||||
"privacyZones": [],
|
||||
"smartDetectZones": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Default",
|
||||
"color": "#AB46BC",
|
||||
"points": [
|
||||
[
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
1,
|
||||
0
|
||||
],
|
||||
[
|
||||
1,
|
||||
1
|
||||
],
|
||||
[
|
||||
0,
|
||||
1
|
||||
]
|
||||
],
|
||||
"sensitivity": 50,
|
||||
"objectTypes": []
|
||||
}
|
||||
],
|
||||
"smartDetectLines": [],
|
||||
"stats": {
|
||||
"rxBytes": 33684237,
|
||||
"txBytes": 1208318620,
|
||||
"wifi": {
|
||||
"channel": 6,
|
||||
"frequency": 2437,
|
||||
"linkSpeedMbps": null,
|
||||
"signalQuality": 100,
|
||||
"signalStrength": -35
|
||||
},
|
||||
"battery": {
|
||||
"percentage": null,
|
||||
"isCharging": false,
|
||||
"sleepState": "disconnected"
|
||||
},
|
||||
"video": {
|
||||
"recordingStart": 1639219284079,
|
||||
"recordingEnd": 1640021215245,
|
||||
"recordingStartLQ": 1639219283987,
|
||||
"recordingEndLQ": 1640021217213,
|
||||
"timelapseStart": 1639219284030,
|
||||
"timelapseEnd": 1640023738713,
|
||||
"timelapseStartLQ": 1639219284030,
|
||||
"timelapseEndLQ": 1640021765237
|
||||
},
|
||||
"storage": {
|
||||
"used": 20401094656,
|
||||
"rate": 693.424269097809
|
||||
},
|
||||
"wifiQuality": 100,
|
||||
"wifiStrength": -35
|
||||
},
|
||||
"featureFlags": {
|
||||
"canAdjustIrLedLevel": false,
|
||||
"canMagicZoom": false,
|
||||
"canOpticalZoom": false,
|
||||
"canTouchFocus": false,
|
||||
"hasAccelerometer": true,
|
||||
"hasAec": true,
|
||||
"hasBattery": false,
|
||||
"hasBluetooth": true,
|
||||
"hasChime": false,
|
||||
"hasExternalIr": false,
|
||||
"hasIcrSensitivity": true,
|
||||
"hasLdc": false,
|
||||
"hasLedIr": true,
|
||||
"hasLedStatus": true,
|
||||
"hasLineIn": false,
|
||||
"hasMic": true,
|
||||
"hasPrivacyMask": true,
|
||||
"hasRtc": false,
|
||||
"hasSdCard": false,
|
||||
"hasSpeaker": true,
|
||||
"hasWifi": true,
|
||||
"hasHdr": true,
|
||||
"hasAutoICROnly": true,
|
||||
"videoModes": [
|
||||
"default"
|
||||
],
|
||||
"videoModeMaxFps": [],
|
||||
"hasMotionZones": true,
|
||||
"hasLcdScreen": false,
|
||||
"mountPositions": [],
|
||||
"smartDetectTypes": [],
|
||||
"motionAlgorithms": [
|
||||
"enhanced"
|
||||
],
|
||||
"hasSquareEventThumbnail": true,
|
||||
"hasPackageCamera": false,
|
||||
"privacyMaskCapability": {
|
||||
"maxMasks": 4,
|
||||
"rectangleOnly": true
|
||||
},
|
||||
"focus": {
|
||||
"steps": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
},
|
||||
"degrees": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
}
|
||||
},
|
||||
"pan": {
|
||||
"steps": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
},
|
||||
"degrees": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
}
|
||||
},
|
||||
"tilt": {
|
||||
"steps": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
},
|
||||
"degrees": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
}
|
||||
},
|
||||
"zoom": {
|
||||
"steps": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
},
|
||||
"degrees": {
|
||||
"max": null,
|
||||
"min": null,
|
||||
"step": null
|
||||
}
|
||||
},
|
||||
"hasSmartDetect": false
|
||||
},
|
||||
"pirSettings": {
|
||||
"pirSensitivity": 100,
|
||||
"pirMotionClipLength": 15,
|
||||
"timelapseFrameInterval": 15,
|
||||
"timelapseTransferInterval": 600
|
||||
},
|
||||
"lcdMessage": {},
|
||||
"wifiConnectionState": {
|
||||
"channel": 6,
|
||||
"frequency": 2437,
|
||||
"phyRate": 72,
|
||||
"signalQuality": 100,
|
||||
"signalStrength": -35,
|
||||
"ssid": "Mortis Camera"
|
||||
},
|
||||
"lenses": [],
|
||||
"id": "0de062b4f6922d489d3b312d",
|
||||
"isConnected": true,
|
||||
"platform": "sav530q",
|
||||
"hasSpeaker": true,
|
||||
"hasWifi": true,
|
||||
"audioBitrate": 64000,
|
||||
"canManage": false,
|
||||
"isManaged": true,
|
||||
"marketName": "G4 Instant",
|
||||
"modelKey": "camera"
|
||||
}
|
|
@ -0,0 +1,358 @@
|
|||
"""Test the UniFi Protect camera platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import copy
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from pyunifiprotect.data import Camera as ProtectCamera
|
||||
from pyunifiprotect.exceptions import NvrError
|
||||
|
||||
from homeassistant.components.camera import Camera, async_get_image
|
||||
from homeassistant.components.unifiprotect.const import (
|
||||
ATTR_BITRATE,
|
||||
ATTR_CHANNEL_ID,
|
||||
ATTR_FPS,
|
||||
ATTR_HEIGHT,
|
||||
ATTR_WIDTH,
|
||||
DEFAULT_ATTRIBUTION,
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.unifiprotect.data import ProtectData
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import MockEntityFixture, time_changed
|
||||
|
||||
|
||||
async def validate_camera_entity(
|
||||
hass: HomeAssistant,
|
||||
camera: ProtectCamera,
|
||||
channel_id: int,
|
||||
secure: bool,
|
||||
rtsp_enabled: bool,
|
||||
enabled: bool,
|
||||
):
|
||||
"""Validate a camera entity."""
|
||||
|
||||
channel = camera.channels[channel_id]
|
||||
|
||||
entity_name = f"{camera.name} {channel.name}"
|
||||
unique_id = f"{camera.id}_{channel.id}"
|
||||
if not secure:
|
||||
entity_name += " Insecure"
|
||||
unique_id += "_insecure"
|
||||
entity_id = f"camera.{entity_name.replace(' ', '_').lower()}"
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
entity = entity_registry.async_get(entity_id)
|
||||
assert entity
|
||||
assert entity.disabled is (not enabled)
|
||||
assert entity.unique_id == unique_id
|
||||
|
||||
if not enabled:
|
||||
return
|
||||
|
||||
camera_platform = hass.data.get("camera")
|
||||
assert camera_platform
|
||||
ha_camera = cast(Camera, camera_platform.get_entity(entity_id))
|
||||
assert ha_camera
|
||||
if rtsp_enabled:
|
||||
if secure:
|
||||
assert await ha_camera.stream_source() == channel.rtsps_url
|
||||
else:
|
||||
assert await ha_camera.stream_source() == channel.rtsp_url
|
||||
else:
|
||||
assert await ha_camera.stream_source() is None
|
||||
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
assert entity_state.attributes[ATTR_WIDTH] == channel.width
|
||||
assert entity_state.attributes[ATTR_HEIGHT] == channel.height
|
||||
assert entity_state.attributes[ATTR_FPS] == channel.fps
|
||||
assert entity_state.attributes[ATTR_BITRATE] == channel.bitrate
|
||||
assert entity_state.attributes[ATTR_CHANNEL_ID] == channel.id
|
||||
|
||||
|
||||
async def test_basic_setup(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera
|
||||
):
|
||||
"""Test working setup of unifiprotect entry."""
|
||||
|
||||
camera_high_only = mock_camera.copy(deep=True)
|
||||
camera_high_only._api = mock_entry.api
|
||||
camera_high_only.channels[0]._api = mock_entry.api
|
||||
camera_high_only.channels[1]._api = mock_entry.api
|
||||
camera_high_only.channels[2]._api = mock_entry.api
|
||||
camera_high_only.name = "Test Camera 1"
|
||||
camera_high_only.id = "test_high"
|
||||
camera_high_only.channels[0].is_rtsp_enabled = True
|
||||
camera_high_only.channels[0].name = "High"
|
||||
camera_high_only.channels[0].rtsp_alias = "test_high_alias"
|
||||
camera_high_only.channels[1].is_rtsp_enabled = False
|
||||
camera_high_only.channels[2].is_rtsp_enabled = False
|
||||
|
||||
camera_all_channels = mock_camera.copy(deep=True)
|
||||
camera_all_channels._api = mock_entry.api
|
||||
camera_all_channels.channels[0]._api = mock_entry.api
|
||||
camera_all_channels.channels[1]._api = mock_entry.api
|
||||
camera_all_channels.channels[2]._api = mock_entry.api
|
||||
camera_all_channels.name = "Test Camera 2"
|
||||
camera_all_channels.id = "test_all"
|
||||
camera_all_channels.channels[0].is_rtsp_enabled = True
|
||||
camera_all_channels.channels[0].name = "High"
|
||||
camera_all_channels.channels[0].rtsp_alias = "test_high_alias"
|
||||
camera_all_channels.channels[1].is_rtsp_enabled = True
|
||||
camera_all_channels.channels[1].name = "Medium"
|
||||
camera_all_channels.channels[1].rtsp_alias = "test_medium_alias"
|
||||
camera_all_channels.channels[2].is_rtsp_enabled = True
|
||||
camera_all_channels.channels[2].name = "Low"
|
||||
camera_all_channels.channels[2].rtsp_alias = "test_low_alias"
|
||||
|
||||
camera_no_channels = mock_camera.copy(deep=True)
|
||||
camera_no_channels._api = mock_entry.api
|
||||
camera_no_channels.channels[0]._api = mock_entry.api
|
||||
camera_no_channels.channels[1]._api = mock_entry.api
|
||||
camera_no_channels.channels[2]._api = mock_entry.api
|
||||
camera_no_channels.name = "Test Camera 3"
|
||||
camera_no_channels.id = "test_none"
|
||||
camera_no_channels.channels[0].is_rtsp_enabled = False
|
||||
camera_no_channels.channels[0].name = "High"
|
||||
camera_no_channels.channels[1].is_rtsp_enabled = False
|
||||
camera_no_channels.channels[2].is_rtsp_enabled = False
|
||||
|
||||
mock_entry.api.bootstrap.cameras = {
|
||||
camera_high_only.id: camera_high_only,
|
||||
camera_all_channels.id: camera_all_channels,
|
||||
camera_no_channels.id: camera_no_channels,
|
||||
}
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await validate_camera_entity(
|
||||
hass, camera_high_only, 0, secure=True, rtsp_enabled=True, enabled=True
|
||||
)
|
||||
await validate_camera_entity(
|
||||
hass, camera_high_only, 0, secure=False, rtsp_enabled=True, enabled=False
|
||||
)
|
||||
|
||||
await validate_camera_entity(
|
||||
hass, camera_all_channels, 0, secure=True, rtsp_enabled=True, enabled=True
|
||||
)
|
||||
await validate_camera_entity(
|
||||
hass, camera_all_channels, 0, secure=False, rtsp_enabled=True, enabled=False
|
||||
)
|
||||
await validate_camera_entity(
|
||||
hass, camera_all_channels, 1, secure=True, rtsp_enabled=True, enabled=False
|
||||
)
|
||||
await validate_camera_entity(
|
||||
hass, camera_all_channels, 1, secure=False, rtsp_enabled=True, enabled=False
|
||||
)
|
||||
await validate_camera_entity(
|
||||
hass, camera_all_channels, 2, secure=True, rtsp_enabled=True, enabled=False
|
||||
)
|
||||
await validate_camera_entity(
|
||||
hass, camera_all_channels, 2, secure=False, rtsp_enabled=True, enabled=False
|
||||
)
|
||||
|
||||
await validate_camera_entity(
|
||||
hass, camera_no_channels, 0, secure=True, rtsp_enabled=False, enabled=True
|
||||
)
|
||||
|
||||
|
||||
async def test_missing_channels(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera
|
||||
):
|
||||
"""Test setting up camera with no camera channels."""
|
||||
|
||||
camera = mock_camera.copy(deep=True)
|
||||
camera.channels = []
|
||||
|
||||
mock_entry.api.bootstrap.cameras = {camera.id: camera}
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
assert len(hass.states.async_all()) == 0
|
||||
assert len(entity_registry.entities) == 0
|
||||
|
||||
|
||||
async def test_camera_image(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[Camera, str],
|
||||
):
|
||||
"""Test retrieving camera image."""
|
||||
|
||||
mock_entry.api.get_camera_snapshot = AsyncMock()
|
||||
|
||||
await async_get_image(hass, simple_camera[1])
|
||||
mock_entry.api.get_camera_snapshot.assert_called_once()
|
||||
|
||||
|
||||
async def test_camera_generic_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""Tests generic entity update service."""
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
data: ProtectData = hass.data[DOMAIN][mock_entry.entry.entry_id]
|
||||
assert data
|
||||
assert data.last_update_success
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
mock_entry.api.update = AsyncMock(return_value=None)
|
||||
await hass.services.async_call(
|
||||
"homeassistant",
|
||||
"update_entity",
|
||||
{ATTR_ENTITY_ID: simple_camera[1]},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
|
||||
async def test_camera_interval_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""Interval updates updates camera entity."""
|
||||
|
||||
data: ProtectData = hass.data[DOMAIN][mock_entry.entry.entry_id]
|
||||
assert data
|
||||
assert data.last_update_success
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = simple_camera[0].copy()
|
||||
new_camera.is_recording = True
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
mock_entry.api.update = AsyncMock(return_value=new_bootstrap)
|
||||
mock_entry.api.bootstrap = new_bootstrap
|
||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "recording"
|
||||
|
||||
|
||||
async def test_camera_bad_interval_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[Camera, str],
|
||||
):
|
||||
"""Interval updates marks camera unavailable."""
|
||||
|
||||
data: ProtectData = hass.data[DOMAIN][mock_entry.entry.entry_id]
|
||||
assert data
|
||||
assert data.last_update_success
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
# update fails
|
||||
mock_entry.api.update = AsyncMock(side_effect=NvrError)
|
||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
assert not data.last_update_success
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "unavailable"
|
||||
|
||||
# next update succeeds
|
||||
mock_entry.api.update = AsyncMock(return_value=mock_entry.api.bootstrap)
|
||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
||||
|
||||
assert data.last_update_success
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
|
||||
async def test_camera_ws_update(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""WS update updates camera entity."""
|
||||
|
||||
data: ProtectData = hass.data[DOMAIN][mock_entry.entry.entry_id]
|
||||
assert data
|
||||
assert data.last_update_success
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = simple_camera[0].copy()
|
||||
new_camera.is_recording = True
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.new_obj = new_camera
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
mock_entry.api.bootstrap = new_bootstrap
|
||||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "recording"
|
||||
|
||||
|
||||
async def test_camera_ws_update_offline(
|
||||
hass: HomeAssistant,
|
||||
mock_entry: MockEntityFixture,
|
||||
simple_camera: tuple[ProtectCamera, str],
|
||||
):
|
||||
"""WS updates marks camera unavailable."""
|
||||
|
||||
data: ProtectData = hass.data[DOMAIN][mock_entry.entry.entry_id]
|
||||
assert data
|
||||
assert data.last_update_success
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
||||
|
||||
# camera goes offline
|
||||
new_bootstrap = copy(mock_entry.api.bootstrap)
|
||||
new_camera = simple_camera[0].copy()
|
||||
new_camera.is_connected = False
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.new_obj = new_camera
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
mock_entry.api.bootstrap = new_bootstrap
|
||||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "unavailable"
|
||||
|
||||
# camera comes back online
|
||||
new_camera.is_connected = True
|
||||
|
||||
mock_msg = Mock()
|
||||
mock_msg.new_obj = new_camera
|
||||
|
||||
new_bootstrap.cameras = {new_camera.id: new_camera}
|
||||
mock_entry.api.bootstrap = new_bootstrap
|
||||
mock_entry.api.ws_subscription(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(simple_camera[1])
|
||||
assert state and state.state == "idle"
|
|
@ -0,0 +1,227 @@
|
|||
"""Test the UniFi Protect config flow."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from pyunifiprotect import NotAuthorized, NvrError
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.unifiprotect.const import (
|
||||
CONF_ALL_UPDATES,
|
||||
CONF_DISABLE_RTSP,
|
||||
CONF_OVERRIDE_CHOST,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import (
|
||||
RESULT_TYPE_ABORT,
|
||||
RESULT_TYPE_CREATE_ENTRY,
|
||||
RESULT_TYPE_FORM,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .conftest import MAC_ADDR, MOCK_NVR_DATA, MOCK_OLD_NVR_DATA
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant) -> None:
|
||||
"""Test we get the form."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert not result["errors"]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
|
||||
return_value=MOCK_NVR_DATA,
|
||||
), patch(
|
||||
"homeassistant.components.unifiprotect.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["title"] == "UnifiProtect"
|
||||
assert result2["data"] == {
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"id": "UnifiProtect",
|
||||
"port": 443,
|
||||
"verify_ssl": False,
|
||||
}
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_form_version_too_old(hass: HomeAssistant) -> None:
|
||||
"""Test we handle the version being too old."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
|
||||
return_value=MOCK_OLD_NVR_DATA,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "protect_version"}
|
||||
|
||||
|
||||
async def test_form_invalid_auth(hass: HomeAssistant) -> None:
|
||||
"""Test we handle invalid auth."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
|
||||
side_effect=NotAuthorized,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"password": "invalid_auth"}
|
||||
|
||||
|
||||
async def test_form_cannot_connect(hass: HomeAssistant) -> None:
|
||||
"""Test we handle cannot connect error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
|
||||
side_effect=NvrError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"base": "nvr_error"}
|
||||
|
||||
|
||||
async def test_form_reauth_auth(hass: HomeAssistant) -> None:
|
||||
"""Test we handle reauth auth."""
|
||||
mock_config = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"id": "UnifiProtect",
|
||||
"port": 443,
|
||||
"verify_ssl": False,
|
||||
},
|
||||
unique_id=dr.format_mac(MAC_ADDR),
|
||||
)
|
||||
mock_config.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={
|
||||
"source": config_entries.SOURCE_REAUTH,
|
||||
"entry_id": mock_config.entry_id,
|
||||
},
|
||||
)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert not result["errors"]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
|
||||
side_effect=NotAuthorized,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_FORM
|
||||
assert result2["errors"] == {"password": "invalid_auth"}
|
||||
assert result2["step_id"] == "reauth_confirm"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr",
|
||||
return_value=MOCK_NVR_DATA,
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
{
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
},
|
||||
)
|
||||
|
||||
assert result3["type"] == RESULT_TYPE_ABORT
|
||||
assert result3["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_form_options(hass: HomeAssistant) -> None:
|
||||
"""Test we handle options flows."""
|
||||
mock_config = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
"host": "1.1.1.1",
|
||||
"username": "test-username",
|
||||
"password": "test-password",
|
||||
"id": "UnifiProtect",
|
||||
"port": 443,
|
||||
"verify_ssl": False,
|
||||
},
|
||||
version=2,
|
||||
unique_id=dr.format_mac(MAC_ADDR),
|
||||
)
|
||||
mock_config.add_to_hass(hass)
|
||||
|
||||
# Integration not setup, since we are only flipping bits in options entry
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config.entry_id)
|
||||
assert result["type"] == RESULT_TYPE_FORM
|
||||
assert not result["errors"]
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result2 = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DISABLE_RTSP: True, CONF_ALL_UPDATES: True, CONF_OVERRIDE_CHOST: True},
|
||||
)
|
||||
|
||||
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
|
||||
assert result2["data"] == {
|
||||
"all_updates": True,
|
||||
"disable_rtsp": True,
|
||||
"override_connection_host": True,
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
"""Test the UniFi Protect setup flow."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pyunifiprotect import NotAuthorized, NvrError
|
||||
|
||||
from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import MOCK_OLD_NVR_DATA, MockEntityFixture
|
||||
|
||||
|
||||
async def test_setup(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test working setup of unifiprotect entry."""
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.entry.state == ConfigEntryState.LOADED
|
||||
assert mock_entry.api.update.called
|
||||
assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac
|
||||
assert mock_entry.entry.entry_id in hass.data[DOMAIN]
|
||||
|
||||
|
||||
async def test_reload(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test updating entry reload entry."""
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.entry.state == ConfigEntryState.LOADED
|
||||
|
||||
options = dict(mock_entry.entry.options)
|
||||
options[CONF_DISABLE_RTSP] = True
|
||||
hass.config_entries.async_update_entry(mock_entry.entry, options=options)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_entry.entry.state == ConfigEntryState.LOADED
|
||||
assert mock_entry.entry.entry_id in hass.data[DOMAIN]
|
||||
assert mock_entry.api.async_disconnect_ws.called
|
||||
|
||||
|
||||
async def test_unload(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test unloading of unifiprotect entry."""
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.entry.state == ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_unload(mock_entry.entry.entry_id)
|
||||
assert mock_entry.entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert mock_entry.api.async_disconnect_ws.called
|
||||
|
||||
|
||||
async def test_setup_too_old(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test setup of unifiprotect entry with too old of version of UniFi Protect."""
|
||||
|
||||
mock_entry.api.get_nvr.return_value = MOCK_OLD_NVR_DATA
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR
|
||||
assert not mock_entry.api.update.called
|
||||
|
||||
|
||||
async def test_setup_failed_update(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test setup of unifiprotect entry with failed update."""
|
||||
|
||||
mock_entry.api.update = AsyncMock(side_effect=NvrError)
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY
|
||||
assert mock_entry.api.update.called
|
||||
|
||||
|
||||
async def test_setup_failed_update_reauth(
|
||||
hass: HomeAssistant, mock_entry: MockEntityFixture
|
||||
):
|
||||
"""Test setup of unifiprotect entry with update that gives unauthroized error."""
|
||||
|
||||
mock_entry.api.update = AsyncMock(side_effect=NotAuthorized)
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY
|
||||
assert mock_entry.api.update.called
|
||||
|
||||
|
||||
async def test_setup_failed_error(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test setup of unifiprotect entry with generic error."""
|
||||
|
||||
mock_entry.api.get_nvr = AsyncMock(side_effect=NvrError)
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY
|
||||
assert not mock_entry.api.update.called
|
||||
|
||||
|
||||
async def test_setup_failed_auth(hass: HomeAssistant, mock_entry: MockEntityFixture):
|
||||
"""Test setup of unifiprotect entry with unauthorized error."""
|
||||
|
||||
mock_entry.api.get_nvr = AsyncMock(side_effect=NotAuthorized)
|
||||
|
||||
await hass.config_entries.async_setup(mock_entry.entry.entry_id)
|
||||
assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR
|
||||
assert not mock_entry.api.update.called
|
Loading…
Reference in New Issue