Files
core/tests/components/zha/test_dynamic_entities.py
T

330 lines
12 KiB
Python

"""Test ZHA dynamic entity lifecycle (runtime add/remove and reference cleanup)."""
from collections.abc import Callable, Coroutine, Generator
from unittest.mock import patch
import pytest
from zha.application import Platform as ZhaPlatform
from zha.application.platforms import PlatformEntity
from zha.quirks import DEVICE_REGISTRY
from zha.zigbee.device import DeviceEntityAddedEvent, DeviceEntityRemovedEvent
from zhaquirks.builder import QuirkBuilder
from zigpy.device import Device
from zigpy.profiles import zha
from zigpy.zcl.clusters import general
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
get_zha_data,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import find_entity_id
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@pytest.fixture(autouse=True)
def platforms_only() -> Generator[None]:
"""Only set up the switch + binary_sensor platforms to speed up tests."""
with patch(
"homeassistant.components.zha.PLATFORMS",
(Platform.SWITCH, Platform.BINARY_SENSOR),
):
yield
async def _create_device(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., Device],
) -> ZHADeviceProxy:
"""Create a device exposing a switch and a binary_sensor that share a unique_id.
OnOff input on an occupancy-sensor device type yields a switch entity
and a binary_sensor entity (Opening) keyed on the same `(ieee, ep, 6)`
base unique_id but on different platforms - a useful collision shape
for verifying that runtime add/remove events are scoped per platform.
"""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.Basic.cluster_id,
general.OnOff.cluster_id,
general.Groups.cluster_id,
],
SIG_EP_OUTPUT: [general.OnOff.cluster_id],
SIG_EP_TYPE: zha.DeviceType.OCCUPANCY_SENSOR,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee="01:2d:6f:00:0a:90:69:e8",
node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00",
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
assert zha_device_proxy is not None
return zha_device_proxy
def _get_platform_entity(
zha_device_proxy: ZHADeviceProxy, platform: Platform
) -> PlatformEntity:
"""Return the underlying ZHA platform entity for the given platform."""
return next(
entity
for entity in zha_device_proxy.device.platform_entities.values()
if platform == entity.PLATFORM
)
async def test_dynamic_entity_lifecycle(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., Device],
) -> None:
"""Test the full hard-remove -> re-add -> soft-remove cycle.
Also confirms that the binary_sensor sharing the same unique_id but on a
different platform is never disturbed - the (platform, unique_id) tuple
is the ZHA entity identity.
"""
zha_device_proxy = await _create_device(hass, setup_zha, zigpy_device_mock)
platform_entity = _get_platform_entity(zha_device_proxy, Platform.SWITCH)
binary_sensor_entity = _get_platform_entity(
zha_device_proxy, Platform.BINARY_SENSOR
)
entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass)
binary_sensor_id = find_entity_id(Platform.BINARY_SENSOR, zha_device_proxy, hass)
assert entity_id is not None
assert binary_sensor_id is not None
# Same unique_id, different platforms - the collision shape we care about.
assert platform_entity.unique_id == binary_sensor_entity.unique_id
assert platform_entity.PLATFORM != binary_sensor_entity.PLATFORM
switch_state_before = hass.states.get(entity_id)
binary_sensor_state_before = hass.states.get(binary_sensor_id)
assert switch_state_before is not None
assert switch_state_before.state == STATE_OFF
assert binary_sensor_state_before is not None
assert binary_sensor_state_before.state == STATE_OFF
# Hard remove: state and registry entry both go away.
zha_device_proxy.device.emit(
DeviceEntityRemovedEvent.event_type,
DeviceEntityRemovedEvent(
platform=ZhaPlatform.SWITCH,
unique_id=platform_entity.unique_id,
remove=True,
),
)
await hass.async_block_till_done()
registry = er.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures
assert registry.async_get(entity_id) is None
assert hass.states.get(entity_id) is None
# The binary_sensor with the same unique_id is untouched.
assert registry.async_get(binary_sensor_id) is not None
assert hass.states.get(binary_sensor_id) == binary_sensor_state_before
ha_zha_data = get_zha_data(hass)
assert len(ha_zha_data.platforms[Platform.SWITCH]) == 0
# Re-add: emitter exercises the on_all_events -> _handle_event_protocol
# -> handler wiring; the entity reappears as a HA state.
zha_device_proxy.device.emit(
DeviceEntityAddedEvent.event_type,
DeviceEntityAddedEvent(
platform=ZhaPlatform.SWITCH,
unique_id=platform_entity.unique_id,
),
)
await hass.async_block_till_done()
# The platform listener drained the queue when SIGNAL_ADD_ENTITIES fired.
assert len(ha_zha_data.platforms[Platform.SWITCH]) == 0
switch_state_after = hass.states.get(entity_id)
assert switch_state_after is not None
assert switch_state_after.state == switch_state_before.state
assert hass.states.get(binary_sensor_id) == binary_sensor_state_before
# Exactly one entity reference is tracked; the remove + re-add cycle did
# not leave a stale entry behind.
gateway_proxy = get_zha_gateway_proxy(hass)
matching_refs = [
ref
for ref in gateway_proxy.ha_entity_refs[zha_device_proxy.device.ieee]
if ref.ha_entity_id == entity_id
]
assert len(matching_refs) == 1
# Soft remove: registry entry is kept, state goes unavailable.
entry = registry.async_get(entity_id)
assert entry is not None
zha_device_proxy.device.emit(
DeviceEntityRemovedEvent.event_type,
DeviceEntityRemovedEvent(
platform=ZhaPlatform.SWITCH,
unique_id=entry.unique_id,
remove=False,
),
)
await hass.async_block_till_done()
assert registry.async_get(entity_id) is not None
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# The binary_sensor with the same unique_id is still untouched.
assert hass.states.get(binary_sensor_id) == binary_sensor_state_before
async def test_unknown_unique_id_is_noop(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., Device],
) -> None:
"""Test that add/remove events with an unknown unique_id are no-ops."""
zha_device_proxy = await _create_device(hass, setup_zha, zigpy_device_mock)
entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass)
assert entity_id is not None
ha_zha_data = get_zha_data(hass)
registry = er.async_get(hass) # pylint: disable=home-assistant-tests-registry-fixtures
# Added: nothing queued, no dispatcher signal fired.
with patch(
"homeassistant.components.zha.helpers.async_dispatcher_send"
) as mock_dispatch:
zha_device_proxy.device.emit(
DeviceEntityAddedEvent.event_type,
DeviceEntityAddedEvent(
platform=ZhaPlatform.SWITCH,
unique_id="nonexistent_unique_id",
),
)
await hass.async_block_till_done()
assert len(ha_zha_data.platforms[Platform.SWITCH]) == 0
mock_dispatch.assert_not_called()
# Removed (both flag values): the existing entity is untouched.
for remove in (False, True):
zha_device_proxy.device.emit(
DeviceEntityRemovedEvent.event_type,
DeviceEntityRemovedEvent(
platform=ZhaPlatform.SWITCH,
unique_id="nonexistent_unique_id",
remove=remove,
),
)
await hass.async_block_till_done()
assert registry.async_get(entity_id) is not None
assert hass.states.get(entity_id) is not None
async def test_remove_entity_reference_when_ieee_already_cleared(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., Device],
) -> None:
"""Test entity teardown when _ha_entity_refs already lost the ieee.
Simulates the race where ``handle_device_removed`` pops the ieee entry
before the entity finishes tearing down. The early-return guard must
keep the popped key out of the dict.
"""
zha_device_proxy = await _create_device(hass, setup_zha, zigpy_device_mock)
entity_id = find_entity_id(Platform.SWITCH, zha_device_proxy, hass)
assert entity_id is not None
gateway_proxy = get_zha_gateway_proxy(hass)
ieee = zha_device_proxy.device.ieee
gateway_proxy._ha_entity_refs.pop(ieee, None)
er.async_get(hass).async_remove(entity_id) # pylint: disable=home-assistant-tests-registry-fixtures
await hass.async_block_till_done()
assert ieee not in gateway_proxy._ha_entity_refs
async def test_reinterview_quirk_class_swap_rebinds_proxy(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., Device],
) -> None:
"""A re-interview that changes the ZHA `Device` class re-binds the proxy."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy = get_zha_gateway_proxy(hass)
endpoints = {
1: {
SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.ON_OFF_SWITCH,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
}
# Join with no matching quirk: resolves to the base `Device` class.
zigpy_device = zigpy_device_mock(
endpoints,
ieee="01:2d:6f:00:0a:90:69:e8",
manufacturer="Fake",
model="Model_A",
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
proxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
assert proxy is not None
original_device = proxy.device
assert not original_device.quirk_applied
switch_id = find_entity_id(Platform.SWITCH, proxy, hass)
assert switch_id is not None
assert hass.states.get(switch_id).state == STATE_OFF
with DEVICE_REGISTRY.preserve_state():
# Register a v2 quirk for the re-interviewed model -> `QuirkV2Device`.
(
QuirkBuilder("Fake", "Model_B")
.friendly_name(model="Quirk B", manufacturer="Fake")
.add_to_registry()
)
reinterviewed = zigpy_device_mock(
endpoints,
ieee="01:2d:6f:00:0a:90:69:e8",
manufacturer="Fake",
model="Model_B",
)
gateway.device_reinterviewed(reinterviewed)
await hass.async_block_till_done(wait_background_tasks=True)
# The `Device` object was replaced by a quirked one
new_device = gateway.get_device(zigpy_device.ieee)
assert new_device is not original_device
assert new_device.quirk_applied
# The proxy followed the swap
assert proxy.device is new_device
# And the switch entity was re-materialized on the new object.
assert hass.states.get(switch_id).state == STATE_OFF