From 3e01085c912817f7e003479816c02c64a958467d Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sun, 24 Mar 2024 23:30:52 -0400 Subject: [PATCH] Add repair for UniFi Protect if RTSP is disabled on camera (#114088) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/camera.py | 55 +++++- .../components/unifiprotect/repairs.py | 125 ++++++++++++- .../components/unifiprotect/strings.json | 30 +++ .../components/unifiprotect/utils.py | 12 ++ tests/components/unifiprotect/test_camera.py | 26 +-- .../unifiprotect/test_media_source.py | 3 +- tests/components/unifiprotect/test_repairs.py | 171 +++++++++++++++++- tests/components/unifiprotect/test_views.py | 2 +- 8 files changed, 391 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 781653d4ca4..1e99bdff541 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -18,8 +18,10 @@ from pyunifiprotect.data import ( from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity from .const import ( ATTR_BITRATE, @@ -33,12 +35,40 @@ from .const import ( ) from .data import ProtectData from .entity import ProtectDeviceEntity -from .utils import async_dispatch_id as _ufpd +from .utils import async_dispatch_id as _ufpd, get_camera_base_name _LOGGER = logging.getLogger(__name__) -def get_camera_channels( +@callback +def _create_rtsp_repair( + hass: HomeAssistant, entry: ConfigEntry, data: ProtectData, camera: UFPCamera +) -> None: + edit_key = "readonly" + if camera.can_write(data.api.bootstrap.auth_user): + edit_key = "writable" + + translation_key = f"rtsp_disabled_{edit_key}" + issue_key = f"rtsp_disabled_{camera.id}" + + ir.async_create_issue( + hass, + DOMAIN, + issue_key, + is_fixable=True, + is_persistent=False, + learn_more_url="https://www.home-assistant.io/integrations/unifiprotect/#camera-streams", + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders={"camera": camera.display_name}, + data={"entry_id": entry.entry_id, "camera_id": camera.id}, + ) + + +@callback +def _get_camera_channels( + hass: HomeAssistant, + entry: ConfigEntry, data: ProtectData, ufp_device: UFPCamera | None = None, ) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]: @@ -70,15 +100,23 @@ def get_camera_channels( # no RTSP enabled use first channel with no stream if is_default: + _create_rtsp_repair(hass, entry, data, camera) yield camera, camera.channels[0], True + else: + ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{camera.id}") def _async_camera_entities( - data: ProtectData, ufp_device: UFPCamera | None = None + hass: HomeAssistant, + entry: ConfigEntry, + data: ProtectData, + ufp_device: UFPCamera | None = None, ) -> list[ProtectDeviceEntity]: disable_stream = data.disable_stream entities: list[ProtectDeviceEntity] = [] - for camera, channel, is_default in get_camera_channels(data, ufp_device): + for camera, channel, is_default in _get_camera_channels( + hass, entry, data, ufp_device + ): # do not enable streaming for package camera # 2 FPS causes a lot of buferring entities.append( @@ -119,7 +157,7 @@ async def async_setup_entry( if not isinstance(device, UFPCamera): return # type: ignore[unreachable] - entities = _async_camera_entities(data, ufp_device=device) + entities = _async_camera_entities(hass, entry, data, ufp_device=device) async_add_entities(entities) entry.async_on_unload( @@ -129,7 +167,7 @@ async def async_setup_entry( async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device) ) - entities = _async_camera_entities(data) + entities = _async_camera_entities(hass, entry, data) async_add_entities(entities) @@ -155,12 +193,13 @@ class ProtectCamera(ProtectDeviceEntity, Camera): super().__init__(data, camera) device = self.device + camera_name = get_camera_base_name(channel) if self._secure: self._attr_unique_id = f"{device.mac}_{channel.id}" - self._attr_name = f"{device.display_name} {channel.name}" + self._attr_name = f"{device.display_name} {camera_name}" else: self._attr_unique_id = f"{device.mac}_{channel.id}_insecure" - self._attr_name = f"{device.display_name} {channel.name} Insecure" + self._attr_name = f"{device.display_name} {camera_name} (Insecure)" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 254984da515..ddd5dc087a1 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -2,10 +2,10 @@ from __future__ import annotations -import logging from typing import cast from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data import Bootstrap, Camera, ModelType from pyunifiprotect.data.types import FirmwareReleaseChannel import voluptuous as vol @@ -18,8 +18,6 @@ from homeassistant.helpers.issue_registry import async_get as async_get_issue_re from .const import CONF_ALLOW_EA from .utils import async_create_api_client -_LOGGER = logging.getLogger(__name__) - class ProtectRepair(RepairsFlow): """Handler for an issue fixing flow.""" @@ -27,7 +25,7 @@ class ProtectRepair(RepairsFlow): _api: ProtectApiClient _entry: ConfigEntry - def __init__(self, api: ProtectApiClient, entry: ConfigEntry) -> None: + def __init__(self, *, api: ProtectApiClient, entry: ConfigEntry) -> None: """Create flow.""" self._api = api @@ -46,7 +44,7 @@ class ProtectRepair(RepairsFlow): return description_placeholders -class EAConfirm(ProtectRepair): +class EAConfirmRepair(ProtectRepair): """Handler for an issue fixing flow.""" async def async_step_init( @@ -92,7 +90,7 @@ class EAConfirm(ProtectRepair): ) -class CloudAccount(ProtectRepair): +class CloudAccountRepair(ProtectRepair): """Handler for an issue fixing flow.""" async def async_step_init( @@ -119,6 +117,108 @@ class CloudAccount(ProtectRepair): return self.async_create_entry(data={}) +class RTSPRepair(ProtectRepair): + """Handler for an issue fixing flow.""" + + _camera_id: str + _camera: Camera | None + _bootstrap: Bootstrap | None + + def __init__( + self, + *, + api: ProtectApiClient, + entry: ConfigEntry, + camera_id: str, + ) -> None: + """Create flow.""" + + super().__init__(api=api, entry=entry) + self._camera_id = camera_id + self._bootstrap = None + self._camera = None + + @callback + def _async_get_placeholders(self) -> dict[str, str]: + description_placeholders = super()._async_get_placeholders() + if self._camera is not None: + description_placeholders["camera"] = self._camera.display_name + + return description_placeholders + + async def _get_boostrap(self) -> Bootstrap: + if self._bootstrap is None: + self._bootstrap = await self._api.get_bootstrap() + + return self._bootstrap + + async def _get_camera(self) -> Camera: + if self._camera is None: + bootstrap = await self._get_boostrap() + self._camera = bootstrap.cameras.get(self._camera_id) + assert self._camera is not None + return self._camera + + async def _enable_rtsp(self) -> None: + camera = await self._get_camera() + bootstrap = await self._get_boostrap() + user = bootstrap.users.get(bootstrap.auth_user_id) + if not user or not camera.can_write(user): + return + + channel = camera.channels[0] + channel.is_rtsp_enabled = True + await self._api.update_device( + ModelType.CAMERA, camera.id, {"channels": camera.unifi_dict()["channels"]} + ) + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await self.async_step_start() + + async def async_step_start( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + if user_input is None: + # make sure camera object is loaded for placeholders + await self._get_camera() + placeholders = self._async_get_placeholders() + return self.async_show_form( + step_id="start", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + updated_camera = await self._api.get_camera(self._camera_id) + if not any(c.is_rtsp_enabled for c in updated_camera.channels): + await self._enable_rtsp() + + updated_camera = await self._api.get_camera(self._camera_id) + if any(c.is_rtsp_enabled for c in updated_camera.channels): + await self.hass.config_entries.async_reload(self._entry.entry_id) + return self.async_create_entry(data={}) + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + return self.async_create_entry(data={}) + + placeholders = self._async_get_placeholders() + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -129,10 +229,19 @@ async def async_create_fix_flow( entry_id = cast(str, data["entry_id"]) if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: api = async_create_api_client(hass, entry) - return EAConfirm(api, entry) + return EAConfirmRepair(api=api, entry=entry) + elif data is not None and issue_id == "cloud_user": entry_id = cast(str, data["entry_id"]) if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: api = async_create_api_client(hass, entry) - return CloudAccount(api, entry) + return CloudAccountRepair(api=api, entry=entry) + + elif data is not None and issue_id.startswith("rtsp_disabled_"): + entry_id = cast(str, data["entry_id"]) + camera_id = cast(str, data["camera_id"]) + if (entry := hass.config_entries.async_get_entry(entry_id)) is not None: + api = async_create_api_client(hass, entry) + return RTSPRepair(api=api, entry=entry, camera_id=camera_id) + return ConfirmRepairFlow() diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 0b01c8f220c..b83d514f836 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -91,6 +91,36 @@ } } }, + "rtsp_disabled_readonly": { + "title": "RTSPS is disabled on camera {camera}", + "fix_flow": { + "step": { + "start": { + "title": "RTSPS is disabled on camera {camera}", + "description": "RTSPS is disabled on the camera {camera}. RTSPS is required to be able to live stream your camera within Home Assistant. If you do not enable RTSPS, it may create an additional load on your UniFi Protect NVR, as any live video players will default to rapidly pulling snapshots from the camera.\n\nPlease [enable RTSPS]({learn_more}) on the camera and then come back and confirm this repair." + }, + "confirm": { + "title": "[%key:component::unifiprotect::issues::rtsp_disabled_readonly::fix_flow::step::start::title%]", + "description": "Are you sure you want to leave RTSPS disabled for {camera}?" + } + } + } + }, + "rtsp_disabled_writable": { + "title": "RTSPS is disabled on camera {camera}", + "fix_flow": { + "step": { + "start": { + "title": "[%key:component::unifiprotect::issues::rtsp_disabled_readonly::fix_flow::step::start::title%]", + "description": "RTSPS is disabled on the camera {camera}. RTSPS is required to live stream your camera within Home Assistant. If you do not enable RTSPS, it may create an additional load on your UniFi Protect NVR as any live video players will default to rapidly pulling snapshots from the camera.\n\nYou may manually [enable RTSPS]({learn_more}) on your selected camera quality channel or Home Assistant can automatically enable the highest quality channel for you. Confirm this repair once you have enabled the RTSPS channel or if you want Home Assistant to enable the highest quality automatically." + }, + "confirm": { + "title": "[%key:component::unifiprotect::issues::rtsp_disabled_readonly::fix_flow::step::start::title%]", + "description": "[%key:component::unifiprotect::issues::rtsp_disabled_readonly::fix_flow::step::confirm::description%]" + } + } + } + }, "deprecate_hdr_switch": { "title": "HDR Mode Switch Deprecated", "description": "UniFi Protect v3 added a new state for HDR (auto). As a result, the HDR Mode Switch has been replaced with an HDR Mode Select, and it is deprecated.\n\nBelow are the detected automations or scripts that use one or more of the deprecated entities:\n{items}\nThe above list may be incomplete and it does not include any template usages inside of dashboards. Please update any templates, automations or scripts accordingly." diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 00f9f9c0cd1..58474e6a531 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -13,6 +13,7 @@ from aiohttp import CookieJar from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( Bootstrap, + CameraChannel, Light, LightModeEnableType, LightModeType, @@ -146,3 +147,14 @@ def async_create_api_client( ignore_unadopted=False, cache_dir=Path(hass.config.path(STORAGE_DIR, "unifiprotect_cache")), ) + + +@callback +def get_camera_base_name(channel: CameraChannel) -> str: + """Get base name for cameras channel.""" + + camera_name = channel.name + if channel.name != "Package Camera": + camera_name = f"{channel.name} Resolution Channel" + + return camera_name diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 7b777d711cf..d374f61c2b0 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -21,6 +21,7 @@ from homeassistant.components.unifiprotect.const import ( DEFAULT_ATTRIBUTION, DEFAULT_SCAN_INTERVAL, ) +from homeassistant.components.unifiprotect.utils import get_camera_base_name from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_ENTITY_ID, @@ -51,7 +52,8 @@ def validate_default_camera_entity( channel = camera_obj.channels[channel_id] - entity_name = f"{camera_obj.name} {channel.name}" + camera_name = get_camera_base_name(channel) + entity_name = f"{camera_obj.name} {camera_name}" unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" @@ -73,7 +75,7 @@ def validate_rtsps_camera_entity( channel = camera_obj.channels[channel_id] - entity_name = f"{camera_obj.name} {channel.name}" + entity_name = f"{camera_obj.name} {channel.name} Resolution Channel" unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" @@ -95,9 +97,9 @@ def validate_rtsp_camera_entity( channel = camera_obj.channels[channel_id] - entity_name = f"{camera_obj.name} {channel.name} Insecure" + entity_name = f"{camera_obj.name} {channel.name} Resolution Channel (Insecure)" unique_id = f"{camera_obj.mac}_{channel.id}_insecure" - entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" + entity_id = f"camera.{entity_name.replace(' ', '_').replace('(', '').replace(')', '').lower()}" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) @@ -314,7 +316,7 @@ async def test_camera_image( ufp.api.get_camera_snapshot = AsyncMock() - await async_get_image(hass, "camera.test_camera_high") + await async_get_image(hass, "camera.test_camera_high_resolution_channel") ufp.api.get_camera_snapshot.assert_called_once() @@ -339,7 +341,7 @@ async def test_camera_generic_update( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" assert await async_setup_component(hass, "homeassistant", {}) @@ -365,7 +367,7 @@ async def test_camera_interval_update( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" state = hass.states.get(entity_id) assert state and state.state == "idle" @@ -388,7 +390,7 @@ async def test_camera_bad_interval_update( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" state = hass.states.get(entity_id) assert state and state.state == "idle" @@ -415,7 +417,7 @@ async def test_camera_ws_update( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" state = hass.states.get(entity_id) assert state and state.state == "idle" @@ -450,7 +452,7 @@ async def test_camera_ws_update_offline( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" state = hass.states.get(entity_id) assert state and state.state == "idle" @@ -492,7 +494,7 @@ async def test_camera_enable_motion( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" camera.__fields__["set_motion_detection"] = Mock(final=False) camera.set_motion_detection = AsyncMock() @@ -514,7 +516,7 @@ async def test_camera_disable_motion( await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.CAMERA, 2, 1) - entity_id = "camera.test_camera_high" + entity_id = "camera.test_camera_high_resolution_channel" camera.__fields__["set_motion_detection"] = Mock(final=False) camera.set_motion_detection = AsyncMock() diff --git a/tests/components/unifiprotect/test_media_source.py b/tests/components/unifiprotect/test_media_source.py index c79a46daafd..e767909d47e 100644 --- a/tests/components/unifiprotect/test_media_source.py +++ b/tests/components/unifiprotect/test_media_source.py @@ -362,7 +362,8 @@ async def test_browse_media_camera( entity_registry = er.async_get(hass) entity_registry.async_update_entity( - "camera.test_camera_high", disabled_by=er.RegistryEntryDisabler("user") + "camera.test_camera_high_resolution_channel", + disabled_by=er.RegistryEntryDisabler("user"), ) await hass.async_block_till_done() diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 6ec0b3fe6ca..f4be3164fd5 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -2,11 +2,11 @@ from __future__ import annotations -from copy import copy +from copy import copy, deepcopy from http import HTTPStatus -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock -from pyunifiprotect.data import CloudAccount, Version +from pyunifiprotect.data import Camera, CloudAccount, ModelType, Version from homeassistant.components.repairs.issue_handler import ( async_process_repairs_platforms, @@ -192,3 +192,168 @@ async def test_cloud_user_fix( assert data["type"] == "create_entry" await hass.async_block_till_done() assert any(ufp.entry.async_get_active_flows(hass, {SOURCE_REAUTH})) + + +async def test_rtsp_read_only_ignore( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test RTSP disabled warning if camera is read-only and it is ignored.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + for user in ufp.api.bootstrap.users.values(): + user.all_permissions = [] + + ufp.api.get_camera = AsyncMock(return_value=doorbell) + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + client = await hass_client() + + issue_id = f"rtsp_disabled_{doorbell.id}" + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == issue_id: + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "start" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + +async def test_rtsp_read_only_fix( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test RTSP disabled warning if camera is read-only and it is fixed.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + for user in ufp.api.bootstrap.users.values(): + user.all_permissions = [] + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + client = await hass_client() + + new_doorbell = deepcopy(doorbell) + new_doorbell.channels[1].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(return_value=new_doorbell) + issue_id = f"rtsp_disabled_{doorbell.id}" + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == issue_id: + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "start" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + +async def test_rtsp_writable_fix( + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test RTSP disabled warning if camera is writable and it is ignored.""" + + for channel in doorbell.channels: + channel.is_rtsp_enabled = False + + await init_entry(hass, ufp, [doorbell]) + await async_process_repairs_platforms(hass) + ws_client = await hass_ws_client(hass) + client = await hass_client() + + new_doorbell = deepcopy(doorbell) + new_doorbell.channels[0].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(side_effect=[doorbell, new_doorbell]) + ufp.api.update_device = AsyncMock() + issue_id = f"rtsp_disabled_{doorbell.id}" + + await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await ws_client.receive_json() + + assert msg["success"] + assert len(msg["result"]["issues"]) > 0 + issue = None + for i in msg["result"]["issues"]: + if i["issue_id"] == issue_id: + issue = i + assert issue is not None + + url = RepairsFlowIndexView.url + resp = await client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "start" + + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await client.post(url) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "create_entry" + + channels = doorbell.unifi_dict()["channels"] + channels[0]["isRtspEnabled"] = True + ufp.api.update_device.assert_called_with( + ModelType.CAMERA, doorbell.id, {"channels": channels} + ) diff --git a/tests/components/unifiprotect/test_views.py b/tests/components/unifiprotect/test_views.py index fb24b399124..f7930e5ff9a 100644 --- a/tests/components/unifiprotect/test_views.py +++ b/tests/components/unifiprotect/test_views.py @@ -483,7 +483,7 @@ async def test_video_entity_id( ) url = async_generate_event_video_url(event) - url = url.replace(camera.id, "camera.test_camera_high") + url = url.replace(camera.id, "camera.test_camera_high_resolution_channel") http_client = await hass_client() response = cast(ClientResponse, await http_client.get(url))