"""Test the UniFi Protect camera platform.""" from __future__ import annotations from copy import copy from unittest.mock import AsyncMock, Mock from pyunifiprotect.data import Camera as ProtectCamera from pyunifiprotect.data.devices import CameraChannel 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, ) 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 def validate_default_camera_entity( hass: HomeAssistant, camera: ProtectCamera, channel_id: int, ) -> str: """Validate a camera entity.""" channel = camera.channels[channel_id] entity_name = f"{camera.name} {channel.name}" unique_id = f"{camera.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: ProtectCamera, channel_id: int, ) -> str: """Validate a disabled RTSPS camera entity.""" channel = camera.channels[channel_id] entity_name = f"{camera.name} {channel.name}" unique_id = f"{camera.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: ProtectCamera, channel_id: int, ) -> str: """Validate a disabled RTSP camera entity.""" channel = camera.channels[channel_id] entity_name = f"{camera.name} {channel.name} Insecure" unique_id = f"{camera.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: ProtectCamera, channel_id: int, entity_id: str, features: int = SUPPORT_STREAM, ): """Validate a camera's state.""" channel = camera.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: ProtectCamera, channel_id: int, entity_id: str, features: int = SUPPORT_STREAM, ): """Validate a camera's state.""" channel = camera.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: ProtectCamera, channel_id: int, entity_id: str, features: int = SUPPORT_STREAM, ): """Validate a camera's state.""" channel = camera.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, } 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, 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", {}) 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.""" 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.""" 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) 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) 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.""" 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.""" 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"