From 0c9834e4cae2d08b2dae371641f8ec04cc5d33ff Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 28 Jan 2026 19:54:15 +0100 Subject: [PATCH] Exclude AI Port from camera entities and RTSP issues (#161188) Co-authored-by: RaHehl --- .../components/unifiprotect/binary_sensor.py | 3 +- .../components/unifiprotect/camera.py | 9 ++- .../components/unifiprotect/event.py | 5 +- .../components/unifiprotect/media_player.py | 9 ++- .../components/unifiprotect/sensor.py | 7 +- tests/components/unifiprotect/conftest.py | 14 ++++ .../unifiprotect/fixtures/sample_aiport.json | 29 ++++++++ .../unifiprotect/test_binary_sensor.py | 20 ++++++ tests/components/unifiprotect/test_camera.py | 71 ++++++++++++++++++- tests/components/unifiprotect/test_event.py | 21 +++++- .../unifiprotect/test_media_player.py | 14 +++- tests/components/unifiprotect/test_sensor.py | 23 +++++- tests/components/unifiprotect/utils.py | 2 +- 13 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 tests/components/unifiprotect/fixtures/sample_aiport.json diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 6e9d0640e8c..689b4ec99f9 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -755,7 +755,8 @@ async def async_setup_entry( entities += async_all_device_entities( data, klass, model_descriptions=model_descriptions, ufp_device=device ) - if device.is_adopted and isinstance(device, Camera): + # AiPort inherits from Camera but should not create camera-specific entities + if device.is_adopted and device.model is ModelType.CAMERA: entities += _async_event_entities(data, ufp_device=device) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index e0b0cb7205f..f281bbe962f 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -8,6 +8,7 @@ import logging from uiprotect.data import ( Camera as UFPCamera, CameraChannel, + ModelType, ProtectAdoptableDeviceModel, StateType, ) @@ -150,7 +151,8 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - if not isinstance(device, UFPCamera): + # AiPort inherits from Camera but should not create camera entities + if not isinstance(device, UFPCamera) or device.model is ModelType.AIPORT: return async_add_entities(_async_camera_entities(hass, entry, data, ufp_device=device)) @@ -158,6 +160,11 @@ async def async_setup_entry( entry.async_on_unload( async_dispatcher_connect(hass, data.channels_signal, _add_new_device) ) + + # Clean up any erroneously created RTSP issues for AI Ports + for device in data.get_by_types({ModelType.AIPORT}): + ir.async_delete_issue(hass, DOMAIN, f"rtsp_disabled_{device.id}") + async_add_entities(_async_camera_entities(hass, entry, data)) diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py index 2bc563bd682..23f5cbcb3f5 100644 --- a/homeassistant/components/unifiprotect/event.py +++ b/homeassistant/components/unifiprotect/event.py @@ -5,6 +5,7 @@ from __future__ import annotations import dataclasses from typing import Any +from uiprotect.data import ModelType from uiprotect.data.nvr import Event, EventDetectedThumbnail from homeassistant.components.event import ( @@ -31,7 +32,6 @@ from .const import ( VEHICLE_EVENT_DELAY_SECONDS, ) from .data import ( - Camera, EventType, ProtectAdoptableDeviceModel, ProtectData, @@ -423,7 +423,8 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - if device.is_adopted and isinstance(device, Camera): + # AiPort inherits from Camera but should not create camera-specific entities + if device.is_adopted and device.model is ModelType.CAMERA: async_add_entities(_async_event_entities(data, ufp_device=device)) data.async_subscribe_adopt(_add_new_device) diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 78c6e72199b..4ebc64942a9 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from typing import Any -from uiprotect.data import Camera, ProtectAdoptableDeviceModel, StateType +from uiprotect.data import Camera, ModelType, ProtectAdoptableDeviceModel, StateType from uiprotect.exceptions import StreamError from homeassistant.components import media_source @@ -47,8 +47,11 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: - if isinstance(device, Camera) and ( - device.has_speaker or device.has_removable_speaker + # AiPort inherits from Camera but should not create camera-specific entities + if ( + device.model is ModelType.CAMERA + and isinstance(device, Camera) + and (device.has_speaker or device.has_removable_speaker) ): async_add_entities([ProtectMediaPlayer(data, device)]) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index ec24491deee..894c1dad871 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -606,7 +606,12 @@ async def async_setup_entry( model_descriptions=_MODEL_DESCRIPTIONS, ufp_device=device, ) - if device.is_adopted_by_us and isinstance(device, Camera): + # AiPort inherits from Camera but should not create camera-specific entities + if ( + device.model is ModelType.CAMERA + and isinstance(device, Camera) + and device.is_adopted_by_us + ): entities += _async_event_entities(data, ufp_device=device) async_add_entities(entities) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 97aeec4a00e..fd07c88e8b3 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -15,6 +15,7 @@ import pytest from uiprotect import ProtectApiClient from uiprotect.data import ( NVR, + AiPort, Bootstrap, Camera, Chime, @@ -397,6 +398,19 @@ def chime(): Chime.model_config["validate_assignment"] = True +@pytest.fixture(name="aiport") +def aiport_fixture(): + """Mock UniFi Protect AI Port device.""" + + # disable pydantic validation so mocking can happen + AiPort.model_config["validate_assignment"] = False + + data = load_json_object_fixture("sample_aiport.json", DOMAIN) + yield AiPort.from_unifi_dict(**data) + + AiPort.model_config["validate_assignment"] = True + + @pytest.fixture(name="fixed_now") def fixed_now_fixture(): """Return datetime object that will be consistent throughout test.""" diff --git a/tests/components/unifiprotect/fixtures/sample_aiport.json b/tests/components/unifiprotect/fixtures/sample_aiport.json new file mode 100644 index 00000000000..a53c77a9169 --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_aiport.json @@ -0,0 +1,29 @@ +{ + "id": "696a16ac001e0b03e4008cec", + "mac": "696A16AC001E", + "host": "192.168.1.100", + "connectionHost": "192.168.1.1", + "type": "AI Port", + "name": "Test AI Port", + "upSince": 1640020678036, + "uptime": 3203, + "lastSeen": 1640023881036, + "connectedSince": 1640020710448, + "state": "CONNECTED", + "hardwareRevision": "1", + "firmwareVersion": "1.0.0", + "latestFirmwareVersion": "1.0.0", + "firmwareBuild": "build123", + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": true, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "isConnected": true, + "marketName": "AI Port", + "modelKey": "aiport" +} diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 4f7e326aeeb..3ebca1378e9 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -7,6 +7,7 @@ from unittest.mock import Mock import pytest from uiprotect.data import ( + AiPort, Camera, Event, EventType, @@ -723,6 +724,25 @@ async def test_binary_sensor_person_detected( assert len(state_changes) == 2 +async def test_aiport_no_binary_sensor_entities( + hass: HomeAssistant, + ufp: MockUFPFixture, + aiport: AiPort, +) -> None: + """Test that AI Port devices do not create camera-specific binary sensor entities.""" + await init_entry(hass, ufp, [aiport]) + + # AI Port should not create any camera-specific binary sensors (motion, smart detection, etc.) + # NVR HDD sensors will still be created, but no AI Port-specific entities + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + + for entity in entities: + if entity.domain == Platform.BINARY_SENSOR: + # No entities should contain the AI Port's device id + assert aiport.id not in entity.unique_id + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_binary_sensor_simultaneous_person_and_vehicle_detection( hass: HomeAssistant, diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index e76de9ea151..e721e6b8688 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, Mock import pytest from uiprotect.api import DEVICE_UPDATE_INTERVAL -from uiprotect.data import Camera as ProtectCamera, CameraChannel, StateType +from uiprotect.data import AiPort, Camera as ProtectCamera, CameraChannel, StateType from uiprotect.exceptions import NvrError from uiprotect.websocket import WebsocketState from webrtc_models import RTCIceCandidateInit @@ -41,7 +41,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from . import patch_ufp_method @@ -627,3 +631,66 @@ async def test_camera_motion_detection( ) mock_method.assert_called_once_with(expected_value) + + +async def test_aiport_no_camera_entities( + hass: HomeAssistant, + ufp: MockUFPFixture, + aiport: AiPort, +) -> None: + """Test that AI Port devices do not create camera entities.""" + await init_entry(hass, ufp, [aiport]) + + # AI Port should not create any camera entities + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + +async def test_aiport_rtsp_issue_cleanup( + hass: HomeAssistant, + ufp: MockUFPFixture, + aiport: AiPort, +) -> None: + """Test that RTSP disabled issues for AI Ports are cleaned up on setup.""" + # Set up the integration with the AI Port first + # (init_entry regenerates IDs, so we need to get the new ID) + await init_entry(hass, ufp, [aiport]) + + # Now get the actual AI Port ID after regeneration + actual_aiport_id = aiport.id + + # Create an RTSP disabled issue for the AI Port + # (simulating an issue that might have been created by a previous buggy version) + issue_id = f"rtsp_disabled_{actual_aiport_id}" + + # Get the issue registry and create the issue directly via internal method + # to avoid translation validation (as we're simulating a legacy issue) + issue_registry = ir.async_get(hass) + 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, + ) + + # Verify the issue exists + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + # Reload the integration - this should clean up the issue + await hass.config_entries.async_reload(ufp.entry.entry_id) + await hass.async_block_till_done() + + # The issue should be cleaned up since AI Ports can't have RTSP + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + # Verify no camera entities were created + assert_entity_counts(hass, Platform.CAMERA, 0, 0) diff --git a/tests/components/unifiprotect/test_event.py b/tests/components/unifiprotect/test_event.py index b3e286dda35..8acc31c80d2 100644 --- a/tests/components/unifiprotect/test_event.py +++ b/tests/components/unifiprotect/test_event.py @@ -7,7 +7,14 @@ from datetime import datetime, timedelta from unittest.mock import Mock, patch import pytest -from uiprotect.data import Camera, Event, EventType, ModelType, SmartDetectObjectType +from uiprotect.data import ( + AiPort, + Camera, + Event, + EventType, + ModelType, + SmartDetectObjectType, +) from homeassistant.components.unifiprotect.const import ( ATTR_EVENT_ID, @@ -1590,3 +1597,15 @@ async def test_vehicle_detection_no_refire_same_data( assert len(events) == 1 unsub() + + +async def test_aiport_no_event_entities( + hass: HomeAssistant, + ufp: MockUFPFixture, + aiport: AiPort, +) -> None: + """Test that AI Port devices do not create camera-specific event entities.""" + await init_entry(hass, ufp, [aiport]) + + # AI Port should not create any camera-specific event entities (doorbell, motion, etc.) + assert_entity_counts(hass, Platform.EVENT, 0, 0) diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index f163687a2c6..81c36d92f83 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock, patch import pytest -from uiprotect.data import Camera +from uiprotect.data import AiPort, Camera from uiprotect.exceptions import StreamError from homeassistant.components.media_player import ( @@ -295,3 +295,15 @@ async def test_media_player_play_error( assert mock_play.called assert not mock_wait.called + + +async def test_aiport_no_media_player_entities( + hass: HomeAssistant, + ufp: MockUFPFixture, + aiport: AiPort, +) -> None: + """Test that AI Port devices do not create camera-specific media player entities.""" + await init_entry(hass, ufp, [aiport]) + + # AI Port should not create any media player entities (speaker) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 0, 0) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index d1696ed5aa6..0449b40d638 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -6,7 +6,7 @@ from datetime import datetime, timedelta from unittest.mock import Mock import pytest -from uiprotect.data import NVR, Camera, Event, EventType, ModelType, Sensor +from uiprotect.data import NVR, AiPort, Camera, Event, EventType, ModelType, Sensor from uiprotect.data.nvr import EventMetadata from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -542,3 +542,24 @@ async def test_sensor_precision( ) assert hass.states.get(entity_id).state == "17.49" + + +async def test_aiport_no_camera_sensor_entities( + hass: HomeAssistant, + ufp: MockUFPFixture, + aiport: AiPort, +) -> None: + """Test that AI Port devices do not create camera-specific sensor entities.""" + await init_entry(hass, ufp, [aiport]) + + # AI Port should only create base device sensors, not camera-specific sensors + # The exact count may vary, but camera motion/detection sensors should not exist + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + + # Check no camera-specific sensors like motion detection exist + for entity in entities: + if entity.domain == Platform.SENSOR: + # Camera-specific sensors should not exist for AI Port + assert "detected_object" not in entity.unique_id + assert "last_motion" not in entity.unique_id diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 6514f672d90..cd7a78186f5 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -177,7 +177,7 @@ def add_device( return device._api = bootstrap.api - if isinstance(device, Camera): + if isinstance(device, Camera) and device.model is ModelType.CAMERA: for channel in device.channels: channel._api = bootstrap.api