mirror of
https://github.com/home-assistant/core.git
synced 2026-10-25 07:45:12 -04:00
Gate the UniFi Protect sense config entities on the capability map (#177593)
This commit is contained in:
@@ -3,9 +3,12 @@
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from uiprotect.data import Sensor
|
||||
from uiprotect.data.devices import Camera, Chime
|
||||
from uiprotect.data.public_devices import SensorFeatureCapability
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.components.unifiprotect.button import SENSOR_BUTTONS
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
|
||||
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -15,8 +18,10 @@ from .utils import (
|
||||
adopt_devices,
|
||||
assert_entity_counts,
|
||||
enable_entity,
|
||||
ids_from_device_description,
|
||||
init_entry,
|
||||
remove_entities,
|
||||
setup_public_sensor,
|
||||
)
|
||||
|
||||
|
||||
@@ -143,3 +148,57 @@ async def test_adopt_button_removed(
|
||||
assert_entity_counts(hass, Platform.BUTTON, 4, 2)
|
||||
entity = entity_registry.async_get(entity_id)
|
||||
assert entity is None
|
||||
|
||||
|
||||
CLEAR_TAMPER = next(d for d in SENSOR_BUTTONS if d.key == "clear_tamper")
|
||||
|
||||
|
||||
async def test_button_sense_capability_creation_filter(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""The clear-tamper button is only created for a sensor advertising tampering."""
|
||||
setup_public_sensor(ufp, capabilities={SensorFeatureCapability.TEMPERATURE})
|
||||
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) is None
|
||||
|
||||
|
||||
async def test_button_sense_capability_registry_cleanup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""A console upgrade removes the clear-tamper button when unsupported."""
|
||||
stale = entity_registry.async_get_or_create(
|
||||
Platform.BUTTON,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_{CLEAR_TAMPER.key}",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
setup_public_sensor(ufp, capabilities={SensorFeatureCapability.TEMPERATURE})
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
|
||||
assert entity_registry.async_get(stale.entity_id) is None
|
||||
|
||||
|
||||
async def test_button_sense_no_capability_map_creates_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)
|
||||
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) is not None
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from uiprotect.data import Camera
|
||||
from uiprotect.data import Camera, Sensor
|
||||
|
||||
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
||||
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
|
||||
from homeassistant.components.unifiprotect.const import DOMAIN
|
||||
from homeassistant.components.unifiprotect.migrate import (
|
||||
SENSE_SETTING_MIRROR_BREAKS_IN,
|
||||
async_deprecate_sense_setting_mirrors,
|
||||
)
|
||||
from homeassistant.const import SERVICE_RELOAD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
@@ -16,7 +20,7 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .utils import MockUFPFixture, init_entry
|
||||
from .utils import MockUFPFixture, init_entry, setup_public_sensor
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
@@ -407,6 +411,211 @@ async def test_migrate_package_binary_sensor_removed(
|
||||
)
|
||||
|
||||
|
||||
async def test_migrate_sense_setting_mirrors_kept(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""The unused setting mirrors survive the deprecation without a repair."""
|
||||
existing = {
|
||||
(platform, key): entity_registry.async_get_or_create(
|
||||
platform,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_{key}",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
for platform, key in (
|
||||
(Platform.BINARY_SENSOR, "motion_enabled"),
|
||||
(Platform.BINARY_SENSOR, "temperature"),
|
||||
(Platform.BINARY_SENSOR, "humidity"),
|
||||
(Platform.BINARY_SENSOR, "light"),
|
||||
(Platform.BINARY_SENSOR, "alarm"),
|
||||
(Platform.SENSOR, "sensitivity"),
|
||||
)
|
||||
}
|
||||
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
|
||||
for (platform, key), entity in existing.items():
|
||||
assert entity_registry.async_get(entity.entity_id) is not None, (
|
||||
f"{platform}.{key}"
|
||||
)
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_{key}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_migrate_sense_setting_mirror_in_use(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""Deprecating a used setting mirror raises an actionable repair."""
|
||||
mirror = entity_registry.async_get_or_create(
|
||||
Platform.BINARY_SENSOR,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_alarm",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
await _load_automation(hass, mirror.entity_id)
|
||||
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
|
||||
assert entity_registry.async_get(mirror.entity_id) is not None
|
||||
issue = issue_registry.async_get_issue(
|
||||
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_alarm"
|
||||
)
|
||||
assert issue is not None
|
||||
assert issue.breaks_in_ha_version == SENSE_SETTING_MIRROR_BREAKS_IN
|
||||
assert issue.translation_placeholders["entity_id"] == mirror.entity_id
|
||||
replacement_id = entity_registry.async_get_entity_id(
|
||||
Platform.SWITCH, DOMAIN, f"{sensor_all.mac}_alarm"
|
||||
)
|
||||
assert issue.translation_placeholders["replacement"] == replacement_id
|
||||
|
||||
|
||||
async def test_migrate_sense_setting_mirror_repair_clears_when_unused(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""The deprecation repair goes away once the last usage is gone.
|
||||
|
||||
A removal repair has to persist, but the entity is still there, so the user
|
||||
can act on this one and it must not keep nagging afterwards.
|
||||
"""
|
||||
mirror = entity_registry.async_get_or_create(
|
||||
Platform.BINARY_SENSOR,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_alarm",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
assert entity_registry.async_get(mirror.entity_id) is not None
|
||||
|
||||
# The repair a previous run raised while the mirror was still in use.
|
||||
issue_id = f"sense_setting_mirror_deprecated_{sensor_all.mac}_alarm"
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
issue_id,
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version=SENSE_SETTING_MIRROR_BREAKS_IN,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="sense_setting_mirror_deprecated",
|
||||
translation_placeholders={
|
||||
"entity_id": mirror.entity_id,
|
||||
"replacement": "switch.test_sensor_alarm_sound_detection",
|
||||
"items": "* `automation.gone`\n",
|
||||
},
|
||||
)
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||
|
||||
async_deprecate_sense_setting_mirrors(hass, ufp.entry, ufp.api.bootstrap)
|
||||
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id) is None
|
||||
|
||||
|
||||
async def test_migrate_sense_setting_mirror_in_use_no_replacement(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""A device that cannot support the setting gets the no-replacement repair."""
|
||||
setup_public_sensor(ufp, capabilities=set())
|
||||
mirror = entity_registry.async_get_or_create(
|
||||
Platform.BINARY_SENSOR,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_alarm",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
await _load_automation(hass, mirror.entity_id)
|
||||
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
|
||||
assert entity_registry.async_get(mirror.entity_id) is not None
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(
|
||||
Platform.SWITCH, DOMAIN, f"{sensor_all.mac}_alarm"
|
||||
)
|
||||
is None
|
||||
)
|
||||
issue = issue_registry.async_get_issue(
|
||||
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_alarm"
|
||||
)
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "sense_setting_mirror_deprecated_no_replacement"
|
||||
assert issue.translation_placeholders["entity_id"] == mirror.entity_id
|
||||
assert "replacement" not in issue.translation_placeholders
|
||||
|
||||
|
||||
async def test_migrate_sense_setting_keys_scoped_to_sensors(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
doorbell: Camera,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""The deprecated keys are shared with camera and light, so scoping matters.
|
||||
|
||||
``motion_enabled`` and ``sensitivity`` also exist on cameras and lights, so a
|
||||
sensor has to be present for the migration to run at all and only the
|
||||
sensor's own mirror may be deprecated.
|
||||
"""
|
||||
camera_entities = [
|
||||
entity_registry.async_get_or_create(
|
||||
platform,
|
||||
DOMAIN,
|
||||
f"{doorbell.mac}_{key}",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
for platform, key in (
|
||||
(Platform.BINARY_SENSOR, "motion_enabled"),
|
||||
(Platform.SENSOR, "sensitivity"),
|
||||
)
|
||||
]
|
||||
sensor_entity = entity_registry.async_get_or_create(
|
||||
Platform.BINARY_SENSOR,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_motion_enabled",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
|
||||
# Both are used, so only the scoping decides which one gets a repair.
|
||||
await _load_automation(hass, sensor_entity.entity_id)
|
||||
for entity in camera_entities:
|
||||
await _load_automation(hass, entity.entity_id)
|
||||
|
||||
await init_entry(hass, ufp, [doorbell, sensor_all], regenerate_ids=False)
|
||||
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"sense_setting_mirror_deprecated_{sensor_all.mac}_motion_enabled"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
for entity in camera_entities:
|
||||
assert entity_registry.async_get(entity.entity_id) is not None
|
||||
assert (
|
||||
issue_registry.async_get_issue(
|
||||
DOMAIN, f"sense_setting_mirror_deprecated_{entity.unique_id}"
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
async def test_migrate_package_binary_sensor_removed_in_use(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
|
||||
@@ -10,11 +10,12 @@ from uiprotect.data import (
|
||||
DeviceState,
|
||||
IRLEDMode,
|
||||
Light,
|
||||
Permission,
|
||||
RingSetting,
|
||||
Sensor,
|
||||
)
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
|
||||
from homeassistant.components.unifiprotect.number import (
|
||||
CAMERA_NUMBERS,
|
||||
LIGHT_NUMBERS,
|
||||
@@ -439,6 +440,35 @@ async def test_number_sense_sensitivity_public_value(
|
||||
assert hass.states.get(entity_id).state == "42"
|
||||
|
||||
|
||||
async def test_number_sense_sensitivity_ignores_local_permissions(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""A read-only local user keeps the motion sensitivity number.
|
||||
|
||||
It writes through the API key, so the local user's write bit must not gate it.
|
||||
Its read-only mirror stays until the deprecation runs out.
|
||||
"""
|
||||
ufp.api.bootstrap.auth_user.all_permissions = [
|
||||
Permission.unifi_dict_to_dict({"rawPermission": "sensor:read:*"})
|
||||
]
|
||||
setup_public_sensor(ufp)
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.NUMBER, sensor_all, SENSE_NUMBERS[0]
|
||||
)
|
||||
assert entity_registry.async_get(entity_id) is not None
|
||||
assert (
|
||||
entity_registry.async_get_entity_id(
|
||||
Platform.SENSOR, DOMAIN, f"{sensor_all.mac}_sensitivity"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
async def test_number_sense_sensitivity_set(
|
||||
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
|
||||
) -> None:
|
||||
|
||||
@@ -10,19 +10,22 @@ from uiprotect.data import (
|
||||
Permission,
|
||||
PublicHdrMode,
|
||||
RecordingMode,
|
||||
Sensor,
|
||||
SmartDetectAudioType,
|
||||
SmartDetectObjectType,
|
||||
VideoMode,
|
||||
)
|
||||
from uiprotect.data.public_devices import SensorFeatureCapability
|
||||
from uiprotect.exceptions import ClientError, NotAuthorized
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION, DOMAIN
|
||||
from homeassistant.components.unifiprotect.switch import (
|
||||
ATTR_PREV_MIC,
|
||||
ATTR_PREV_RECORD,
|
||||
CAMERA_SWITCHES,
|
||||
LIGHT_SWITCHES,
|
||||
PRIVACY_MODE_SWITCH,
|
||||
SENSE_SWITCHES,
|
||||
ProtectSwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -47,10 +50,12 @@ from .utils import (
|
||||
init_entry,
|
||||
make_public_camera,
|
||||
make_public_light,
|
||||
make_public_sensor,
|
||||
public_device_ws_message,
|
||||
remove_entities,
|
||||
setup_public_camera,
|
||||
setup_public_light,
|
||||
setup_public_sensor,
|
||||
)
|
||||
|
||||
CAMERA_SWITCHES_BASIC = [
|
||||
@@ -871,3 +876,232 @@ async def test_switch_turn_on_not_authorized(
|
||||
await hass.services.async_call(
|
||||
"switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
|
||||
|
||||
# A USL Environmental reports these four and neither motion nor alarm-sound
|
||||
# detection, so none of its capabilities back a motion or alarm switch.
|
||||
_ENV_CAPABILITIES = {
|
||||
SensorFeatureCapability.TEMPERATURE,
|
||||
SensorFeatureCapability.HUMIDITY,
|
||||
SensorFeatureCapability.LIGHT,
|
||||
SensorFeatureCapability.WATER_LEAK,
|
||||
}
|
||||
|
||||
|
||||
async def test_switch_sense_capability_creation_filter(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""A capability map limits the config switches to the advertised capabilities."""
|
||||
setup_public_sensor(ufp, capabilities=_ENV_CAPABILITIES)
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
for key, created in (
|
||||
("status_light", True),
|
||||
("temperature", True),
|
||||
("humidity", True),
|
||||
("light", True),
|
||||
("motion", False),
|
||||
("alarm", False),
|
||||
):
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
assert (entity_registry.async_get(entity_id) is not None) is created, key
|
||||
|
||||
|
||||
async def test_switch_sense_capability_registry_cleanup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""A console upgrade removes registry entries for unsupported capabilities."""
|
||||
stale = entity_registry.async_get_or_create(
|
||||
Platform.SWITCH,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_motion",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
setup_public_sensor(ufp, capabilities=_ENV_CAPABILITIES)
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
|
||||
assert entity_registry.async_get(stale.entity_id) is None
|
||||
|
||||
|
||||
async def test_switch_sense_no_capability_map_creates_all(
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
async def test_switch_sense_no_capability_map_keeps_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.
|
||||
"""
|
||||
existing = entity_registry.async_get_or_create(
|
||||
Platform.SWITCH,
|
||||
DOMAIN,
|
||||
f"{sensor_all.mac}_motion",
|
||||
config_entry=ufp.entry,
|
||||
)
|
||||
setup_public_sensor(ufp)
|
||||
await init_entry(hass, ufp, [sensor_all], regenerate_ids=False)
|
||||
|
||||
assert entity_registry.async_get(existing.entity_id) is not None
|
||||
|
||||
|
||||
# The five sense settings the public API exposes, with the public-mock override
|
||||
# that flips them and the public setter each switch must write through.
|
||||
MIGRATED_SENSE_SWITCHES = [
|
||||
("motion", "motion_enabled", "set_motion_status_public"),
|
||||
("temperature", "temperature_enabled", "set_temperature_status_public"),
|
||||
("humidity", "humidity_enabled", "set_humidity_status_public"),
|
||||
("light", "light_enabled", "set_light_status_public"),
|
||||
("alarm", "alarm_enabled", "set_alarm_public"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("key", "public_kwarg", "set_method"), MIGRATED_SENSE_SWITCHES)
|
||||
async def test_switch_sense_public_value(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
key: str,
|
||||
public_kwarg: str,
|
||||
set_method: str,
|
||||
) -> None:
|
||||
"""Each migrated sense switch reads its state from the public object."""
|
||||
setup_public_sensor(ufp)
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_ON
|
||||
|
||||
# every setting is enabled on the private fixture, so a public OFF can only
|
||||
# come from the public object
|
||||
public = make_public_sensor(sensor_all, **{public_kwarg: False})
|
||||
ufp.devices_ws_subscription(public_device_ws_message(public))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("key", "public_kwarg", "set_method"), MIGRATED_SENSE_SWITCHES)
|
||||
async def test_switch_sense_set_public(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
key: str,
|
||||
public_kwarg: str,
|
||||
set_method: str,
|
||||
) -> None:
|
||||
"""Each migrated sense switch writes through the public API."""
|
||||
setup_public_sensor(ufp)
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
|
||||
with patch_ufp_method(
|
||||
sensor_all, set_method, new_callable=AsyncMock
|
||||
) as mock_method:
|
||||
await hass.services.async_call(
|
||||
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
mock_method.assert_called_once_with(False)
|
||||
|
||||
|
||||
async def test_switch_sense_unavailable_without_public(
|
||||
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
|
||||
) -> None:
|
||||
"""A migrated sense switch is unavailable without a public object."""
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == "motion")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_switch_sense_status_light_stays_private(
|
||||
hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor
|
||||
) -> None:
|
||||
"""The status light has no public counterpart, so it reads the private object.
|
||||
|
||||
Unlike the migrated switches it must stay usable without a public object.
|
||||
"""
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == "status_light")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
assert hass.states.get(entity_id).state == STATE_ON
|
||||
|
||||
with patch_ufp_method(
|
||||
sensor_all, "set_status_light", new_callable=AsyncMock
|
||||
) as mock_method:
|
||||
await hass.services.async_call(
|
||||
"switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
mock_method.assert_called_once_with(False)
|
||||
|
||||
|
||||
async def test_switch_sense_public_switches_ignore_local_permissions(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
sensor_all: Sensor,
|
||||
) -> None:
|
||||
"""A read-only local user keeps the migrated switches but not the private one.
|
||||
|
||||
The migrated switches write through the API key, so the local user's write
|
||||
bit must not gate them; the status light still uses a private setter and
|
||||
stays behind PermRequired.WRITE.
|
||||
"""
|
||||
ufp.api.bootstrap.auth_user.all_permissions = [
|
||||
Permission.unifi_dict_to_dict({"rawPermission": "sensor:read:*"})
|
||||
]
|
||||
setup_public_sensor(ufp)
|
||||
await init_entry(hass, ufp, [sensor_all])
|
||||
|
||||
for key, _public_kwarg, _set_method in MIGRATED_SENSE_SWITCHES:
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == key)
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
assert entity_registry.async_get(entity_id) is not None, key
|
||||
|
||||
description = next(d for d in SENSE_SWITCHES if d.key == "status_light")
|
||||
_, entity_id = await ids_from_device_description(
|
||||
hass, Platform.SWITCH, sensor_all, description
|
||||
)
|
||||
assert entity_registry.async_get(entity_id) is None
|
||||
|
||||
@@ -38,8 +38,10 @@ from uiprotect.data.public_devices import (
|
||||
PublicLightModeSettings,
|
||||
PublicOsdSettings,
|
||||
PublicSensor,
|
||||
PublicSensorAlarmSettingsRead,
|
||||
PublicSensorLeakSettings,
|
||||
PublicSensorMotionSettingsRead,
|
||||
PublicSensorThresholdSettings,
|
||||
PublicSmartDetectSettings,
|
||||
PublicWirelessBatteryStatus,
|
||||
PublicWirelessConnectionState,
|
||||
@@ -267,6 +269,10 @@ def make_public_sensor(
|
||||
is_motion_detected: bool | None = None,
|
||||
motion_enabled: bool | None = None,
|
||||
motion_sensitivity: int | None = None,
|
||||
temperature_enabled: bool | None = None,
|
||||
humidity_enabled: bool | None = None,
|
||||
light_enabled: bool | None = None,
|
||||
alarm_enabled: bool | None = None,
|
||||
mount_type: MountType | None = None,
|
||||
is_opened: bool | None = None,
|
||||
is_leak_detected: bool | None = None,
|
||||
@@ -332,6 +338,30 @@ def make_public_sensor(
|
||||
else motion_sensitivity
|
||||
),
|
||||
)
|
||||
public.temperature_settings = PublicSensorThresholdSettings(
|
||||
is_enabled=(
|
||||
sensor.temperature_settings.is_enabled
|
||||
if temperature_enabled is None
|
||||
else temperature_enabled
|
||||
)
|
||||
)
|
||||
public.humidity_settings = PublicSensorThresholdSettings(
|
||||
is_enabled=(
|
||||
sensor.humidity_settings.is_enabled
|
||||
if humidity_enabled is None
|
||||
else humidity_enabled
|
||||
)
|
||||
)
|
||||
public.light_settings = PublicSensorThresholdSettings(
|
||||
is_enabled=(
|
||||
sensor.light_settings.is_enabled if light_enabled is None else light_enabled
|
||||
)
|
||||
)
|
||||
public.alarm_settings = PublicSensorAlarmSettingsRead(
|
||||
is_enabled=(
|
||||
sensor.alarm_settings.is_enabled if alarm_enabled is None else alarm_enabled
|
||||
)
|
||||
)
|
||||
public.wireless_connection_state = PublicWirelessConnectionState(
|
||||
battery_status=PublicWirelessBatteryStatus(
|
||||
percentage=(
|
||||
|
||||
Reference in New Issue
Block a user