Add repair for UniFi Protect if RTSP is disabled on camera (#114088)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Christopher Bailey 2024-03-24 23:30:52 -04:00 committed by GitHub
parent a5128c2148
commit 3e01085c91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 391 additions and 33 deletions

View File

@ -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

View File

@ -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()

View File

@ -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."

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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}
)

View File

@ -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))