Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions majordom_zigbee/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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


Expand Down
56 changes: 53 additions & 3 deletions tests/test_controller.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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
)
Loading