Update uiprotect to 3.1.1 (#120173)
This commit is contained in:
parent
57e615aa36
commit
ea0c93e3db
11 changed files with 123 additions and 87 deletions
|
@ -6,6 +6,7 @@ from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiohttp.client_exceptions import ServerDisconnectedError
|
from aiohttp.client_exceptions import ServerDisconnectedError
|
||||||
|
from uiprotect.api import DEVICE_UPDATE_INTERVAL
|
||||||
from uiprotect.data import Bootstrap
|
from uiprotect.data import Bootstrap
|
||||||
from uiprotect.data.types import FirmwareReleaseChannel
|
from uiprotect.data.types import FirmwareReleaseChannel
|
||||||
from uiprotect.exceptions import ClientError, NotAuthorized
|
from uiprotect.exceptions import ClientError, NotAuthorized
|
||||||
|
@ -29,7 +30,6 @@ from homeassistant.helpers.typing import ConfigType
|
||||||
from .const import (
|
from .const import (
|
||||||
AUTH_RETRIES,
|
AUTH_RETRIES,
|
||||||
CONF_ALLOW_EA,
|
CONF_ALLOW_EA,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
|
||||||
DEVICES_THAT_ADOPT,
|
DEVICES_THAT_ADOPT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
MIN_REQUIRED_PROTECT_V,
|
MIN_REQUIRED_PROTECT_V,
|
||||||
|
@ -49,7 +49,7 @@ from .views import ThumbnailProxyView, VideoProxyView
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL)
|
SCAN_INTERVAL = timedelta(seconds=DEVICE_UPDATE_INTERVAL)
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
@ -70,11 +70,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
|
||||||
"""Set up the UniFi Protect config entries."""
|
"""Set up the UniFi Protect config entries."""
|
||||||
protect = async_create_api_client(hass, entry)
|
protect = async_create_api_client(hass, entry)
|
||||||
_LOGGER.debug("Connect to UniFi Protect")
|
_LOGGER.debug("Connect to UniFi Protect")
|
||||||
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bootstrap = await protect.get_bootstrap()
|
await protect.update()
|
||||||
nvr_info = bootstrap.nvr
|
|
||||||
except NotAuthorized as err:
|
except NotAuthorized as err:
|
||||||
retry_key = f"{entry.entry_id}_auth"
|
retry_key = f"{entry.entry_id}_auth"
|
||||||
retries = hass.data.setdefault(DOMAIN, {}).get(retry_key, 0)
|
retries = hass.data.setdefault(DOMAIN, {}).get(retry_key, 0)
|
||||||
|
@ -86,6 +84,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
|
||||||
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
|
except (TimeoutError, ClientError, ServerDisconnectedError) as err:
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry)
|
||||||
|
bootstrap = protect.bootstrap
|
||||||
|
nvr_info = bootstrap.nvr
|
||||||
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
|
auth_user = bootstrap.users.get(bootstrap.auth_user_id)
|
||||||
if auth_user and auth_user.cloud_account:
|
if auth_user and auth_user.cloud_account:
|
||||||
ir.async_create_issue(
|
ir.async_create_issue(
|
||||||
|
@ -169,11 +170,7 @@ async def _async_setup_entry(
|
||||||
bootstrap: Bootstrap,
|
bootstrap: Bootstrap,
|
||||||
) -> None:
|
) -> None:
|
||||||
await async_migrate_data(hass, entry, data_service.api, bootstrap)
|
await async_migrate_data(hass, entry, data_service.api, bootstrap)
|
||||||
|
data_service.async_setup()
|
||||||
await data_service.async_setup()
|
|
||||||
if not data_service.last_update_success:
|
|
||||||
raise ConfigEntryNotReady
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
hass.http.register_view(ThumbnailProxyView(hass))
|
hass.http.register_view(ThumbnailProxyView(hass))
|
||||||
hass.http.register_view(VideoProxyView(hass))
|
hass.http.register_view(VideoProxyView(hass))
|
||||||
|
|
|
@ -691,6 +691,7 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
|
||||||
super()._async_update_device_from_protect(device)
|
super()._async_update_device_from_protect(device)
|
||||||
slot = self._disk.slot
|
slot = self._disk.slot
|
||||||
self._attr_available = False
|
self._attr_available = False
|
||||||
|
available = self.data.last_update_success
|
||||||
|
|
||||||
# should not be possible since it would require user to
|
# should not be possible since it would require user to
|
||||||
# _downgrade_ to make ustorage disppear
|
# _downgrade_ to make ustorage disppear
|
||||||
|
@ -698,7 +699,7 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity):
|
||||||
for disk in self.device.system_info.ustorage.disks:
|
for disk in self.device.system_info.ustorage.disks:
|
||||||
if disk.slot == slot:
|
if disk.slot == slot:
|
||||||
self._disk = disk
|
self._disk = disk
|
||||||
self._attr_available = True
|
self._attr_available = available
|
||||||
break
|
break
|
||||||
|
|
||||||
self._attr_is_on = not self._disk.is_healthy
|
self._attr_is_on = not self._disk.is_healthy
|
||||||
|
|
|
@ -5,9 +5,9 @@ from uiprotect.data import ModelType, Version
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
|
|
||||||
DOMAIN = "unifiprotect"
|
DOMAIN = "unifiprotect"
|
||||||
# some UniFi OS consoles have an unknown rate limit on auth
|
# If rate limit for 4.x or later a 429 is returned
|
||||||
# if rate limit is triggered a 401 is returned
|
# so we can use a lower value
|
||||||
AUTH_RETRIES = 11 # ~12 hours of retries with the last waiting ~6 hours
|
AUTH_RETRIES = 2
|
||||||
|
|
||||||
ATTR_EVENT_SCORE = "event_score"
|
ATTR_EVENT_SCORE = "event_score"
|
||||||
ATTR_EVENT_ID = "event_id"
|
ATTR_EVENT_ID = "event_id"
|
||||||
|
@ -35,7 +35,6 @@ CONFIG_OPTIONS = [
|
||||||
DEFAULT_PORT = 443
|
DEFAULT_PORT = 443
|
||||||
DEFAULT_ATTRIBUTION = "Powered by UniFi Protect Server"
|
DEFAULT_ATTRIBUTION = "Powered by UniFi Protect Server"
|
||||||
DEFAULT_BRAND = "Ubiquiti"
|
DEFAULT_BRAND = "Ubiquiti"
|
||||||
DEFAULT_SCAN_INTERVAL = 60
|
|
||||||
DEFAULT_VERIFY_SSL = False
|
DEFAULT_VERIFY_SSL = False
|
||||||
DEFAULT_MAX_MEDIA = 1000
|
DEFAULT_MAX_MEDIA = 1000
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@ from typing_extensions import Generator
|
||||||
from uiprotect import ProtectApiClient
|
from uiprotect import ProtectApiClient
|
||||||
from uiprotect.data import (
|
from uiprotect.data import (
|
||||||
NVR,
|
NVR,
|
||||||
Bootstrap,
|
|
||||||
Camera,
|
Camera,
|
||||||
Event,
|
Event,
|
||||||
EventType,
|
EventType,
|
||||||
|
@ -23,6 +22,7 @@ from uiprotect.data import (
|
||||||
)
|
)
|
||||||
from uiprotect.exceptions import ClientError, NotAuthorized
|
from uiprotect.exceptions import ClientError, NotAuthorized
|
||||||
from uiprotect.utils import log_event
|
from uiprotect.utils import log_event
|
||||||
|
from uiprotect.websocket import WebsocketState
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
@ -83,8 +83,7 @@ class ProtectData:
|
||||||
str, set[Callable[[ProtectDeviceType], None]]
|
str, set[Callable[[ProtectDeviceType], None]]
|
||||||
] = defaultdict(set)
|
] = defaultdict(set)
|
||||||
self._pending_camera_ids: set[str] = set()
|
self._pending_camera_ids: set[str] = set()
|
||||||
self._unsub_interval: CALLBACK_TYPE | None = None
|
self._unsubs: list[CALLBACK_TYPE] = []
|
||||||
self._unsub_websocket: CALLBACK_TYPE | None = None
|
|
||||||
self._auth_failures = 0
|
self._auth_failures = 0
|
||||||
self.last_update_success = False
|
self.last_update_success = False
|
||||||
self.api = protect
|
self.api = protect
|
||||||
|
@ -115,11 +114,9 @@ class ProtectData:
|
||||||
self, device_types: Iterable[ModelType], ignore_unadopted: bool = True
|
self, device_types: Iterable[ModelType], ignore_unadopted: bool = True
|
||||||
) -> Generator[ProtectAdoptableDeviceModel]:
|
) -> Generator[ProtectAdoptableDeviceModel]:
|
||||||
"""Get all devices matching types."""
|
"""Get all devices matching types."""
|
||||||
|
bootstrap = self.api.bootstrap
|
||||||
for device_type in device_types:
|
for device_type in device_types:
|
||||||
devices = async_get_devices_by_type(
|
for device in async_get_devices_by_type(bootstrap, device_type).values():
|
||||||
self.api.bootstrap, device_type
|
|
||||||
).values()
|
|
||||||
for device in devices:
|
|
||||||
if ignore_unadopted and not device.is_adopted_by_us:
|
if ignore_unadopted and not device.is_adopted_by_us:
|
||||||
continue
|
continue
|
||||||
yield device
|
yield device
|
||||||
|
@ -130,33 +127,61 @@ class ProtectData:
|
||||||
Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted)
|
Generator[Camera], self.get_by_types({ModelType.CAMERA}, ignore_unadopted)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_setup(self) -> None:
|
@callback
|
||||||
|
def async_setup(self) -> None:
|
||||||
"""Subscribe and do the refresh."""
|
"""Subscribe and do the refresh."""
|
||||||
self._unsub_websocket = self.api.subscribe_websocket(
|
self.last_update_success = True
|
||||||
self._async_process_ws_message
|
self._async_update_change(True, force_update=True)
|
||||||
)
|
api = self.api
|
||||||
await self.async_refresh()
|
self._unsubs = [
|
||||||
|
api.subscribe_websocket_state(self._async_websocket_state_changed),
|
||||||
|
api.subscribe_websocket(self._async_process_ws_message),
|
||||||
|
async_track_time_interval(
|
||||||
|
self._hass, self._async_poll, self._update_interval
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_websocket_state_changed(self, state: WebsocketState) -> None:
|
||||||
|
"""Handle a change in the websocket state."""
|
||||||
|
self._async_update_change(state is WebsocketState.CONNECTED)
|
||||||
|
|
||||||
|
def _async_update_change(
|
||||||
|
self,
|
||||||
|
success: bool,
|
||||||
|
force_update: bool = False,
|
||||||
|
exception: Exception | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Process a change in update success."""
|
||||||
|
was_success = self.last_update_success
|
||||||
|
self.last_update_success = success
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
level = logging.ERROR if was_success else logging.DEBUG
|
||||||
|
title = self._entry.title
|
||||||
|
_LOGGER.log(level, "%s: Connection lost", title, exc_info=exception)
|
||||||
|
self._async_process_updates()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._auth_failures = 0
|
||||||
|
if not was_success:
|
||||||
|
_LOGGER.info("%s: Connection restored", self._entry.title)
|
||||||
|
self._async_process_updates()
|
||||||
|
elif force_update:
|
||||||
|
self._async_process_updates()
|
||||||
|
|
||||||
async def async_stop(self, *args: Any) -> None:
|
async def async_stop(self, *args: Any) -> None:
|
||||||
"""Stop processing data."""
|
"""Stop processing data."""
|
||||||
if self._unsub_websocket:
|
for unsub in self._unsubs:
|
||||||
self._unsub_websocket()
|
unsub()
|
||||||
self._unsub_websocket = None
|
self._unsubs.clear()
|
||||||
if self._unsub_interval:
|
|
||||||
self._unsub_interval()
|
|
||||||
self._unsub_interval = None
|
|
||||||
await self.api.async_disconnect_ws()
|
await self.api.async_disconnect_ws()
|
||||||
|
|
||||||
async def async_refresh(self, *_: Any, force: bool = False) -> None:
|
async def async_refresh(self) -> None:
|
||||||
"""Update the data."""
|
"""Update the data."""
|
||||||
|
|
||||||
# if last update was failure, force until success
|
|
||||||
if not self.last_update_success:
|
|
||||||
force = True
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updates = await self.api.update(force=force)
|
await self.api.update()
|
||||||
except NotAuthorized:
|
except NotAuthorized as ex:
|
||||||
if self._auth_failures < AUTH_RETRIES:
|
if self._auth_failures < AUTH_RETRIES:
|
||||||
_LOGGER.exception("Auth error while updating")
|
_LOGGER.exception("Auth error while updating")
|
||||||
self._auth_failures += 1
|
self._auth_failures += 1
|
||||||
|
@ -164,17 +189,11 @@ class ProtectData:
|
||||||
await self.async_stop()
|
await self.async_stop()
|
||||||
_LOGGER.exception("Reauthentication required")
|
_LOGGER.exception("Reauthentication required")
|
||||||
self._entry.async_start_reauth(self._hass)
|
self._entry.async_start_reauth(self._hass)
|
||||||
self.last_update_success = False
|
self._async_update_change(False, exception=ex)
|
||||||
except ClientError:
|
except ClientError as ex:
|
||||||
if self.last_update_success:
|
self._async_update_change(False, exception=ex)
|
||||||
_LOGGER.exception("Error while updating")
|
|
||||||
self.last_update_success = False
|
|
||||||
# manually trigger update to mark entities unavailable
|
|
||||||
self._async_process_updates(self.api.bootstrap)
|
|
||||||
else:
|
else:
|
||||||
self.last_update_success = True
|
self._async_update_change(True, force_update=True)
|
||||||
self._auth_failures = 0
|
|
||||||
self._async_process_updates(updates)
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_pending_camera_id(self, camera_id: str) -> None:
|
def async_add_pending_camera_id(self, camera_id: str) -> None:
|
||||||
|
@ -184,7 +203,6 @@ class ProtectData:
|
||||||
initialized yet. Will cause Websocket code to check for channels to be
|
initialized yet. Will cause Websocket code to check for channels to be
|
||||||
initialized for the camera and issue a dispatch once they do.
|
initialized for the camera and issue a dispatch once they do.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self._pending_camera_ids.add(camera_id)
|
self._pending_camera_ids.add(camera_id)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
@ -278,25 +296,15 @@ class ProtectData:
|
||||||
self._async_update_device(new_obj, message.changed_data)
|
self._async_update_device(new_obj, message.changed_data)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_process_updates(self, updates: Bootstrap | None) -> None:
|
def _async_process_updates(self) -> None:
|
||||||
"""Process update from the protect data."""
|
"""Process update from the protect data."""
|
||||||
|
|
||||||
# Websocket connected, use data from it
|
|
||||||
if updates is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
self._async_signal_device_update(self.api.bootstrap.nvr)
|
self._async_signal_device_update(self.api.bootstrap.nvr)
|
||||||
for device in self.get_by_types(DEVICES_THAT_ADOPT):
|
for device in self.get_by_types(DEVICES_THAT_ADOPT):
|
||||||
self._async_signal_device_update(device)
|
self._async_signal_device_update(device)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_poll(self, now: datetime) -> None:
|
def _async_poll(self, now: datetime) -> None:
|
||||||
"""Poll the Protect API.
|
"""Poll the Protect API."""
|
||||||
|
|
||||||
If the websocket is connected, most of the time
|
|
||||||
this will be a no-op. If the websocket is disconnected,
|
|
||||||
this will trigger a reconnect and refresh.
|
|
||||||
"""
|
|
||||||
self._entry.async_create_background_task(
|
self._entry.async_create_background_task(
|
||||||
self._hass,
|
self._hass,
|
||||||
self.async_refresh(),
|
self.async_refresh(),
|
||||||
|
@ -309,10 +317,6 @@ class ProtectData:
|
||||||
self, mac: str, update_callback: Callable[[ProtectDeviceType], None]
|
self, mac: str, update_callback: Callable[[ProtectDeviceType], None]
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Add an callback subscriber."""
|
"""Add an callback subscriber."""
|
||||||
if not self._subscriptions:
|
|
||||||
self._unsub_interval = async_track_time_interval(
|
|
||||||
self._hass, self._async_poll, self._update_interval
|
|
||||||
)
|
|
||||||
self._subscriptions[mac].add(update_callback)
|
self._subscriptions[mac].add(update_callback)
|
||||||
return partial(self._async_unsubscribe, mac, update_callback)
|
return partial(self._async_unsubscribe, mac, update_callback)
|
||||||
|
|
||||||
|
@ -324,9 +328,6 @@ class ProtectData:
|
||||||
self._subscriptions[mac].remove(update_callback)
|
self._subscriptions[mac].remove(update_callback)
|
||||||
if not self._subscriptions[mac]:
|
if not self._subscriptions[mac]:
|
||||||
del self._subscriptions[mac]
|
del self._subscriptions[mac]
|
||||||
if not self._subscriptions and self._unsub_interval:
|
|
||||||
self._unsub_interval()
|
|
||||||
self._unsub_interval = None
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_signal_device_update(self, device: ProtectDeviceType) -> None:
|
def _async_signal_device_update(self, device: ProtectDeviceType) -> None:
|
||||||
|
|
|
@ -40,7 +40,7 @@
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["uiprotect", "unifi_discovery"],
|
"loggers": ["uiprotect", "unifi_discovery"],
|
||||||
"requirements": ["uiprotect==1.20.0", "unifi-discovery==1.1.8"],
|
"requirements": ["uiprotect==3.1.1", "unifi-discovery==1.1.8"],
|
||||||
"ssdp": [
|
"ssdp": [
|
||||||
{
|
{
|
||||||
"manufacturer": "Ubiquiti Networks",
|
"manufacturer": "Ubiquiti Networks",
|
||||||
|
|
|
@ -2794,7 +2794,7 @@ twitchAPI==4.0.0
|
||||||
uasiren==0.0.1
|
uasiren==0.0.1
|
||||||
|
|
||||||
# homeassistant.components.unifiprotect
|
# homeassistant.components.unifiprotect
|
||||||
uiprotect==1.20.0
|
uiprotect==3.1.1
|
||||||
|
|
||||||
# homeassistant.components.landisgyr_heat_meter
|
# homeassistant.components.landisgyr_heat_meter
|
||||||
ultraheat-api==0.5.7
|
ultraheat-api==0.5.7
|
||||||
|
|
|
@ -2174,7 +2174,7 @@ twitchAPI==4.0.0
|
||||||
uasiren==0.0.1
|
uasiren==0.0.1
|
||||||
|
|
||||||
# homeassistant.components.unifiprotect
|
# homeassistant.components.unifiprotect
|
||||||
uiprotect==1.20.0
|
uiprotect==3.1.1
|
||||||
|
|
||||||
# homeassistant.components.landisgyr_heat_meter
|
# homeassistant.components.landisgyr_heat_meter
|
||||||
ultraheat-api==0.5.7
|
ultraheat-api==0.5.7
|
||||||
|
|
|
@ -29,6 +29,7 @@ from uiprotect.data import (
|
||||||
Viewer,
|
Viewer,
|
||||||
WSSubscriptionMessage,
|
WSSubscriptionMessage,
|
||||||
)
|
)
|
||||||
|
from uiprotect.websocket import WebsocketState
|
||||||
|
|
||||||
from homeassistant.components.unifiprotect.const import DOMAIN
|
from homeassistant.components.unifiprotect.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -148,7 +149,14 @@ def mock_entry(
|
||||||
ufp.ws_subscription = ws_callback
|
ufp.ws_subscription = ws_callback
|
||||||
return Mock()
|
return Mock()
|
||||||
|
|
||||||
|
def subscribe_websocket_state(
|
||||||
|
ws_state_subscription: Callable[[WebsocketState], None],
|
||||||
|
) -> Any:
|
||||||
|
ufp.ws_state_subscription = ws_state_subscription
|
||||||
|
return Mock()
|
||||||
|
|
||||||
ufp_client.subscribe_websocket = subscribe
|
ufp_client.subscribe_websocket = subscribe
|
||||||
|
ufp_client.subscribe_websocket_state = subscribe_websocket_state
|
||||||
yield ufp
|
yield ufp
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,10 +4,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
from uiprotect.api import DEVICE_UPDATE_INTERVAL
|
||||||
from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType
|
from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType
|
||||||
from uiprotect.exceptions import NvrError
|
from uiprotect.exceptions import NvrError
|
||||||
|
from uiprotect.websocket import WebsocketState
|
||||||
|
|
||||||
from homeassistant.components.camera import (
|
from homeassistant.components.camera import (
|
||||||
|
STATE_IDLE,
|
||||||
CameraEntityFeature,
|
CameraEntityFeature,
|
||||||
async_get_image,
|
async_get_image,
|
||||||
async_get_stream_source,
|
async_get_stream_source,
|
||||||
|
@ -19,13 +22,13 @@ from homeassistant.components.unifiprotect.const import (
|
||||||
ATTR_HEIGHT,
|
ATTR_HEIGHT,
|
||||||
ATTR_WIDTH,
|
ATTR_WIDTH,
|
||||||
DEFAULT_ATTRIBUTION,
|
DEFAULT_ATTRIBUTION,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.unifiprotect.utils import get_camera_base_name
|
from homeassistant.components.unifiprotect.utils import get_camera_base_name
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
ATTR_ATTRIBUTION,
|
ATTR_ATTRIBUTION,
|
||||||
ATTR_ENTITY_ID,
|
ATTR_ENTITY_ID,
|
||||||
ATTR_SUPPORTED_FEATURES,
|
ATTR_SUPPORTED_FEATURES,
|
||||||
|
STATE_UNAVAILABLE,
|
||||||
Platform,
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
@ -377,7 +380,7 @@ async def test_camera_interval_update(
|
||||||
|
|
||||||
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
ufp.api.bootstrap.cameras = {new_camera.id: new_camera}
|
||||||
ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap)
|
ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap)
|
||||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
await time_changed(hass, DEVICE_UPDATE_INTERVAL)
|
||||||
|
|
||||||
state = hass.states.get(entity_id)
|
state = hass.states.get(entity_id)
|
||||||
assert state and state.state == "recording"
|
assert state and state.state == "recording"
|
||||||
|
@ -397,19 +400,46 @@ async def test_camera_bad_interval_update(
|
||||||
|
|
||||||
# update fails
|
# update fails
|
||||||
ufp.api.update = AsyncMock(side_effect=NvrError)
|
ufp.api.update = AsyncMock(side_effect=NvrError)
|
||||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
await time_changed(hass, DEVICE_UPDATE_INTERVAL)
|
||||||
|
|
||||||
state = hass.states.get(entity_id)
|
state = hass.states.get(entity_id)
|
||||||
assert state and state.state == "unavailable"
|
assert state and state.state == "unavailable"
|
||||||
|
|
||||||
# next update succeeds
|
# next update succeeds
|
||||||
ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap)
|
ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap)
|
||||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
await time_changed(hass, DEVICE_UPDATE_INTERVAL)
|
||||||
|
|
||||||
state = hass.states.get(entity_id)
|
state = hass.states.get(entity_id)
|
||||||
assert state and state.state == "idle"
|
assert state and state.state == "idle"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_camera_websocket_disconnected(
|
||||||
|
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
|
||||||
|
) -> None:
|
||||||
|
"""Test the websocket gets disconnected and reconnected."""
|
||||||
|
|
||||||
|
await init_entry(hass, ufp, [camera])
|
||||||
|
assert_entity_counts(hass, Platform.CAMERA, 2, 1)
|
||||||
|
entity_id = "camera.test_camera_high_resolution_channel"
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state and state.state == STATE_IDLE
|
||||||
|
|
||||||
|
# websocket disconnects
|
||||||
|
ufp.ws_state_subscription(WebsocketState.DISCONNECTED)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state and state.state == STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
# websocket reconnects
|
||||||
|
ufp.ws_state_subscription(WebsocketState.CONNECTED)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state and state.state == STATE_IDLE
|
||||||
|
|
||||||
|
|
||||||
async def test_camera_ws_update(
|
async def test_camera_ws_update(
|
||||||
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
|
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
|
@ -5,12 +5,12 @@ from __future__ import annotations
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
from uiprotect import NotAuthorized, NvrError, ProtectApiClient
|
from uiprotect import NotAuthorized, NvrError, ProtectApiClient
|
||||||
|
from uiprotect.api import DEVICE_UPDATE_INTERVAL
|
||||||
from uiprotect.data import NVR, Bootstrap, CloudAccount, Light
|
from uiprotect.data import NVR, Bootstrap, CloudAccount, Light
|
||||||
|
|
||||||
from homeassistant.components.unifiprotect.const import (
|
from homeassistant.components.unifiprotect.const import (
|
||||||
AUTH_RETRIES,
|
AUTH_RETRIES,
|
||||||
CONF_DISABLE_RTSP,
|
CONF_DISABLE_RTSP,
|
||||||
DEFAULT_SCAN_INTERVAL,
|
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||||
|
@ -116,12 +116,12 @@ async def test_setup_too_old(
|
||||||
|
|
||||||
old_bootstrap = ufp.api.bootstrap.copy()
|
old_bootstrap = ufp.api.bootstrap.copy()
|
||||||
old_bootstrap.nvr = old_nvr
|
old_bootstrap.nvr = old_nvr
|
||||||
ufp.api.get_bootstrap.return_value = old_bootstrap
|
ufp.api.update.return_value = old_bootstrap
|
||||||
|
ufp.api.bootstrap = old_bootstrap
|
||||||
|
|
||||||
await hass.config_entries.async_setup(ufp.entry.entry_id)
|
await hass.config_entries.async_setup(ufp.entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
|
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
|
||||||
assert not ufp.api.update.called
|
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_cloud_account(
|
async def test_setup_cloud_account(
|
||||||
|
@ -179,13 +179,13 @@ async def test_setup_failed_update_reauth(
|
||||||
# to verify it is not transient
|
# to verify it is not transient
|
||||||
ufp.api.update = AsyncMock(side_effect=NotAuthorized)
|
ufp.api.update = AsyncMock(side_effect=NotAuthorized)
|
||||||
for _ in range(AUTH_RETRIES):
|
for _ in range(AUTH_RETRIES):
|
||||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
await time_changed(hass, DEVICE_UPDATE_INTERVAL)
|
||||||
assert len(hass.config_entries.flow._progress) == 0
|
assert len(hass.config_entries.flow._progress) == 0
|
||||||
|
|
||||||
assert ufp.api.update.call_count == AUTH_RETRIES
|
assert ufp.api.update.call_count == AUTH_RETRIES
|
||||||
assert ufp.entry.state is ConfigEntryState.LOADED
|
assert ufp.entry.state is ConfigEntryState.LOADED
|
||||||
|
|
||||||
await time_changed(hass, DEFAULT_SCAN_INTERVAL)
|
await time_changed(hass, DEVICE_UPDATE_INTERVAL)
|
||||||
assert ufp.api.update.call_count == AUTH_RETRIES + 1
|
assert ufp.api.update.call_count == AUTH_RETRIES + 1
|
||||||
assert len(hass.config_entries.flow._progress) == 1
|
assert len(hass.config_entries.flow._progress) == 1
|
||||||
|
|
||||||
|
@ -193,18 +193,17 @@ async def test_setup_failed_update_reauth(
|
||||||
async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
|
async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
|
||||||
"""Test setup of unifiprotect entry with generic error."""
|
"""Test setup of unifiprotect entry with generic error."""
|
||||||
|
|
||||||
ufp.api.get_bootstrap = AsyncMock(side_effect=NvrError)
|
ufp.api.update = AsyncMock(side_effect=NvrError)
|
||||||
|
|
||||||
await hass.config_entries.async_setup(ufp.entry.entry_id)
|
await hass.config_entries.async_setup(ufp.entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert ufp.entry.state is ConfigEntryState.SETUP_RETRY
|
assert ufp.entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
assert not ufp.api.update.called
|
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
|
async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture) -> None:
|
||||||
"""Test setup of unifiprotect entry with unauthorized error after multiple retries."""
|
"""Test setup of unifiprotect entry with unauthorized error after multiple retries."""
|
||||||
|
|
||||||
ufp.api.get_bootstrap = AsyncMock(side_effect=NotAuthorized)
|
ufp.api.update = AsyncMock(side_effect=NotAuthorized)
|
||||||
|
|
||||||
await hass.config_entries.async_setup(ufp.entry.entry_id)
|
await hass.config_entries.async_setup(ufp.entry.entry_id)
|
||||||
assert ufp.entry.state is ConfigEntryState.SETUP_RETRY
|
assert ufp.entry.state is ConfigEntryState.SETUP_RETRY
|
||||||
|
@ -215,7 +214,6 @@ async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture) -> No
|
||||||
|
|
||||||
await hass.config_entries.async_reload(ufp.entry.entry_id)
|
await hass.config_entries.async_reload(ufp.entry.entry_id)
|
||||||
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
|
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
|
||||||
assert not ufp.api.update.called
|
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_starts_discovery(
|
async def test_setup_starts_discovery(
|
||||||
|
|
|
@ -20,6 +20,7 @@ from uiprotect.data import (
|
||||||
)
|
)
|
||||||
from uiprotect.data.bootstrap import ProtectDeviceRef
|
from uiprotect.data.bootstrap import ProtectDeviceRef
|
||||||
from uiprotect.test_util.anonymize import random_hex
|
from uiprotect.test_util.anonymize import random_hex
|
||||||
|
from uiprotect.websocket import WebsocketState
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant, split_entity_id
|
from homeassistant.core import HomeAssistant, split_entity_id
|
||||||
|
@ -38,6 +39,7 @@ class MockUFPFixture:
|
||||||
entry: MockConfigEntry
|
entry: MockConfigEntry
|
||||||
api: ProtectApiClient
|
api: ProtectApiClient
|
||||||
ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None
|
ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None
|
||||||
|
ws_state_subscription: Callable[[WebsocketState], None] | None = None
|
||||||
|
|
||||||
def ws_msg(self, msg: WSSubscriptionMessage) -> Any:
|
def ws_msg(self, msg: WSSubscriptionMessage) -> Any:
|
||||||
"""Emit WS message for testing."""
|
"""Emit WS message for testing."""
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue