core/tests/components/zha/test_discover.py

487 lines
18 KiB
Python

"""Test ZHA device discovery."""
from collections.abc import Callable
import re
from typing import Any
from unittest import mock
from unittest.mock import AsyncMock, Mock, patch
import pytest
from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC
import zigpy.profiles.zha
import zigpy.quirks
import zigpy.types
import zigpy.zcl.clusters.closures
import zigpy.zcl.clusters.general
import zigpy.zcl.clusters.security
import zigpy.zcl.foundation as zcl_f
import homeassistant.components.zha.core.cluster_handlers as cluster_handlers
import homeassistant.components.zha.core.const as zha_const
from homeassistant.components.zha.core.device import ZHADevice
import homeassistant.components.zha.core.discovery as disc
from homeassistant.components.zha.core.endpoint import Endpoint
from homeassistant.components.zha.core.helpers import get_zha_gateway
import homeassistant.components.zha.core.registries as zha_regs
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
import homeassistant.helpers.entity_registry as er
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from .zha_devices_list import (
DEV_SIG_ATTRIBUTES,
DEV_SIG_CLUSTER_HANDLERS,
DEV_SIG_ENT_MAP,
DEV_SIG_ENT_MAP_CLASS,
DEV_SIG_ENT_MAP_ID,
DEV_SIG_EVT_CLUSTER_HANDLERS,
DEVICES,
)
NO_TAIL_ID = re.compile("_\\d$")
UNIQUE_ID_HD = re.compile(r"^(([\da-fA-F]{2}:){7}[\da-fA-F]{2}-\d{1,3})", re.X)
IGNORE_SUFFIXES = [
zigpy.zcl.clusters.general.OnOff.StartUpOnOff.__name__,
"on_off_transition_time",
"on_level",
"on_transition_time",
"off_transition_time",
"default_move_rate",
"start_up_current_level",
]
def contains_ignored_suffix(unique_id: str) -> bool:
"""Return true if the unique_id ends with an ignored suffix."""
for suffix in IGNORE_SUFFIXES:
if suffix.lower() in unique_id.lower():
return True
return False
@patch(
"zigpy.zcl.clusters.general.Identify.request",
new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]),
)
# We do this here because we are testing ZHA discovery logic. Point being we want to ensure that
# all discovered entities are dispatched for creation. In order to test this we need the entities
# added to HA. So we ensure that they are all enabled even though they won't necessarily be in reality
# at runtime
@patch(
"homeassistant.components.zha.entity.ZhaEntity.entity_registry_enabled_default",
new=Mock(return_value=True),
)
@pytest.mark.parametrize("device", DEVICES)
async def test_devices(
device,
hass_disable_services,
zigpy_device_mock,
zha_device_joined_restored,
) -> None:
"""Test device discovery."""
entity_registry = er.async_get(hass_disable_services)
zigpy_device = zigpy_device_mock(
endpoints=device[SIG_ENDPOINTS],
ieee="00:11:22:33:44:55:66:77",
manufacturer=device[SIG_MANUFACTURER],
model=device[SIG_MODEL],
node_descriptor=device[SIG_NODE_DESC],
attributes=device.get(DEV_SIG_ATTRIBUTES),
patch_cluster=False,
)
cluster_identify = _get_first_identify_cluster(zigpy_device)
if cluster_identify:
cluster_identify.request.reset_mock()
orig_new_entity = Endpoint.async_new_entity
_dispatch = mock.MagicMock(wraps=orig_new_entity)
try:
Endpoint.async_new_entity = lambda *a, **kw: _dispatch(*a, **kw)
zha_dev = await zha_device_joined_restored(zigpy_device)
await hass_disable_services.async_block_till_done()
finally:
Endpoint.async_new_entity = orig_new_entity
if cluster_identify:
# We only identify on join
should_identify = (
zha_device_joined_restored.name == "zha_device_joined"
and not zigpy_device.skip_configuration
)
if should_identify:
assert cluster_identify.request.mock_calls == [
mock.call(
False,
cluster_identify.commands_by_name["trigger_effect"].id,
cluster_identify.commands_by_name["trigger_effect"].schema,
effect_id=zigpy.zcl.clusters.general.Identify.EffectIdentifier.Okay,
effect_variant=(
zigpy.zcl.clusters.general.Identify.EffectVariant.Default
),
expect_reply=True,
manufacturer=None,
tsn=None,
)
]
else:
assert cluster_identify.request.mock_calls == []
event_cluster_handlers = {
ch.id
for endpoint in zha_dev._endpoints.values()
for ch in endpoint.client_cluster_handlers.values()
}
assert event_cluster_handlers == set(device[DEV_SIG_EVT_CLUSTER_HANDLERS])
# we need to probe the class create entity factory so we need to reset this to get accurate results
zha_regs.ZHA_ENTITIES.clean_up()
# build a dict of entity_class -> (platform, unique_id, cluster_handlers) tuple
ha_ent_info = {}
created_entity_count = 0
for call in _dispatch.call_args_list:
_, platform, entity_cls, unique_id, cluster_handlers = call[0]
# the factory can return None. We filter these out to get an accurate created entity count
response = entity_cls.create_entity(unique_id, zha_dev, cluster_handlers)
if response and not contains_ignored_suffix(response.unique_id):
created_entity_count += 1
unique_id_head = UNIQUE_ID_HD.match(unique_id).group(
0
) # ieee + endpoint_id
ha_ent_info[(unique_id_head, entity_cls.__name__)] = (
platform,
unique_id,
cluster_handlers,
)
for comp_id, ent_info in device[DEV_SIG_ENT_MAP].items():
platform, unique_id = comp_id
no_tail_id = NO_TAIL_ID.sub("", ent_info[DEV_SIG_ENT_MAP_ID])
ha_entity_id = entity_registry.async_get_entity_id(platform, "zha", unique_id)
assert ha_entity_id is not None
assert ha_entity_id.startswith(no_tail_id)
test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS]
test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0)
assert (test_unique_id_head, test_ent_class) in ha_ent_info
ha_comp, ha_unique_id, ha_cluster_handlers = ha_ent_info[
(test_unique_id_head, test_ent_class)
]
assert platform is ha_comp.value
# unique_id used for discover is the same for "multi entities"
assert unique_id.startswith(ha_unique_id)
assert {ch.name for ch in ha_cluster_handlers} == set(
ent_info[DEV_SIG_CLUSTER_HANDLERS]
)
assert created_entity_count == len(device[DEV_SIG_ENT_MAP])
entity_ids = hass_disable_services.states.async_entity_ids()
await hass_disable_services.async_block_till_done()
zha_entity_ids = {
ent
for ent in entity_ids
if not contains_ignored_suffix(ent) and ent.split(".")[0] in zha_const.PLATFORMS
}
assert zha_entity_ids == {
e[DEV_SIG_ENT_MAP_ID] for e in device[DEV_SIG_ENT_MAP].values()
}
def _get_first_identify_cluster(zigpy_device):
for endpoint in list(zigpy_device.endpoints.values())[1:]:
if hasattr(endpoint, "identify"):
return endpoint.identify
@mock.patch(
"homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_device_type"
)
@mock.patch(
"homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_cluster_id"
)
def test_discover_entities(m1, m2) -> None:
"""Test discover endpoint class method."""
endpoint = mock.MagicMock()
disc.PROBE.discover_entities(endpoint)
assert m1.call_count == 1
assert m1.call_args[0][0] is endpoint
assert m2.call_count == 1
assert m2.call_args[0][0] is endpoint
@pytest.mark.parametrize(
("device_type", "platform", "hit"),
[
(zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT, Platform.LIGHT, True),
(zigpy.profiles.zha.DeviceType.ON_OFF_BALLAST, Platform.SWITCH, True),
(zigpy.profiles.zha.DeviceType.SMART_PLUG, Platform.SWITCH, True),
(0xFFFF, None, False),
],
)
def test_discover_by_device_type(device_type, platform, hit) -> None:
"""Test entity discovery by device type."""
endpoint = mock.MagicMock(spec_set=Endpoint)
ep_mock = mock.PropertyMock()
ep_mock.return_value.profile_id = 0x0104
ep_mock.return_value.device_type = device_type
type(endpoint).zigpy_endpoint = ep_mock
get_entity_mock = mock.MagicMock(
return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
)
with mock.patch(
"homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
get_entity_mock,
):
disc.PROBE.discover_by_device_type(endpoint)
if hit:
assert get_entity_mock.call_count == 1
assert endpoint.claim_cluster_handlers.call_count == 1
assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed
assert endpoint.async_new_entity.call_count == 1
assert endpoint.async_new_entity.call_args[0][0] == platform
assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
def test_discover_by_device_type_override() -> None:
"""Test entity discovery by device type overriding."""
endpoint = mock.MagicMock(spec_set=Endpoint)
ep_mock = mock.PropertyMock()
ep_mock.return_value.profile_id = 0x0104
ep_mock.return_value.device_type = 0x0100
type(endpoint).zigpy_endpoint = ep_mock
overrides = {endpoint.unique_id: {"type": Platform.SWITCH}}
get_entity_mock = mock.MagicMock(
return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
)
with mock.patch(
"homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
get_entity_mock,
), mock.patch.dict(disc.PROBE._device_configs, overrides, clear=True):
disc.PROBE.discover_by_device_type(endpoint)
assert get_entity_mock.call_count == 1
assert endpoint.claim_cluster_handlers.call_count == 1
assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed
assert endpoint.async_new_entity.call_count == 1
assert endpoint.async_new_entity.call_args[0][0] == Platform.SWITCH
assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
def test_discover_probe_single_cluster() -> None:
"""Test entity discovery by single cluster."""
endpoint = mock.MagicMock(spec_set=Endpoint)
ep_mock = mock.PropertyMock()
ep_mock.return_value.profile_id = 0x0104
ep_mock.return_value.device_type = 0x0100
type(endpoint).zigpy_endpoint = ep_mock
get_entity_mock = mock.MagicMock(
return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed)
)
cluster_handler_mock = mock.MagicMock(spec_set=cluster_handlers.ClusterHandler)
with mock.patch(
"homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity",
get_entity_mock,
):
disc.PROBE.probe_single_cluster(Platform.SWITCH, cluster_handler_mock, endpoint)
assert get_entity_mock.call_count == 1
assert endpoint.claim_cluster_handlers.call_count == 1
assert endpoint.claim_cluster_handlers.call_args[0][0] is mock.sentinel.claimed
assert endpoint.async_new_entity.call_count == 1
assert endpoint.async_new_entity.call_args[0][0] == Platform.SWITCH
assert endpoint.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls
assert endpoint.async_new_entity.call_args[0][3] == mock.sentinel.claimed
@pytest.mark.parametrize("device_info", DEVICES)
async def test_discover_endpoint(
device_info: dict[str, Any],
zha_device_mock: Callable[..., ZHADevice],
hass: HomeAssistant,
) -> None:
"""Test device discovery."""
with mock.patch(
"homeassistant.components.zha.core.endpoint.Endpoint.async_new_entity"
) as new_ent:
device = zha_device_mock(
device_info[SIG_ENDPOINTS],
manufacturer=device_info[SIG_MANUFACTURER],
model=device_info[SIG_MODEL],
node_desc=device_info[SIG_NODE_DESC],
patch_cluster=True,
)
assert device_info[DEV_SIG_EVT_CLUSTER_HANDLERS] == sorted(
ch.id
for endpoint in device._endpoints.values()
for ch in endpoint.client_cluster_handlers.values()
)
# build a dict of entity_class -> (platform, unique_id, cluster_handlers) tuple
ha_ent_info = {}
for call in new_ent.call_args_list:
platform, entity_cls, unique_id, cluster_handlers = call[0]
if not contains_ignored_suffix(unique_id):
unique_id_head = UNIQUE_ID_HD.match(unique_id).group(
0
) # ieee + endpoint_id
ha_ent_info[(unique_id_head, entity_cls.__name__)] = (
platform,
unique_id,
cluster_handlers,
)
for platform_id, ent_info in device_info[DEV_SIG_ENT_MAP].items():
platform, unique_id = platform_id
test_ent_class = ent_info[DEV_SIG_ENT_MAP_CLASS]
test_unique_id_head = UNIQUE_ID_HD.match(unique_id).group(0)
assert (test_unique_id_head, test_ent_class) in ha_ent_info
entity_platform, entity_unique_id, entity_cluster_handlers = ha_ent_info[
(test_unique_id_head, test_ent_class)
]
assert platform is entity_platform.value
# unique_id used for discover is the same for "multi entities"
assert unique_id.startswith(entity_unique_id)
assert {ch.name for ch in entity_cluster_handlers} == set(
ent_info[DEV_SIG_CLUSTER_HANDLERS]
)
device.async_cleanup_handles()
def _ch_mock(cluster):
"""Return mock of a cluster_handler with a cluster."""
cluster_handler = mock.MagicMock()
type(cluster_handler).cluster = mock.PropertyMock(
return_value=cluster(mock.MagicMock())
)
return cluster_handler
@mock.patch(
(
"homeassistant.components.zha.core.discovery.ProbeEndpoint"
".handle_on_off_output_cluster_exception"
),
new=mock.MagicMock(),
)
@mock.patch(
"homeassistant.components.zha.core.discovery.ProbeEndpoint.probe_single_cluster"
)
def _test_single_input_cluster_device_class(probe_mock):
"""Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
door_ch = _ch_mock(zigpy.zcl.clusters.closures.DoorLock)
cover_ch = _ch_mock(zigpy.zcl.clusters.closures.WindowCovering)
multistate_ch = _ch_mock(zigpy.zcl.clusters.general.MultistateInput)
class QuirkedIAS(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.security.IasZone):
pass
ias_ch = _ch_mock(QuirkedIAS)
class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput):
pass
analog_ch = _ch_mock(_Analog)
endpoint = mock.MagicMock(spec_set=Endpoint)
endpoint.unclaimed_cluster_handlers.return_value = [
door_ch,
cover_ch,
multistate_ch,
ias_ch,
]
disc.ProbeEndpoint().discover_by_cluster_id(endpoint)
assert probe_mock.call_count == len(endpoint.unclaimed_cluster_handlers())
probes = (
(Platform.LOCK, door_ch),
(Platform.COVER, cover_ch),
(Platform.SENSOR, multistate_ch),
(Platform.BINARY_SENSOR, ias_ch),
(Platform.SENSOR, analog_ch),
)
for call, details in zip(probe_mock.call_args_list, probes):
platform, ch = details
assert call[0][0] == platform
assert call[0][1] == ch
def test_single_input_cluster_device_class_by_cluster_class() -> None:
"""Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class."""
mock_reg = {
zigpy.zcl.clusters.closures.DoorLock.cluster_id: Platform.LOCK,
zigpy.zcl.clusters.closures.WindowCovering.cluster_id: Platform.COVER,
zigpy.zcl.clusters.general.AnalogInput: Platform.SENSOR,
zigpy.zcl.clusters.general.MultistateInput: Platform.SENSOR,
zigpy.zcl.clusters.security.IasZone: Platform.BINARY_SENSOR,
}
with mock.patch.dict(
zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, mock_reg, clear=True
):
_test_single_input_cluster_device_class()
@pytest.mark.parametrize(
("override", "entity_id"),
[
(None, "light.manufacturer_model_light"),
("switch", "switch.manufacturer_model_switch"),
],
)
async def test_device_override(
hass_disable_services, zigpy_device_mock, setup_zha, override, entity_id
) -> None:
"""Test device discovery override."""
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT,
"endpoint_id": 1,
SIG_EP_INPUT: [0, 3, 4, 5, 6, 8, 768, 2821, 64513],
SIG_EP_OUTPUT: [25],
SIG_EP_PROFILE: 260,
}
},
"00:11:22:33:44:55:66:77",
"manufacturer",
"model",
patch_cluster=False,
)
if override is not None:
override = {"device_config": {"00:11:22:33:44:55:66:77-1": {"type": override}}}
await setup_zha(override)
assert hass_disable_services.states.get(entity_id) is None
zha_gateway = get_zha_gateway(hass_disable_services)
await zha_gateway.async_device_initialized(zigpy_device)
await hass_disable_services.async_block_till_done()
assert hass_disable_services.states.get(entity_id) is not None
async def test_group_probe_cleanup_called(
hass_disable_services, setup_zha, config_entry
) -> None:
"""Test cleanup happens when ZHA is unloaded."""
await setup_zha()
disc.GROUP_PROBE.cleanup = mock.Mock(wraps=disc.GROUP_PROBE.cleanup)
await config_entry.async_unload(hass_disable_services)
await hass_disable_services.async_block_till_done()
disc.GROUP_PROBE.cleanup.assert_called()