core/tests/components/unifiprotect/test_camera.py

493 lines
16 KiB
Python
Raw Normal View History

"""Test the UniFi Protect camera platform."""
# pylint: disable=protected-access
from __future__ import annotations
from copy import copy
from unittest.mock import AsyncMock, Mock, patch
import pytest
from pyunifiprotect.data import Camera as ProtectCamera
from pyunifiprotect.data.devices import CameraChannel
from pyunifiprotect.data.types import StateType
from pyunifiprotect.exceptions import NvrError
from homeassistant.components.camera import (
SUPPORT_STREAM,
Camera,
async_get_image,
async_get_stream_source,
)
from homeassistant.components.unifiprotect.const import (
ATTR_BITRATE,
ATTR_CHANNEL_ID,
ATTR_FPS,
ATTR_HEIGHT,
ATTR_WIDTH,
DEFAULT_ATTRIBUTION,
DEFAULT_SCAN_INTERVAL,
)
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from .conftest import MockEntityFixture, enable_entity, time_changed
@pytest.fixture(name="camera")
async def camera_fixture(
hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera
):
"""Fixture for a single camera, no extra setup."""
camera_obj = mock_camera.copy(deep=True)
camera_obj._api = mock_entry.api
camera_obj.channels[0]._api = mock_entry.api
camera_obj.channels[1]._api = mock_entry.api
camera_obj.channels[2]._api = mock_entry.api
camera_obj.name = "Test Camera"
camera_obj.channels[0].is_rtsp_enabled = True
camera_obj.channels[0].name = "High"
camera_obj.channels[1].is_rtsp_enabled = False
camera_obj.channels[2].is_rtsp_enabled = False
mock_entry.api.bootstrap.cameras = {
camera_obj.id: camera_obj,
}
with patch("homeassistant.components.unifiprotect.PLATFORMS", [Platform.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_obj, "camera.test_camera_high")
def validate_default_camera_entity(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
) -> str:
"""Validate a camera entity."""
channel = camera_obj.channels[channel_id]
entity_name = f"{camera_obj.name} {channel.name}"
unique_id = f"{camera_obj.id}_{channel.id}"
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 False
assert entity.unique_id == unique_id
return entity_id
def validate_rtsps_camera_entity(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
) -> str:
"""Validate a disabled RTSPS camera entity."""
channel = camera_obj.channels[channel_id]
entity_name = f"{camera_obj.name} {channel.name}"
unique_id = f"{camera_obj.id}_{channel.id}"
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 True
assert entity.unique_id == unique_id
return entity_id
def validate_rtsp_camera_entity(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
) -> str:
"""Validate a disabled RTSP camera entity."""
channel = camera_obj.channels[channel_id]
entity_name = f"{camera_obj.name} {channel.name} Insecure"
unique_id = f"{camera_obj.id}_{channel.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 True
assert entity.unique_id == unique_id
return entity_id
def validate_common_camera_state(
hass: HomeAssistant,
channel: CameraChannel,
entity_id: str,
features: int = SUPPORT_STREAM,
):
"""Validate state that is common to all camera entity, regradless of type."""
entity_state = hass.states.get(entity_id)
assert entity_state
assert entity_state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
assert entity_state.attributes[ATTR_SUPPORTED_FEATURES] == features
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 validate_rtsps_camera_state(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
entity_id: str,
features: int = SUPPORT_STREAM,
):
"""Validate a camera's state."""
channel = camera_obj.channels[channel_id]
assert await async_get_stream_source(hass, entity_id) == channel.rtsps_url
validate_common_camera_state(hass, channel, entity_id, features)
async def validate_rtsp_camera_state(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
entity_id: str,
features: int = SUPPORT_STREAM,
):
"""Validate a camera's state."""
channel = camera_obj.channels[channel_id]
assert await async_get_stream_source(hass, entity_id) == channel.rtsp_url
validate_common_camera_state(hass, channel, entity_id, features)
async def validate_no_stream_camera_state(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
entity_id: str,
features: int = SUPPORT_STREAM,
):
"""Validate a camera's state."""
channel = camera_obj.channels[channel_id]
assert await async_get_stream_source(hass, entity_id) is None
validate_common_camera_state(hass, channel, entity_id, features)
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_medium_only = mock_camera.copy(deep=True)
camera_medium_only._api = mock_entry.api
camera_medium_only.channels[0]._api = mock_entry.api
camera_medium_only.channels[1]._api = mock_entry.api
camera_medium_only.channels[2]._api = mock_entry.api
camera_medium_only.name = "Test Camera 2"
camera_medium_only.id = "test_medium"
camera_medium_only.channels[0].is_rtsp_enabled = False
camera_medium_only.channels[1].is_rtsp_enabled = True
camera_medium_only.channels[1].name = "Medium"
camera_medium_only.channels[1].rtsp_alias = "test_medium_alias"
camera_medium_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 3"
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 4"
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_medium_only.id: camera_medium_only,
camera_all_channels.id: camera_all_channels,
camera_no_channels.id: camera_no_channels,
}
with patch("homeassistant.components.unifiprotect.PLATFORMS", [Platform.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()) == 4
assert len(entity_registry.entities) == 11
# test camera 1
entity_id = validate_default_camera_entity(hass, camera_high_only, 0)
await validate_rtsps_camera_state(hass, camera_high_only, 0, entity_id)
entity_id = validate_rtsp_camera_entity(hass, camera_high_only, 0)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsp_camera_state(hass, camera_high_only, 0, entity_id)
# test camera 2
entity_id = validate_default_camera_entity(hass, camera_medium_only, 1)
await validate_rtsps_camera_state(hass, camera_medium_only, 1, entity_id)
entity_id = validate_rtsp_camera_entity(hass, camera_medium_only, 1)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsp_camera_state(hass, camera_medium_only, 1, entity_id)
# test camera 3
entity_id = validate_default_camera_entity(hass, camera_all_channels, 0)
await validate_rtsps_camera_state(hass, camera_all_channels, 0, entity_id)
entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 0)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsp_camera_state(hass, camera_all_channels, 0, entity_id)
entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 1)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsps_camera_state(hass, camera_all_channels, 1, entity_id)
entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 1)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsp_camera_state(hass, camera_all_channels, 1, entity_id)
entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 2)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsps_camera_state(hass, camera_all_channels, 2, entity_id)
entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 2)
await enable_entity(hass, mock_entry.entry.entry_id, entity_id)
await validate_rtsp_camera_state(hass, camera_all_channels, 2, entity_id)
# test camera 4
entity_id = validate_default_camera_entity(hass, camera_no_channels, 0)
await validate_no_stream_camera_state(
hass, camera_no_channels, 0, entity_id, features=0
)
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,
camera: tuple[Camera, str],
):
"""Test retrieving camera image."""
mock_entry.api.get_camera_snapshot = AsyncMock()
await async_get_image(hass, camera[1])
mock_entry.api.get_camera_snapshot.assert_called_once()
async def test_camera_generic_update(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
camera: tuple[ProtectCamera, str],
):
"""Tests generic entity update service."""
assert await async_setup_component(hass, "homeassistant", {})
state = hass.states.get(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: camera[1]},
blocking=True,
)
state = hass.states.get(camera[1])
assert state and state.state == "idle"
async def test_camera_interval_update(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
camera: tuple[ProtectCamera, str],
):
"""Interval updates updates camera entity."""
state = hass.states.get(camera[1])
assert state and state.state == "idle"
new_bootstrap = copy(mock_entry.api.bootstrap)
new_camera = 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(camera[1])
assert state and state.state == "recording"
async def test_camera_bad_interval_update(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
camera: tuple[Camera, str],
):
"""Interval updates marks camera unavailable."""
state = hass.states.get(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)
state = hass.states.get(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)
state = hass.states.get(camera[1])
assert state and state.state == "idle"
async def test_camera_ws_update(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
camera: tuple[ProtectCamera, str],
):
"""WS update updates camera entity."""
state = hass.states.get(camera[1])
assert state and state.state == "idle"
new_bootstrap = copy(mock_entry.api.bootstrap)
new_camera = 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(camera[1])
assert state and state.state == "recording"
async def test_camera_ws_update_offline(
hass: HomeAssistant,
mock_entry: MockEntityFixture,
camera: tuple[ProtectCamera, str],
):
"""WS updates marks camera unavailable."""
state = hass.states.get(camera[1])
assert state and state.state == "idle"
# camera goes offline
new_bootstrap = copy(mock_entry.api.bootstrap)
new_camera = camera[0].copy()
new_camera.state = StateType.DISCONNECTED
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(camera[1])
assert state and state.state == "unavailable"
# camera comes back online
new_camera.state = StateType.CONNECTED
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(camera[1])
assert state and state.state == "idle"