1
0
mirror of https://github.com/home-assistant/core.git synced 2026-07-07 06:46:17 +01:00
Files
core/tests/components/unifiprotect/test_camera.py
T

438 lines
15 KiB
Python

"""Test the UniFi Protect camera platform."""
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from uiprotect.data import AiPort, Camera as ProtectCamera, StateType
from uiprotect.exceptions import ClientError, NotAuthorized
from homeassistant.components.camera import (
CameraEntityFeature,
async_get_image,
async_get_stream_source,
)
from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN
from homeassistant.components.unifiprotect.utils import get_camera_base_name
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from . import patch_ufp_method
from .utils import (
MockUFPFixture,
adopt_devices,
assert_entity_counts,
enable_entity,
init_entry,
remove_entities,
)
def _channel_entity_id(camera_obj: ProtectCamera, channel_id: int) -> str:
"""Return the entity_id for a camera channel."""
channel = camera_obj.channels[channel_id]
base_name = get_camera_base_name(channel)
return f"camera.{camera_obj.name}_{base_name}".replace(" ", "_").lower()
def _assert_entity(
hass: HomeAssistant,
camera_obj: ProtectCamera,
channel_id: int,
*,
enabled: bool,
) -> str:
"""Assert a camera entity exists with the secure unique_id and no insecure twin."""
entity_id = _channel_entity_id(camera_obj, channel_id)
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 == f"{camera_obj.mac}_{camera_obj.channels[channel_id].id}"
# The legacy insecure private-path entity must not exist anymore.
assert (
entity_registry.async_get(f"{entity_id}_insecure".replace(" ", "_").lower())
is None
)
return entity_id
async def test_basic_setup(
hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera
) -> None:
"""One enabled high entity plus disabled medium/low, all secure."""
await init_entry(hass, ufp, [camera_all])
# high (enabled) + medium + low (disabled); no insecure entities
assert_entity_counts(hass, Platform.CAMERA, 3, 1)
high_id = _assert_entity(hass, camera_all, 0, enabled=True)
assert (
await async_get_stream_source(hass, high_id)
== camera_all.channels[0].rtsps_no_srtp_url
)
# medium starts disabled; once enabled it streams from the public API
medium_id = _assert_entity(hass, camera_all, 1, enabled=False)
await enable_entity(hass, ufp.entry.entry_id, medium_id)
assert (
await async_get_stream_source(hass, medium_id)
== camera_all.channels[1].rtsps_no_srtp_url
)
_assert_entity(hass, camera_all, 2, enabled=False)
async def test_doorbell_setup(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: ProtectCamera
) -> None:
"""Doorbell exposes its active qualities; the package channel is just one of them."""
await init_entry(hass, ufp, [doorbell])
# high (enabled) + package (active too, disabled by default); both stream
assert_entity_counts(hass, Platform.CAMERA, 2, 1)
high_id = _assert_entity(hass, doorbell, 0, enabled=True)
assert (
await async_get_stream_source(hass, high_id)
== doorbell.channels[0].rtsps_no_srtp_url
)
# the package channel is not special-cased: it streams like any quality tier
package_id = _assert_entity(hass, doorbell, 3, enabled=False)
await enable_entity(hass, ufp.entry.entry_id, package_id)
assert (
await async_get_stream_source(hass, package_id)
== doorbell.channels[3].rtsps_no_srtp_url
)
async def test_first_active_quality_is_default(
hass: HomeAssistant,
ufp: MockUFPFixture,
camera_all: ProtectCamera,
issue_registry: ir.IssueRegistry,
) -> None:
"""The first active quality is the default; inactive ones get no entity."""
camera_all.channels = [c.model_copy() for c in camera_all.channels]
camera_all.channels[0].is_rtsp_enabled = False # high inactive
camera_all.channels[1].is_rtsp_enabled = True # medium active
camera_all.channels[2].is_rtsp_enabled = False # low inactive
await init_entry(hass, ufp, [camera_all])
# only medium exists, enabled by default, with a working stream
assert_entity_counts(hass, Platform.CAMERA, 1, 1)
medium_id = _assert_entity(hass, camera_all, 1, enabled=True)
assert (
await async_get_stream_source(hass, medium_id)
== camera_all.channels[1].rtsps_no_srtp_url
)
entity_registry = er.async_get(hass)
assert entity_registry.async_get(_channel_entity_id(camera_all, 0)) is None
assert entity_registry.async_get(_channel_entity_id(camera_all, 2)) is None
assert (
issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera_all.id}") is None
)
async def test_no_active_stream(
hass: HomeAssistant,
ufp: MockUFPFixture,
camera: ProtectCamera,
issue_registry: ir.IssueRegistry,
) -> None:
"""A camera with no active stream is exposed for snapshots and raises a repair."""
camera.channels = [c.model_copy() for c in camera.channels]
for channel in camera.channels:
channel.is_rtsp_enabled = False
await init_entry(hass, ufp, [camera])
high_id = _assert_entity(hass, camera, 0, enabled=True)
assert await async_get_stream_source(hass, high_id) is None
state = hass.states.get(high_id)
assert state
assert state.attributes["supported_features"] == CameraEntityFeature(0)
assert (
issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is not None
)
async def test_offline_camera_no_repair(
hass: HomeAssistant,
ufp: MockUFPFixture,
camera: ProtectCamera,
issue_registry: ir.IssueRegistry,
) -> None:
"""An offline camera with no stream gets no repair (offline, not disabled)."""
camera.state = StateType.DISCONNECTED
await init_entry(hass, ufp, [camera])
_assert_entity(hass, camera, 0, enabled=True)
assert issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is None
@pytest.mark.parametrize("ufp_options", [{CONF_DISABLE_RTSP: True}], indirect=True)
async def test_disable_rtsp(
hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera
) -> None:
"""Disabling RTSP globally removes the stream source."""
await init_entry(hass, ufp, [camera_all])
high_id = _assert_entity(hass, camera_all, 0, enabled=True)
assert await async_get_stream_source(hass, high_id) is None
async def test_camera_image(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
) -> None:
"""Main snapshot is fetched from the public API."""
await init_entry(hass, ufp, [camera])
assert_entity_counts(hass, Platform.CAMERA, 1, 1)
ufp.api.get_public_api_camera_snapshot = AsyncMock()
await async_get_image(hass, _channel_entity_id(camera, 0))
ufp.api.get_public_api_camera_snapshot.assert_called_once()
assert ufp.api.get_public_api_camera_snapshot.call_args.kwargs["package"] is False
async def test_package_camera_image(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: ProtectCamera
) -> None:
"""Package snapshot is fetched from the public API with package=True."""
await init_entry(hass, ufp, [doorbell])
assert_entity_counts(hass, Platform.CAMERA, 2, 1)
package_id = _channel_entity_id(doorbell, 3)
await enable_entity(hass, ufp.entry.entry_id, package_id)
ufp.api.get_public_api_camera_snapshot = AsyncMock()
await async_get_image(hass, package_id)
ufp.api.get_public_api_camera_snapshot.assert_called_once()
assert ufp.api.get_public_api_camera_snapshot.call_args.kwargs["package"] is True
async def test_package_camera_without_stream(
hass: HomeAssistant, ufp: MockUFPFixture, doorbell: ProtectCamera
) -> None:
"""The package camera stays available for snapshots when its stream is off."""
doorbell.channels = [c.model_copy() for c in doorbell.channels]
doorbell.channels[3].is_rtsp_enabled = False # package stream off (the default)
await init_entry(hass, ufp, [doorbell])
# high (enabled, streaming) + package (disabled, snapshot-only) still present
assert_entity_counts(hass, Platform.CAMERA, 2, 1)
package_id = _assert_entity(hass, doorbell, 3, enabled=False)
await enable_entity(hass, ufp.entry.entry_id, package_id)
assert await async_get_stream_source(hass, package_id) is None
state = hass.states.get(package_id)
assert state
assert state.attributes["supported_features"] == CameraEntityFeature(0)
# snapshots still work, fetched with package=True
ufp.api.get_public_api_camera_snapshot = AsyncMock()
await async_get_image(hass, package_id)
assert ufp.api.get_public_api_camera_snapshot.call_args.kwargs["package"] is True
async def test_package_only_camera(
hass: HomeAssistant,
ufp: MockUFPFixture,
camera: ProtectCamera,
issue_registry: ir.IssueRegistry,
) -> None:
"""A camera with only a package channel still exposes a snapshot entity."""
package = camera.channels[0].model_copy()
package.id = 3
package.fps = 2
package.is_rtsp_enabled = False
camera.channels = [package]
await init_entry(hass, ufp, [camera])
# only the package snapshot entity (disabled by default); no main fallback, no repair
assert_entity_counts(hass, Platform.CAMERA, 1, 0)
_assert_entity(hass, camera, 0, enabled=False)
assert issue_registry.async_get_issue(DOMAIN, f"rtsp_disabled_{camera.id}") is None
async def test_no_channels(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
) -> None:
"""A camera without channels yet creates no entities."""
camera.channels = []
await init_entry(hass, ufp, [camera])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
async def test_streams_unavailable(
hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera
) -> None:
"""A camera the library leaves unprimed (no streams) has no stream source."""
async def _prime_streamless() -> Any:
pb = ufp.api.public_bootstrap
pb.cameras = {
camera_all.id: SimpleNamespace(
id=camera_all.id, state=camera_all.state, rtsps_streams=None
)
}
return pb
ufp.api.update_public = AsyncMock(side_effect=_prime_streamless)
await init_entry(hass, ufp, [camera_all])
high_id = _channel_entity_id(camera_all, 0)
assert await async_get_stream_source(hass, high_id) is None
async def test_public_bootstrap_failure_not_ready(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
) -> None:
"""A failed public bootstrap prime leaves the entry in setup-retry.
The websockets/poll subscriptions opened during setup must be torn down so
a retry does not leak another set.
"""
ufp.api.update_public = AsyncMock(side_effect=ClientError("boom"))
await init_entry(hass, ufp, [camera])
assert ufp.entry.state is ConfigEntryState.SETUP_RETRY
ufp.api.async_disconnect_ws.assert_called()
async def test_public_bootstrap_revoked_key_reauth(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
) -> None:
"""A revoked API key (public 401) triggers reauth, not an endless retry."""
ufp.api.update_public = AsyncMock(side_effect=NotAuthorized("revoked"))
await init_entry(hass, ufp, [camera])
assert ufp.entry.state is ConfigEntryState.SETUP_ERROR
ufp.api.async_disconnect_ws.assert_called()
assert any(ufp.entry.async_get_active_flows(hass, {SOURCE_REAUTH}))
async def test_adopt(
hass: HomeAssistant, ufp: MockUFPFixture, camera_all: ProtectCamera
) -> None:
"""A camera adopted at runtime loads its public streams before entities."""
await init_entry(hass, ufp, [camera_all])
assert_entity_counts(hass, Platform.CAMERA, 3, 1)
await remove_entities(hass, ufp, [camera_all])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
await adopt_devices(hass, ufp, [camera_all])
assert_entity_counts(hass, Platform.CAMERA, 3, 1)
high_id = _assert_entity(hass, camera_all, 0, enabled=True)
assert (
await async_get_stream_source(hass, high_id)
== camera_all.channels[0].rtsps_no_srtp_url
)
@pytest.mark.parametrize(
("service", "expected_value"),
[
("enable_motion_detection", True),
("disable_motion_detection", False),
],
)
async def test_camera_motion_detection(
hass: HomeAssistant,
ufp: MockUFPFixture,
camera: ProtectCamera,
service: str,
expected_value: bool,
) -> None:
"""Test enabling/disabling motion detection on a camera."""
await init_entry(hass, ufp, [camera])
assert_entity_counts(hass, Platform.CAMERA, 1, 1)
entity_id = _channel_entity_id(camera, 0)
with patch_ufp_method(
camera, "set_motion_detection", new_callable=AsyncMock
) as mock_method:
await hass.services.async_call(
"camera",
service,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_method.assert_called_once_with(expected_value)
async def test_aiport_no_camera_entities(
hass: HomeAssistant, ufp: MockUFPFixture, aiport: AiPort
) -> None:
"""AI Port devices do not create camera entities."""
await init_entry(hass, ufp, [aiport])
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
async def test_aiport_stream_issue_cleanup(
hass: HomeAssistant,
ufp: MockUFPFixture,
aiport: AiPort,
issue_registry: ir.IssueRegistry,
) -> None:
"""Stale public-stream issues for AI Ports are cleaned up on setup."""
await init_entry(hass, ufp, [aiport])
issue_id = f"rtsp_disabled_{aiport.id}"
# Simulate a legacy issue created directly (bypass translation validation).
issue_registry.issues[(DOMAIN, issue_id)] = ir.IssueEntry(
active=True,
breaks_in_ha_version=None,
created=None,
data=None,
dismissed_version=None,
domain=DOMAIN,
is_fixable=True,
is_persistent=False,
issue_domain=None,
issue_id=issue_id,
learn_more_url=None,
severity=ir.IssueSeverity.WARNING,
translation_key="rtsp_disabled",
translation_placeholders=None,
)
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
await hass.config_entries.async_reload(ufp.entry.entry_id)
await hass.async_block_till_done()
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
assert_entity_counts(hass, Platform.CAMERA, 0, 0)
async def test_snapshot_low_quality_without_stream(
hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera
) -> None:
"""Without a live stream, snapshots are requested at low quality."""
camera.channels = [c.model_copy() for c in camera.channels]
for channel in camera.channels:
channel.is_rtsp_enabled = False
await init_entry(hass, ufp, [camera])
ufp.api.get_public_api_camera_snapshot = AsyncMock()
await async_get_image(hass, _channel_entity_id(camera, 0))
ufp.api.get_public_api_camera_snapshot.assert_called_once()
assert (
ufp.api.get_public_api_camera_snapshot.call_args.kwargs["high_quality"] is False
)