diff --git a/majordom_zigbee/controller.py b/majordom_zigbee/controller.py index 0997243..906c01b 100644 --- a/majordom_zigbee/controller.py +++ b/majordom_zigbee/controller.py @@ -67,6 +67,10 @@ _quirks_loaded = False +# A cluster the device lists but doesn't implement answers a read with a Default Response carrying +# UNSUPPORTED_CLUSTER, which zigpy fans out to every requested attribute. +_UNSUPPORTED_STATUSES = frozenset({ZCLStatus.UNSUPPORTED_ATTRIBUTE, ZCLStatus.UNSUPPORTED_CLUSTER}) + _SETTING_ENTITY_TYPES = frozenset({"config", "diagnostic"}) _SENSOR_PLATFORMS = frozenset({"sensor", "binary_sensor"}) @@ -172,14 +176,12 @@ def _check_zcl_failures( """ cluster_path = _zb_path(device, zbdevice, endpoint, cluster) unsupported = [ - attr_id - for attr_id, status in failures.items() - if attr_id is not None and status == ZCLStatus.UNSUPPORTED_ATTRIBUTE + attr_id for attr_id, status in failures.items() if attr_id is not None and status in _UNSUPPORTED_STATUSES ] errors = { attr_id: status for attr_id, status in failures.items() - if status != ZCLStatus.SUCCESS and status != ZCLStatus.UNSUPPORTED_ATTRIBUTE + if status != ZCLStatus.SUCCESS and status not in _UNSUPPORTED_STATUSES } for attr_id in unsupported: @@ -378,7 +380,24 @@ async def pair_device( # v2 QuirkBuilder entity judgment for this device (empty for non-quirked devices). quirk_ux = quirk_ux_map(zbdevice) for endpoint in zbdevice.non_zdo_endpoints: - for cluster in endpoint.clusters: + # Only server (input) clusters: output clusters are client-side, so their attributes + # live on the remote and fetch/send_command never reach them. + for cluster in endpoint.in_clusters.values(): + # Probe before mapping: a simple descriptor often lists clusters the firmware + # doesn't implement (e.g. Tuya relays with Metering/ElectricalMeasurement). The + # read marks unsupported attributes, so only what the device has gets exposed. + await self._read_cluster_attributes( + device, + zbdevice, + endpoint, + cluster, + self._readable_attribute_ids(cluster), + log_prefix="[PAIR]", + timeout=2, + ) + if self._is_unimplemented(cluster): + log.debug("[PAIR] skipping unimplemented %s", _zb_path(endpoint=endpoint, cluster=cluster)) + continue for attribute_id, attribute in cluster.attributes.items(): if cluster.is_attribute_unsupported(attribute_id): continue @@ -607,12 +626,17 @@ async def fetch(self, device: ZBDevice) -> None: t0 = asyncio.get_event_loop().time() for endpoint in zbdevice.non_zdo_endpoints: for cluster_id, cluster in endpoint.in_clusters.items(): - readable_ids = [ - attr_id for attr_id in cluster.attributes if not cluster.is_attribute_unsupported(attr_id) - ] attr_values = await self._read_cluster_attributes( - device, zbdevice, endpoint, cluster, readable_ids, log_prefix="[FETCH]", timeout=2 + device, + zbdevice, + endpoint, + cluster, + self._readable_attribute_ids(cluster), + log_prefix="[FETCH]", + timeout=2, ) + if self._is_unimplemented(cluster): + continue # pairing exposed no parameters for it for attribute_id in cluster.attributes: if cluster.is_attribute_unsupported(attribute_id): @@ -851,6 +875,15 @@ async def _read_cluster_attributes( ) return attr_values + @staticmethod + def _readable_attribute_ids(cluster: zigpy.zcl.Cluster) -> list[int]: + return [attr_id for attr_id in cluster.attributes if not cluster.is_attribute_unsupported(attr_id)] + + @classmethod + def _is_unimplemented(cls, cluster: zigpy.zcl.Cluster) -> bool: + """Listed in the simple descriptor, but the device reported every attribute unsupported.""" + return bool(cluster.attributes) and not cls._readable_attribute_ids(cluster) + def _create_task(self, coro) -> asyncio.Task: task = asyncio.create_task(coro) self._tasks.add(task) diff --git a/tests/conftest.py b/tests/conftest.py index 172c024..95c1b6f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,7 @@ PARAM_COMMAND_ID = "71f0a643-94d1-5def-b4ff-c5a394fafb63" # toggle command_1/6/2 PARAM_ATTRIBUTE_ID = "d20e6f38-6ad2-5f51-a7b0-0f812b58e5cd" # on_time attribute_1/6/16385 (Read|Write) MOCK_INPUT_CLUSTERS: list[int] = [6, 8] # OnOff, LevelControl; tests may monkeypatch it +MOCK_OUTPUT_CLUSTERS: list[int] = [] # client-side clusters; tests may monkeypatch it def _make_mock_zb_device(app: zigpy.application.ControllerApplication) -> zigpy.device.Device: @@ -61,6 +62,8 @@ def _make_mock_zb_device(app: zigpy.application.ControllerApplication) -> zigpy. ep.device_type = zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT for cluster_id in MOCK_INPUT_CLUSTERS: ep.add_input_cluster(cluster_id) + for cluster_id in MOCK_OUTPUT_CLUSTERS: + ep.add_output_cluster(cluster_id) return dev diff --git a/tests/test_controller.py b/tests/test_controller.py index 611bc09..911275f 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,7 +1,7 @@ """Unit tests driving ZigBeeController directly against the in-memory zigpy stub.""" from typing import Any, cast -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from uuid import UUID import conftest @@ -11,11 +11,14 @@ from majordom_integration_sdk.schemas.command import DeviceCommand from majordom_integration_sdk.schemas.parameter import Parameter, ParameterDataType from zigpy.zcl.clusters.closures import DoorLock, WindowCovering -from zigpy.zcl.clusters.general import LevelControl +from zigpy.zcl.clusters.general import LevelControl, OnOff +from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement from zigpy.zcl.clusters.hvac import Fan from zigpy.zcl.clusters.lighting import Color +from zigpy.zcl.clusters.smartenergy import Metering +from zigpy.zcl.foundation import Status as ZCLStatus -from majordom_zigbee.model import ZBParameter +from majordom_zigbee.model import ZBDeviceState, ZBParameter from majordom_zigbee.zigbee_spec import MAIN_PARAMETER_BY_CLUSTER @@ -287,3 +290,50 @@ async def test_attribute_enum_valid_values_map_value_to_label(zigbee, monkeypatc fan_mode, ) write_mock.assert_awaited_once_with({fan_mode_id: Fan.FanMode.On.value}) + + +# Tuya TS0001 dry-contact relay: the simple descriptor lists Metering and ElectricalMeasurement, +# but the firmware implements neither. Metering answers UNSUPPORTED_ATTRIBUTE per attribute, while +# ElectricalMeasurement is rejected as a whole cluster (a Default Response with UNSUPPORTED_CLUSTER). +_TS0001_INPUT_CLUSTERS = [0x0000, 0x0003, 0x0004, 0x0005, OnOff.cluster_id, Metering.cluster_id] +_TS0001_INPUT_CLUSTERS += [ElectricalMeasurement.cluster_id, 0xE000, 0xE001] +_TS0001_OUTPUT_CLUSTERS = [0x000A, 0x0019] # Time, Ota (client side) +_TS0001_UNSUPPORTED_STATUS = { + Metering.cluster_id: ZCLStatus.UNSUPPORTED_ATTRIBUTE, + ElectricalMeasurement.cluster_id: ZCLStatus.UNSUPPORTED_CLUSTER, +} + + +async def _ts0001_read_attributes(cluster: zigpy.zcl.Cluster, attributes: list[int], **_kwargs): + if (status := _TS0001_UNSUPPORTED_STATUS.get(cluster.cluster_id)) is not None: + return {}, dict.fromkeys(attributes, status) + return {}, {} + + +async def test_ts0001_exposes_only_supported_clusters(zigbee, monkeypatch): + monkeypatch.setattr(conftest, "MOCK_INPUT_CLUSTERS", _TS0001_INPUT_CLUSTERS) + monkeypatch.setattr(conftest, "MOCK_OUTPUT_CLUSTERS", _TS0001_OUTPUT_CLUSTERS) + controller, output, repository, _app = zigbee + with patch.object(zigpy.zcl.Cluster, "read_attributes", new=_ts0001_read_attributes): + discovery = await _pair(zigbee) + await _wait_for(lambda: bool(output.events)) # the background fetch after pairing + device = await _device(repository, discovery.id) + async with repository.session() as repo: + parameters = (await repo.state(discovery.id, as_=ZBDeviceState)).parameters + clusters = {p.integration_data.cluster_id for p in parameters} + + # unimplemented clusters expose nothing, client-side (output) clusters have no attributes to read, + # and the attribute-less Tuya clusters 0xE000/0xE001 (no quirk applied here) add nothing either + assert clusters == {0x0000, 0x0003, 0x0004, 0x0005, OnOff.cluster_id} + # fetch reports values only for the parameters pairing exposed + assert {e.parameter_id for e in output.events} <= {p.id for p in parameters} + + # the relay itself stays exposed and controllable + mapper = controller._mapper + on_off_id = str(mapper.attribute_parameter_uuid(discovery.id, 1, OnOff.cluster_id, OnOff.AttributeDefs.on_off.id)) + toggle_id = str(mapper.command_parameter_uuid(discovery.id, 1, OnOff.cluster_id, OnOff.ServerCommandDefs.toggle.id)) + assert {on_off_id, toggle_id} <= {str(p.id) for p in parameters} + toggle = await _param(repository, discovery.id, toggle_id) + await controller.send_command( + DeviceCommand(device_id=discovery.id, parameter_id=UUID(toggle_id), value=None), device, toggle + )