Add unifiprotect integration ()

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/62811/head
Christopher Bailey 2021-12-26 01:12:57 -05:00 committed by GitHub
parent c54439ef42
commit e982e7403a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 2258 additions and 0 deletions

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}
),
)

View File

@ -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",
]

View File

@ -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()

View File

@ -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
)
)

View File

@ -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"
}

View File

@ -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"
}
}
}
}
}

View File

@ -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"
}
}
}
}

View File

@ -323,6 +323,7 @@ FLOWS = [
"twilio",
"twinkly",
"unifi",
"unifiprotect",
"upb",
"upcloud",
"upnp",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the UniFi Protect integration."""

View File

@ -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()

View File

@ -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"
}

View File

@ -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"

View File

@ -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,
}

View File

@ -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