diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 32f6238ee21f..b923572931f3 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -149,10 +149,6 @@ async def async_setup_entry( 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/const.py b/homeassistant/components/unifiprotect/const.py index beaf24d29eda..717f960ca2f9 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -41,7 +41,6 @@ DEFAULT_VERIFY_SSL = False DEFAULT_MAX_MEDIA = 1000 DEVICES_THAT_ADOPT = { - ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.VIEWPORT, diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 4c8c61265d1b..06b07cf51bf1 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -164,7 +164,6 @@ def _async_device_entities( _ALL_MODEL_TYPES = ( - ModelType.AIPORT, ModelType.CAMERA, ModelType.LIGHT, ModelType.SENSOR, @@ -208,6 +207,10 @@ def async_all_device_entities( device_model_type = ufp_device.model assert device_model_type is not None + # Runtime adoption must honor the same model-type allowlist as initial setup, + # so unsupported devices (e.g. AI Port) get no entities when adopted live. + if device_model_type not in _ALL_MODEL_TYPES: + return [] descs = _combine_model_descs(device_model_type, model_descriptions, all_descs) return _async_device_entities( data, klass, device_model_type, descs, unadopted_descs, ufp_device diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 3e1804f6d19d..8ed230acdf89 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -11,7 +11,11 @@ from homeassistant.components.automation import automations_with_entity from homeassistant.components.script import scripts_with_entity from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.helpers.issue_registry import IssueSeverity from .const import DOMAIN @@ -109,6 +113,10 @@ async def async_migrate_data( async_deprecate_hdr(hass, entry) _LOGGER.debug("Completed Migrate: async_deprecate_hdr") + _LOGGER.debug("Start Migrate: async_remove_aiport_devices") + async_remove_aiport_devices(hass, entry) + _LOGGER.debug("Completed Migrate: async_remove_aiport_devices") + _LOGGER.debug("Start Migrate: async_migrate_insecure_cameras") async_migrate_insecure_cameras(hass, entry) _LOGGER.debug("Completed Migrate: async_migrate_insecure_cameras") @@ -118,6 +126,35 @@ async def async_migrate_data( _LOGGER.debug("Completed Migrate: async_remove_package_binary_sensor") +# Device type (``ProtectAdoptableDeviceModel.type``) reported by AI Ports. Matched +# in the registry so cleanup does not depend on the bundled library still exposing +# the AI Port model. +_AIPORT_DEVICE_TYPE = "AI Port" + + +@callback +def async_remove_aiport_devices(hass: HomeAssistant, entry: UFPConfigEntry) -> None: + """Remove AI Port devices and their diagnostic-only entities. + + AI Ports only ever exposed diagnostic sensors (no automation-relevant + functionality) and behave transparently, extending the camera they back. + They have no public API representation, so support is dropped. Devices are + matched from the registry (by device type) rather than the live bootstrap, so + cleanup works even once the library drops the AI Port model. + + Added in 2026.7.0 + """ + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + if device.model_id != _AIPORT_DEVICE_TYPE: + continue + # Detaching the config entry removes the device (it has no other entry) + # and its entities along with it. + device_registry.async_update_device( + device.id, remove_config_entry_id=entry.entry_id + ) + + @callback def async_migrate_insecure_cameras(hass: HomeAssistant, entry: UFPConfigEntry) -> None: """Migrate the legacy plain-RTSP "(insecure)" camera entities. diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 50684ad406fe..8c429e5397ae 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -383,42 +383,6 @@ async def test_aiport_no_camera_entities( 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: diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 985b50c5f6f9..08dc5716263f 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -9,7 +9,11 @@ from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + issue_registry as ir, +) from homeassistant.setup import async_setup_component from .utils import MockUFPFixture, init_entry @@ -220,6 +224,37 @@ async def test_deprecate_entity_script( assert issue is None +async def test_migrate_remove_aiport_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + ufp: MockUFPFixture, +) -> None: + """A leftover AI Port device/entity is removed by type, bootstrap-independent.""" + mac = "AABBCCDDEEFF" + device = device_registry.async_get_or_create( + config_entry_id=ufp.entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + model_id="AI Port", + ) + entity = entity_registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{mac}_uptime", + config_entry=ufp.entry, + device_id=device.id, + ) + + # AI Port deliberately absent from the bootstrap — cleanup is registry-based + await init_entry(hass, ufp, []) + + assert entity_registry.async_get(entity.entity_id) is None + assert ( + device_registry.async_get_device(connections={(dr.CONNECTION_NETWORK_MAC, mac)}) + is None + ) + + async def test_migrate_insecure_camera_redirected( hass: HomeAssistant, entity_registry: er.EntityRegistry, diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 25531ca43c68..499e66ff68c7 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -692,22 +692,32 @@ async def test_sensor_precision( assert hass.states.get(entity_id).state == "17.49" -async def test_aiport_no_camera_sensor_entities( +async def test_aiport_no_sensor_entities( hass: HomeAssistant, + entity_registry: er.EntityRegistry, ufp: MockUFPFixture, aiport: AiPort, ) -> None: - """Test that AI Port devices do not create camera-specific sensor entities.""" + """AI Port devices create no entities (support dropped).""" 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) + assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")] - # 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 + +async def test_aiport_no_sensor_entities_on_runtime_adopt( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ufp: MockUFPFixture, + sensor_all: Sensor, + aiport: AiPort, +) -> None: + """An AI Port adopted while running still creates no entities.""" + await init_entry(hass, ufp, [sensor_all]) + + aiport._api = ufp.api + aiport.feature_flags = Mock(is_ptz=False) + await adopt_devices(hass, ufp, [aiport]) + + entities = er.async_entries_for_config_entry(entity_registry, ufp.entry.entry_id) + assert not [e for e in entities if e.unique_id.startswith(f"{aiport.mac}_")]