Bump uiprotect to 17.0.0 (#183178)

This commit is contained in:
Raphael Hehl
2026-09-27 10:22:15 +02:00
committed by GitHub
parent 79ea9eb39e
commit 6f373ae500
14 changed files with 48 additions and 197 deletions
@@ -73,7 +73,7 @@ def _async_capability_supported(
Smart-detect capabilities are answered by the master object (the private
camera in hybrid, the public one otherwise). Sensor capabilities come from
the public capability map; without one every description is created.
the public capability map; a sensor without one supports none.
"""
if (capability := description.ufp_capability) is None:
return True
@@ -82,7 +82,7 @@ def _async_capability_supported(
"Camera | PublicCamera", private if private is not None else public
)
return camera.can_detect(capability)
if not isinstance(public, PublicSensor) or not public.has_feature_flags:
if not isinstance(public, PublicSensor):
return True
return public.supports(capability)
@@ -96,8 +96,8 @@ def async_remove_unsupported_sense_entities(
) -> None:
"""Remove registry entries for sense entities the device cannot support.
Only acts when a public capability map is present (newer firmware); a console
upgrade then drops the never-functional entities created before the map existed.
Drops the never-functional entities created before the capability map
existed, and those of a sensor that reports no map.
"""
entity_registry = er.async_get(hass)
is_public_only = data.api.is_public_only
@@ -720,8 +720,8 @@ class ProtectEntityDescription(EntityDescription, Generic[T]): # noqa: UP046
# is often compound (e.g. mount type plus a settings flag).
ufp_public_enabled_fn: Callable[[PublicDeviceModel], bool] | None = None
# Capability required to create the entity: a sensor capability is checked
# against the public capability map (without one every description is
# created), a smart-detect type against the camera's advertised types.
# against the public capability map, a smart-detect type against the
# camera's advertised types.
ufp_capability: SensorFeatureCapability | SmartDetectObjectType | None = None
ufp_perm: PermRequired | None = None
@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["uiprotect"],
"quality_scale": "platinum",
"requirements": ["uiprotect==16.16.1"]
"requirements": ["uiprotect==17.0.0"]
}
+1 -1
View File
@@ -3341,7 +3341,7 @@ uasiren==0.0.1
uhooapi==1.2.8
# homeassistant.components.unifiprotect
uiprotect==16.16.1
uiprotect==17.0.0
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.6.1
-15
View File
@@ -14,7 +14,6 @@ from uiprotect import ProtectApiClient
from uiprotect.api import RTSPSStreams
from uiprotect.data import (
NVR,
AiPort,
Bootstrap,
Camera,
Chime,
@@ -151,7 +150,6 @@ def bootstrap_fixture(nvr: NVR):
data["liveviews"] = []
data["events"] = []
data["chimes"] = []
data["aiports"] = []
return Bootstrap.from_unifi_dict(**data)
@@ -516,19 +514,6 @@ 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."""
@@ -1,29 +0,0 @@
{
"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"
}
@@ -3,8 +3,6 @@
dict({
'bootstrap': dict({
'accessKey': '**REDACTED**',
'aiports': list([
]),
'authUserId': '**REDACTED_ID**',
'bridges': list([
dict({
@@ -6,7 +6,6 @@ from unittest.mock import Mock
import pytest
from uiprotect.data import (
AiPort,
Camera,
Event,
EventType,
@@ -496,14 +495,6 @@ async def test_binary_sensor_sense_leak_public_value(
STATE_OFF,
id="capability-external",
),
pytest.param(
MountType.NONE,
None,
True,
True,
STATE_UNAVAILABLE,
id="settings-without-capability-map",
),
pytest.param(
MountType.NONE,
set(),
@@ -601,21 +592,23 @@ async def test_binary_sensor_sense_capability_registry_cleanup(
assert entity_registry.async_get(stale.entity_id) is None
async def test_binary_sensor_sense_no_capability_map_creates_all(
async def test_binary_sensor_sense_no_capability_map_creates_none(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""Without a capability map (older firmware) every sense entity is created."""
setup_public_sensor(ufp)
"""A sensor without a capability map gets no capability-gated entity."""
setup_public_sensor(ufp, capabilities=set())
await init_entry(hass, ufp, [sensor_all])
for description in (SENSE_DOOR, SENSE_TAMPERING, SENSE_MOTION, SENSE_LEAK):
_, entity_id = await ids_from_device_description(
hass, Platform.BINARY_SENSOR, sensor_all, description
)
assert entity_registry.async_get(entity_id) is not None
assert (
entity_registry.async_get_entity_id(
Platform.BINARY_SENSOR, DOMAIN, f"{sensor_all.mac}_{description.key}"
)
is None
), description.key
async def test_binary_sensor_sense_tampering_public_value(
@@ -979,26 +972,6 @@ async def test_binary_sensor_doorbell_ring(
assert hass.states.get(entity_id).state == STATE_OFF
async def test_aiport_no_binary_sensor_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
aiport: AiPort,
) -> None:
"""Test AI Port devices do not create camera-specific binary sensors."""
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
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,
+8 -6
View File
@@ -188,17 +188,19 @@ async def test_button_sense_capability_registry_cleanup(
assert entity_registry.async_get(stale.entity_id) is None
async def test_button_sense_no_capability_map_creates_clear_tamper(
async def test_button_sense_no_capability_map_skips_clear_tamper(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""Without a capability map (Protect below 7.2) the button is still created."""
setup_public_sensor(ufp)
"""A sensor without a capability map gets no clear tamper button."""
setup_public_sensor(ufp, capabilities=set())
await init_entry(hass, ufp, [sensor_all])
_, entity_id = await ids_from_device_description(
hass, Platform.BUTTON, sensor_all, CLEAR_TAMPER
assert (
entity_registry.async_get_entity_id(
Platform.BUTTON, DOMAIN, f"{sensor_all.mac}_{CLEAR_TAMPER.key}"
)
is None
)
assert entity_registry.async_get(entity_id) is not None
@@ -8,7 +8,6 @@ from aiohttp.client_exceptions import ServerDisconnectedError
import pytest
from uiprotect.api import RTSPSStreams
from uiprotect.data import (
AiPort,
Camera as ProtectCamera,
ChannelQuality,
DeviceState,
@@ -399,14 +398,6 @@ 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:
"""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_public_only_camera(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
@@ -11,7 +11,6 @@ from unittest.mock import Mock, patch
import pytest
from uiprotect import EventChange, ProtectEvent, ProtectEventChannel
from uiprotect.data import (
AiPort,
Camera,
Event,
EventType,
@@ -1770,19 +1769,6 @@ async def test_vehicle_detection_no_refire_same_data(
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)
async def test_motion_detection_event(
hass: HomeAssistant,
ufp: MockUFPFixture,
@@ -3,7 +3,7 @@
from unittest.mock import AsyncMock, Mock, patch
import pytest
from uiprotect.data import AiPort, Camera
from uiprotect.data import Camera
from uiprotect.exceptions import StreamError
from homeassistant.components.media_player import (
@@ -293,15 +293,3 @@ 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)
@@ -8,7 +8,6 @@ from unittest.mock import Mock
import pytest
from uiprotect.data import (
NVR,
AiPort,
Camera,
DeviceState,
Event,
@@ -754,44 +753,6 @@ async def test_sensor_precision(
assert hass.states.get(entity_id).state == "17.49"
async def test_aiport_no_sensor_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
aiport: AiPort,
) -> None:
"""AI Port devices create no entities (support dropped)."""
await init_entry(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}_")]
# 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}_")]
async def test_sensor_light_last_motion_public(
hass: HomeAssistant, ufp: MockUFPFixture, light: Light
) -> None:
+16 -16
View File
@@ -996,44 +996,44 @@ async def test_switch_sense_capability_registry_cleanup(
assert entity_registry.async_get(stale.entity_id) is None
async def test_switch_sense_no_capability_map_creates_all(
async def test_switch_sense_no_capability_map_creates_none(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""Without a capability map (Protect below 7.2) every config switch is created."""
setup_public_sensor(ufp)
"""A sensor without a capability map gets no capability-gated config switch."""
setup_public_sensor(ufp, capabilities=set())
await init_entry(hass, ufp, [sensor_all])
for description in SENSE_SWITCHES:
_, entity_id = await ids_from_device_description(
hass, Platform.SWITCH, sensor_all, description
)
assert entity_registry.async_get(entity_id) is not None, description.key
gated = [desc for desc in SENSE_SWITCHES if desc.ufp_capability is not None]
assert gated
for description in gated:
assert (
entity_registry.async_get_entity_id(
Platform.SWITCH, DOMAIN, f"{sensor_all.mac}_{description.key}"
)
is None
), description.key
async def test_switch_sense_no_capability_map_keeps_existing(
async def test_switch_sense_no_capability_map_removes_existing(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ufp: MockUFPFixture,
sensor_all: Sensor,
) -> None:
"""Without a capability map (Protect below 7.2) nothing is removed.
The console cannot say which capabilities it lacks, so an existing entity
must survive setup instead of being deleted on a guess.
"""
"""A switch created before its sensor reported no capability map is removed."""
existing = entity_registry.async_get_or_create(
Platform.SWITCH,
DOMAIN,
f"{sensor_all.mac}_motion",
config_entry=ufp.entry,
)
setup_public_sensor(ufp)
setup_public_sensor(ufp, capabilities=set())
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
assert entity_registry.async_get(existing.entity_id) is not None
assert entity_registry.async_get(existing.entity_id) is None
# The five sense settings the public API exposes, with the public-mock override
+6 -10
View File
@@ -370,8 +370,8 @@ def make_public_sensor(
never from real capture data. Each ``*`` override lets a test diverge from
the private value. The mount-derived enablement properties are computed from
the resolved mount type so a ``mount_type`` override stays consistent.
``capabilities`` mimics the capability map of newer firmware; ``None`` (the
default) models older firmware without a map, where every entity is created.
``capabilities`` is the sensor's capability map; ``None`` (the default)
advertises every capability.
"""
public = Mock(spec=PublicSensor)
public.id = sensor.id
@@ -391,12 +391,8 @@ def make_public_sensor(
if is_tampering_detected is None
else is_tampering_detected
)
public.has_feature_flags = capabilities is not None
public.supports = Mock(
side_effect=lambda capability: (
capabilities is not None and capability in capabilities
)
)
caps = set(SensorFeatureCapability) if capabilities is None else capabilities
public.supports = Mock(side_effect=lambda capability: capability in caps)
public.leak_settings = PublicSensorLeakSettings(
is_internal_enabled=leak_internal_enabled,
is_external_enabled=leak_external_enabled,
@@ -715,8 +711,8 @@ def setup_public_sensor(
Lookups go through the real ``PublicBootstrap.get``; the mirror resolves
against the private bootstrap at call time, so it is robust to ``init_entry``
regenerating device ids. ``capabilities`` is forwarded to the mirror to model
newer firmware with a capability map. Further keyword arguments are handed
regenerating device ids. ``capabilities`` is forwarded to the mirror as the
sensor's capability map. Further keyword arguments are handed
to ``make_public_sensor``, so a test can diverge a mirrored value.
"""
public_bootstrap = PublicBootstrap()