Skip to content

Commit 4ecc0c6

Browse files
authored
Add support for Tuya DPs mapped to multiple cluster attributes (#3643)
1 parent 4e96fae commit 4ecc0c6

File tree

4 files changed

+160
-67
lines changed

4 files changed

+160
-67
lines changed

tests/test_tuya_builder.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for TuyaQuirkBuilder."""
22

3+
from collections.abc import ByteString
34
import datetime
45
from unittest import mock
56

@@ -9,13 +10,19 @@
910
import zigpy.types as t
1011
from zigpy.zcl import foundation
1112
from zigpy.zcl.clusters.general import Basic
13+
from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
1214

1315
from tests.common import ClusterListener, MockDatetime, wait_for_zigpy_tasks
1416
import zhaquirks
1517
from zhaquirks.const import BatterySize
1618
from zhaquirks.tuya import (
1719
TUYA_QUERY_DATA,
1820
TUYA_SET_TIME,
21+
DPToAttributeMapping,
22+
TuyaCommand,
23+
TuyaData,
24+
TuyaDatapointData,
25+
TuyaLocalCluster,
1926
TuyaPowerConfigurationCluster,
2027
TuyaPowerConfigurationCluster2AAA,
2128
)
@@ -156,6 +163,18 @@ class TestEnum(t.enum8):
156163
class ModTuyaMCUCluster(TuyaMCUCluster):
157164
"""Modified Cluster."""
158165

166+
class Tuya3PhaseElectricalMeasurement(ElectricalMeasurement, TuyaLocalCluster):
167+
"""Tuya Electrical Measurement cluster."""
168+
169+
def dpToPower(data: ByteString) -> int:
170+
return data[0]
171+
172+
def dpToCurrent(data: ByteString) -> int:
173+
return data[1]
174+
175+
def dpToVoltage(data: ByteString) -> int:
176+
return data[2]
177+
159178
entry = (
160179
TuyaQuirkBuilder(device_mock.manufacturer, device_mock.model, registry=registry)
161180
.tuya_battery(dp_id=1)
@@ -193,6 +212,27 @@ class ModTuyaMCUCluster(TuyaMCUCluster):
193212
translation_key="test_enum",
194213
fallback_name="Test enum",
195214
)
215+
.tuya_dp_multi(
216+
dp_id=11,
217+
attribute_mapping=[
218+
DPToAttributeMapping(
219+
ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
220+
attribute_name="active_power",
221+
converter=dpToPower,
222+
),
223+
DPToAttributeMapping(
224+
ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
225+
attribute_name="rms_current",
226+
converter=dpToCurrent,
227+
),
228+
DPToAttributeMapping(
229+
ep_attribute=Tuya3PhaseElectricalMeasurement.ep_attribute,
230+
attribute_name="rms_voltage",
231+
converter=dpToVoltage,
232+
),
233+
],
234+
)
235+
.adds(Tuya3PhaseElectricalMeasurement)
196236
.skip_configuration()
197237
.add_to_registry(replacement_cluster=ModTuyaMCUCluster)
198238
)
@@ -251,6 +291,17 @@ class ModTuyaMCUCluster(TuyaMCUCluster):
251291
assert tuya_listener.attribute_updates[0][0] == 0xEF0A
252292
assert tuya_listener.attribute_updates[0][1] == TestEnum.B
253293

294+
electric_data = TuyaCommand(
295+
status=0,
296+
tsn=2,
297+
datapoints=[TuyaDatapointData(11, TuyaData("345"))],
298+
)
299+
tuya_cluster.handle_get_data(electric_data)
300+
electrical_meas_cluster = ep.electrical_measurement
301+
assert electrical_meas_cluster.get("active_power") == "3"
302+
assert electrical_meas_cluster.get("rms_current") == "4"
303+
assert electrical_meas_cluster.get("rms_voltage") == "5"
304+
254305

255306
async def test_tuya_quirkbuilder_duplicated_mappings(device_mock):
256307
"""Test that mapping the same DP multiple times will raise."""
@@ -268,6 +319,24 @@ async def test_tuya_quirkbuilder_duplicated_mappings(device_mock):
268319
.add_to_registry()
269320
)
270321

322+
with pytest.raises(ValueError):
323+
(
324+
TuyaQuirkBuilder(
325+
device_mock.manufacturer, device_mock.model, registry=registry
326+
)
327+
.tuya_battery(dp_id=1)
328+
.tuya_dp_multi(
329+
dp_id=1,
330+
attribute_mapping=[
331+
DPToAttributeMapping(
332+
ep_attribute=ElectricalMeasurement.ep_attribute,
333+
attribute_name="active_power",
334+
),
335+
],
336+
)
337+
.add_to_registry()
338+
)
339+
271340

272341
@pytest.mark.parametrize(
273342
"read_attr_spell,data_query_spell",

zhaquirks/tuya/__init__.py

Lines changed: 51 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1533,35 +1533,43 @@ class AttributeDefs(BaseAttributeDefs):
15331533
),
15341534
}
15351535

1536-
dp_to_attribute: dict[int, DPToAttributeMapping] = {}
1536+
dp_to_attribute: dict[int, DPToAttributeMapping | list[DPToAttributeMapping]] = {}
15371537
data_point_handlers: dict[int, str] = {}
15381538

15391539
def __init__(self, *args, **kwargs):
15401540
"""Initialize the cluster and mark attributes as valid on LocalDataClusters."""
15411541
super().__init__(*args, **kwargs)
1542-
for dp_map in self.dp_to_attribute.values():
1542+
1543+
self._dp_to_attributes: dict[int, list[DPToAttributeMapping]] = {
1544+
dp: attr if isinstance(attr, list) else [attr]
1545+
for dp, attr in self.dp_to_attribute.items()
1546+
}
1547+
for dp_map in self._dp_to_attributes.values():
15431548
# get the endpoint that is being mapped to
15441549
endpoint = self.endpoint
1545-
if dp_map.endpoint_id:
1546-
endpoint = self.endpoint.device.endpoints.get(dp_map.endpoint_id)
1547-
1548-
# the endpoint to be mapped to might not actually exist within all quirks
1549-
if not endpoint:
1550-
continue
1551-
1552-
cluster = getattr(endpoint, dp_map.ep_attribute, None)
1553-
# the cluster to be mapped to might not actually exist within all quirks
1554-
if not cluster:
1555-
continue
1556-
1557-
# mark mapped to attribute as valid if existing and if on a LocalDataCluster
1558-
attr = cluster.attributes_by_name.get(dp_map.attribute_name)
1559-
if attr and isinstance(cluster, LocalDataCluster):
1560-
# _VALID_ATTRIBUTES is only a class variable, but as want to modify it
1561-
# per instance here, we need to create an instance variable first
1562-
if "_VALID_ATTRIBUTES" not in cluster.__dict__:
1563-
cluster._VALID_ATTRIBUTES = set()
1564-
cluster._VALID_ATTRIBUTES.add(attr.id)
1550+
for mapped_attr in dp_map:
1551+
if mapped_attr.endpoint_id:
1552+
endpoint = self.endpoint.device.endpoints.get(
1553+
mapped_attr.endpoint_id
1554+
)
1555+
1556+
# the endpoint to be mapped to might not actually exist within all quirks
1557+
if not endpoint:
1558+
continue
1559+
1560+
cluster = getattr(endpoint, mapped_attr.ep_attribute, None)
1561+
# the cluster to be mapped to might not actually exist within all quirks
1562+
if not cluster:
1563+
continue
1564+
1565+
# mark mapped to attribute as valid if existing and if on a LocalDataCluster
1566+
attr = cluster.attributes_by_name.get(mapped_attr.attribute_name)
1567+
if attr and isinstance(cluster, LocalDataCluster):
1568+
# _VALID_ATTRIBUTES is only a class variable, but as want to modify it
1569+
# per instance here, we need to create an instance variable first
1570+
if "_VALID_ATTRIBUTES" not in cluster.__dict__:
1571+
cluster._VALID_ATTRIBUTES = set()
1572+
cluster._VALID_ATTRIBUTES.add(attr.id)
15651573

15661574
def handle_cluster_request(
15671575
self,
@@ -1640,27 +1648,29 @@ def handle_set_time_request(self, payload: t.uint16_t) -> foundation.Status:
16401648
def _dp_2_attr_update(self, datapoint: TuyaDatapointData) -> None:
16411649
"""Handle data point to attribute report conversion."""
16421650
try:
1643-
dp_map = self.dp_to_attribute[datapoint.dp]
1651+
dp_map = self._dp_to_attributes[datapoint.dp]
16441652
except KeyError:
16451653
self.debug("No attribute mapping for %s data point", datapoint.dp)
16461654
return
16471655

16481656
endpoint = self.endpoint
1649-
if dp_map.endpoint_id:
1650-
endpoint = self.endpoint.device.endpoints[dp_map.endpoint_id]
1651-
cluster = getattr(endpoint, dp_map.ep_attribute)
1652-
value = datapoint.data.payload
1653-
if dp_map.converter:
1654-
value = dp_map.converter(value)
1655-
1656-
if isinstance(dp_map.attribute_name, tuple):
1657-
for k, v in zip(dp_map.attribute_name, value):
1658-
if isinstance(v, AttributeWithMask):
1659-
v = cluster.get(k, 0) & (~v.mask) | v.value
1660-
cluster.update_attribute(k, v)
1661-
else:
1662-
if isinstance(value, AttributeWithMask):
1663-
value = (
1664-
cluster.get(dp_map.attribute_name, 0) & (~value.mask) | value.value
1665-
)
1666-
cluster.update_attribute(dp_map.attribute_name, value)
1657+
for mapped_attr in dp_map:
1658+
if mapped_attr.endpoint_id:
1659+
endpoint = self.endpoint.device.endpoints[mapped_attr.endpoint_id]
1660+
cluster = getattr(endpoint, mapped_attr.ep_attribute)
1661+
value = datapoint.data.payload
1662+
if mapped_attr.converter:
1663+
value = mapped_attr.converter(value)
1664+
1665+
if isinstance(mapped_attr.attribute_name, tuple):
1666+
for k, v in zip(mapped_attr.attribute_name, value):
1667+
if isinstance(v, AttributeWithMask):
1668+
v = cluster.get(k, 0) & (~v.mask) | v.value
1669+
cluster.update_attribute(k, v)
1670+
else:
1671+
if isinstance(value, AttributeWithMask):
1672+
value = (
1673+
cluster.get(mapped_attr.attribute_name, 0) & (~value.mask)
1674+
| value.value
1675+
)
1676+
cluster.update_attribute(mapped_attr.attribute_name, value)

zhaquirks/tuya/builder/__init__.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ def __init__(
196196
) -> None:
197197
"""Init the TuyaQuirkBuilder."""
198198
self.tuya_data_point_handlers: dict[int, str] = {}
199-
self.tuya_dp_to_attribute: dict[int, DPToAttributeMapping] = {}
199+
self.tuya_dp_to_attribute: dict[int, list[DPToAttributeMapping]] = {}
200200
self.new_attributes: set[foundation.ZCLAttributeDef] = set()
201201
super().__init__(manufacturer, model, registry)
202202
# quirk_file will point to the init call above if called from this QuirkBuilder,
@@ -503,20 +503,33 @@ def tuya_dp(
503503
) -> QuirkBuilder: # fmt: skip
504504
"""Add Tuya DP Converter."""
505505

506-
if dp_id in self.tuya_dp_to_attribute:
507-
raise ValueError(f"DP {dp_id} is already mapped.")
508-
509-
self.tuya_dp_to_attribute.update(
510-
{
511-
dp_id: DPToAttributeMapping(
506+
self.tuya_dp_multi(
507+
dp_id,
508+
[
509+
DPToAttributeMapping(
512510
ep_attribute,
513511
attribute_name,
514512
converter=converter,
515513
dp_converter=dp_converter,
516514
endpoint_id=endpoint_id,
517515
)
518-
}
516+
],
517+
dp_handler,
519518
)
519+
return self
520+
521+
def tuya_dp_multi(
522+
self,
523+
dp_id: int,
524+
attribute_mapping: list[DPToAttributeMapping],
525+
dp_handler: str = "_dp_2_attr_update",
526+
) -> QuirkBuilder: # fmt: skip
527+
"""Add Tuya DP Converter that maps to multiple attributes."""
528+
529+
if dp_id in self.tuya_dp_to_attribute:
530+
raise ValueError(f"DP {dp_id} is already mapped.")
531+
532+
self.tuya_dp_to_attribute.update({dp_id: attribute_mapping})
520533
self.tuya_data_point_handlers.update({dp_id: dp_handler})
521534
return self
522535

@@ -814,7 +827,7 @@ class TuyaReplacementCluster(replacement_cluster): # type: ignore[valid-type]
814827
"""Replacement Tuya Cluster."""
815828

816829
data_point_handlers: dict[int, str]
817-
dp_to_attribute: dict[int, DPToAttributeMapping]
830+
dp_to_attribute: dict[int, list[DPToAttributeMapping]]
818831

819832
class AttributeDefs(NewAttributeDefs):
820833
"""Attribute Definitions."""

zhaquirks/tuya/mcu/__init__.py

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -288,25 +288,26 @@ def tuya_mcu_command(self, cluster_data: TuyaClusterData):
288288
def get_dp_mapping(
289289
self, endpoint_id: int, attribute_name: str
290290
) -> Optional[tuple[int, DPToAttributeMapping]]:
291-
"""Search for the DP in dp_to_attribute."""
291+
"""Search for the DP in _dp_to_attributes."""
292292

293293
result = {}
294-
for dp, dp_mapping in self.dp_to_attribute.items():
295-
if (
296-
attribute_name == dp_mapping.attribute_name
297-
or (
298-
isinstance(dp_mapping.attribute_name, tuple)
299-
and attribute_name in dp_mapping.attribute_name
300-
)
301-
) and (
302-
(
303-
dp_mapping.endpoint_id is None
304-
and endpoint_id == self.endpoint.endpoint_id
305-
)
306-
or (endpoint_id == dp_mapping.endpoint_id)
307-
):
308-
self.debug("get_dp_mapping --> found DP: %s", dp)
309-
result[dp] = dp_mapping
294+
for dp, dp_mapping in self._dp_to_attributes.items():
295+
for mapped_attr in dp_mapping:
296+
if (
297+
attribute_name == mapped_attr.attribute_name
298+
or (
299+
isinstance(mapped_attr.attribute_name, tuple)
300+
and attribute_name in mapped_attr.attribute_name
301+
)
302+
) and (
303+
(
304+
mapped_attr.endpoint_id is None
305+
and endpoint_id == self.endpoint.endpoint_id
306+
)
307+
or (endpoint_id == mapped_attr.endpoint_id)
308+
):
309+
self.debug("get_dp_mapping --> found DP: %s", dp)
310+
result[dp] = mapped_attr
310311
return result
311312

312313
def handle_mcu_version_response(self, payload: MCUVersion) -> foundation.Status:

0 commit comments

Comments
 (0)