Skip to content

Commit c55555e

Browse files
authored
Refactor Tuya spell implementation, add Tuya query data spell (#2940)
1 parent 6ab4376 commit c55555e

File tree

3 files changed

+153
-41
lines changed

3 files changed

+153
-41
lines changed

tests/test_tuya.py

Lines changed: 104 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
GreenPowerProxy,
1818
Groups,
1919
Identify,
20+
OnOff,
2021
Ota,
2122
PowerConfiguration,
2223
)
@@ -36,10 +37,13 @@
3637
ZONE_STATUS_CHANGE_COMMAND,
3738
)
3839
from zhaquirks.tuya import (
40+
TUYA_QUERY_DATA,
3941
Data,
42+
EnchantedDevice,
4043
TuyaEnchantableCluster,
4144
TuyaManufClusterAttributes,
4245
TuyaNewManufCluster,
46+
TuyaZBOnOffAttributeCluster,
4347
)
4448
import zhaquirks.tuya.sm0202_motion
4549
import zhaquirks.tuya.ts011f_plug
@@ -1608,22 +1612,58 @@ async def test_power_config_no_bind(zigpy_device_from_quirk, quirk):
16081612
assert len(bind_mock.mock_calls) == 0
16091613

16101614

1611-
ENCHANTED_QUIRKS = []
1615+
class TuyaTestSpellDevice(EnchantedDevice):
1616+
"""Tuya test spell device."""
1617+
1618+
tuya_spell_data_query = True # enable additional data query spell
1619+
1620+
signature = {
1621+
MODELS_INFO: [("UjqjHq6ZErY23tgs", "zo9WD7q5dbvDj96y")],
1622+
ENDPOINTS: {
1623+
1: {
1624+
PROFILE_ID: zha.PROFILE_ID,
1625+
DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
1626+
INPUT_CLUSTERS: [
1627+
Basic.cluster_id,
1628+
OnOff.cluster_id,
1629+
TuyaNewManufCluster.cluster_id,
1630+
],
1631+
OUTPUT_CLUSTERS: [],
1632+
}
1633+
},
1634+
}
1635+
1636+
replacement = {
1637+
ENDPOINTS: {
1638+
1: {
1639+
PROFILE_ID: zha.PROFILE_ID,
1640+
DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
1641+
INPUT_CLUSTERS: [
1642+
Basic.cluster_id,
1643+
TuyaZBOnOffAttributeCluster,
1644+
TuyaNewManufCluster,
1645+
],
1646+
OUTPUT_CLUSTERS: [],
1647+
}
1648+
}
1649+
}
1650+
1651+
1652+
ENCHANTED_QUIRKS = [TuyaTestSpellDevice]
16121653
for manufacturer in zigpy.quirks._DEVICE_REGISTRY._registry.values():
16131654
for model_quirk_list in manufacturer.values():
16141655
for quirk_entry in model_quirk_list:
16151656
if quirk_entry in ENCHANTED_QUIRKS:
16161657
continue
1617-
# right now, this basically includes `issubclass(quirk, EnchantedDevice)`, as that sets `TUYA_SPELL`
1618-
if getattr(quirk_entry, "TUYA_SPELL", False):
1658+
if issubclass(quirk_entry, EnchantedDevice):
16191659
ENCHANTED_QUIRKS.append(quirk_entry)
16201660

16211661
del quirk_entry, model_quirk_list, manufacturer
16221662

16231663

16241664
@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock())
16251665
async def test_tuya_spell(zigpy_device_from_quirk):
1626-
"""Test that enchanted Tuya devices have their spell applied when binding OnOff cluster."""
1666+
"""Test that enchanted Tuya devices have their spell applied when binding bindable cluster."""
16271667
non_bindable_cluster_ids = [
16281668
Basic.cluster_id,
16291669
Identify.cluster_id,
@@ -1639,6 +1679,7 @@ async def test_tuya_spell(zigpy_device_from_quirk):
16391679

16401680
for quirk in ENCHANTED_QUIRKS:
16411681
device = zigpy_device_from_quirk(quirk)
1682+
assert isinstance(device, EnchantedDevice)
16421683

16431684
# fail if SKIP_CONFIGURATION is set, as that will cause ZHA to not call bind()
16441685
if getattr(device, SKIP_CONFIGURATION, False):
@@ -1661,45 +1702,84 @@ async def test_tuya_spell(zigpy_device_from_quirk):
16611702
):
16621703
await cluster.bind()
16631704

1664-
# check that exactly one Tuya spell was cast
1705+
# the number of Tuya spells that are allowed to be cast, so the sum of enabled Tuya spells
1706+
enabled_tuya_spells_num = (
1707+
device.tuya_spell_read_attributes + device.tuya_spell_data_query
1708+
)
1709+
1710+
# skip if no Tuya spells are enabled,
1711+
# this case is already handled in the test_tuya_spell_devices_valid() test
1712+
if enabled_tuya_spells_num == 0:
1713+
continue
1714+
1715+
# check that exactly a Tuya spell was cast
16651716
if len(request_mock.mock_calls) == 0:
16661717
pytest.fail(
16671718
f"Enchanted quirk {quirk} did not cast a Tuya spell. "
16681719
f"One bindable cluster subclassing `TuyaEnchantableCluster` on endpoint 1 needs to be implemented. "
16691720
f"Also check that enchanted bindable clusters do not modify their `ep_attribute`, "
16701721
f"as ZHA will not call bind() in that case."
16711722
)
1672-
elif len(request_mock.mock_calls) > 1:
1723+
# check that no more than one call was made for each enabled spell
1724+
elif len(request_mock.mock_calls) > enabled_tuya_spells_num:
16731725
pytest.fail(
16741726
f"Enchanted quirk {quirk} cast more than one Tuya spell. "
16751727
f"Make sure to only implement one cluster subclassing `TuyaEnchantableCluster` on endpoint 1."
16761728
)
16771729

1678-
assert (
1679-
request_mock.mock_calls[0][1][1]
1680-
== foundation.GeneralCommand.Read_Attributes
1681-
) # read attributes
1682-
assert request_mock.mock_calls[0][1][3] == [4, 0, 1, 5, 7, 65534] # spell
1730+
# used to check list of mock calls below
1731+
messages = 0
1732+
1733+
# check 'attribute read spell' was cast correctly (if enabled)
1734+
if device.tuya_spell_read_attributes:
1735+
assert (
1736+
request_mock.mock_calls[messages][1][1]
1737+
== foundation.GeneralCommand.Read_Attributes
1738+
)
1739+
assert request_mock.mock_calls[messages][1][3] == [4, 0, 1, 5, 7, 65534]
1740+
messages += 1
1741+
1742+
# check 'query data spell' was cast correctly (if enabled)
1743+
if device.tuya_spell_data_query:
1744+
assert not request_mock.mock_calls[messages][1][0]
1745+
assert request_mock.mock_calls[messages][1][1] == TUYA_QUERY_DATA
1746+
messages += 1
1747+
16831748
request_mock.reset_mock()
16841749

16851750

16861751
def test_tuya_spell_devices_valid():
16871752
"""Test that all enchanted Tuya devices implement at least one enchanted cluster."""
16881753

16891754
for quirk in ENCHANTED_QUIRKS:
1690-
enchanted_clusters = 0
1755+
# check that at least one Tuya spell is enabled for an EnchantedDevice
1756+
if not quirk.tuya_spell_read_attributes and not quirk.tuya_spell_data_query:
1757+
pytest.fail(
1758+
f"Enchanted quirk {quirk} does not have any Tuya spells enabled. "
1759+
f"Enable at least one Tuya spell by setting `TUYA_SPELL_READ_ATTRIBUTES` or `TUYA_SPELL_DATA_QUERY` "
1760+
f"or inherit CustomDevice rather than EnchantedDevice."
1761+
)
1762+
1763+
enchanted_clusters = 0 # number of clusters subclassing TuyaEnchantableCluster
1764+
tuya_cluster_exists = False # cluster subclassing TuyaNewManufCluster existing
16911765

16921766
# iterate over all clusters in the replacement
16931767
for endpoint_id, endpoint in quirk.replacement[ENDPOINTS].items():
16941768
if endpoint_id != 1: # spell is only activated on endpoint 1 for now
16951769
continue
16961770
for cluster in endpoint[INPUT_CLUSTERS] + endpoint[OUTPUT_CLUSTERS]:
1697-
if not isinstance(cluster, int) and issubclass(
1698-
cluster, TuyaEnchantableCluster
1699-
):
1700-
enchanted_clusters += 1
1701-
1702-
# one EnchantedDevice must have exactly one enchanted cluster on endpoint 1
1771+
if not isinstance(cluster, int):
1772+
# count all clusters which would apply the spell on bind()
1773+
if issubclass(cluster, TuyaEnchantableCluster):
1774+
enchanted_clusters += 1
1775+
# check if there's a valid Tuya cluster where the id wasn't modified
1776+
if (
1777+
issubclass(cluster, TuyaNewManufCluster)
1778+
and cluster.cluster_id == TuyaNewManufCluster.cluster_id
1779+
):
1780+
tuya_cluster_exists = True
1781+
1782+
# an EnchantedDevice must have exactly one enchanted cluster on endpoint 1
17031783
if enchanted_clusters == 0:
17041784
pytest.fail(
17051785
f"{quirk} does not have a cluster subclassing `TuyaEnchantableCluster` on endpoint 1 "
@@ -1709,3 +1789,9 @@ def test_tuya_spell_devices_valid():
17091789
pytest.fail(
17101790
f"{quirk} has more than one cluster subclassing `TuyaEnchantableCluster` on endpoint 1"
17111791
)
1792+
1793+
# an EnchantedDevice with the data query spell must also have a cluster subclassing TuyaNewManufCluster
1794+
if quirk.tuya_spell_data_query and not tuya_cluster_exists:
1795+
pytest.fail(
1796+
f"{quirk} set Tuya data query spell but has no cluster subclassing `TuyaNewManufCluster` on endpoint 1"
1797+
)

zhaquirks/tuya/__init__.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import zigpy.types as t
1010
from zigpy.zcl import foundation
1111
from zigpy.zcl.clusters.closures import WindowCovering
12-
from zigpy.zcl.clusters.general import LevelControl, OnOff, PowerConfiguration
12+
from zigpy.zcl.clusters.general import Basic, LevelControl, OnOff, PowerConfiguration
1313
from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
1414
from zigpy.zcl.clusters.hvac import Thermostat, UserInterface
1515
from zigpy.zcl.clusters.smartenergy import Metering
@@ -527,6 +527,18 @@ async def write_attributes(self, attributes, manufacturer=None):
527527
return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]]
528528

529529

530+
class EnchantedDevice(CustomDevice):
531+
"""Class for Tuya devices which need to be unlocked by casting a 'spell'. This happens during binding.
532+
533+
To make sure the spell is cast, the device needs to implement a subclass of `TuyaEnchantableCluster`.
534+
For more information, see the documentation of `TuyaEnchantableCluster`.
535+
"""
536+
537+
# These values can be overridden from a quirk to enable (or disable) additional Tuya spells:
538+
tuya_spell_read_attributes: bool = True # spell reading attributes on Basic cluster
539+
tuya_spell_data_query: bool = False # additional spell needed for some devices
540+
541+
530542
class TuyaEnchantableCluster(CustomCluster):
531543
"""Tuya cluster that casts a magic spell if `TUYA_SPELL` is set.
532544
@@ -549,22 +561,44 @@ class TuyaEnchantableCluster(CustomCluster):
549561

550562
async def bind(self):
551563
"""Bind cluster and start casting the spell if necessary."""
552-
# check if the device needs to have the spell cast
564+
device = self.endpoint.device
565+
566+
# check if the device is an EnchantedDevice
553567
# and since the cluster can be used on multiple endpoints, check that it's endpoint 1
554-
if (
555-
getattr(self.endpoint.device, "TUYA_SPELL", False)
556-
and self.endpoint.endpoint_id == 1
557-
):
558-
await self.spell()
568+
if isinstance(device, EnchantedDevice) and self.endpoint.endpoint_id == 1:
569+
if device.tuya_spell_read_attributes:
570+
await self.spell_attribute_reads()
571+
if device.tuya_spell_data_query:
572+
await self.spell_data_query()
573+
559574
return await super().bind()
560575

561-
async def spell(self):
562-
"""Cast spell, so the Tuya device works correctly."""
563-
self.debug("Executing spell on Tuya device %s", self.endpoint.device.ieee)
576+
async def spell_attribute_reads(self):
577+
"""Cast 'attribute read' spell, so the Tuya device works correctly."""
578+
self.debug(
579+
"Executing attribute read spell on Tuya device %s",
580+
self.endpoint.device.ieee,
581+
)
564582
attr_to_read = [4, 0, 1, 5, 7, 0xFFFE]
565-
basic_cluster = self.endpoint.device.endpoints[1].in_clusters[0]
583+
basic_cluster = self.endpoint.device.endpoints[1].in_clusters[Basic.cluster_id]
566584
await basic_cluster.read_attributes(attr_to_read)
567-
self.debug("Executed spell on Tuya device %s", self.endpoint.device.ieee)
585+
self.debug(
586+
"Executed attribute read spell on Tuya device %s", self.endpoint.device.ieee
587+
)
588+
589+
async def spell_data_query(self):
590+
"""Cast 'data query' spell, also required for some Tuya devices to send data."""
591+
self.debug(
592+
"Executing data query spell on Tuya device %s", self.endpoint.device.ieee
593+
)
594+
# tests verify that a device with an enabled 'data query spell' has a TuyaNewManufCluster (subclass)
595+
tuya_cluster = self.endpoint.device.endpoints[1].in_clusters[
596+
TuyaNewManufCluster.cluster_id
597+
]
598+
await tuya_cluster.command(TUYA_QUERY_DATA)
599+
self.debug(
600+
"Executed data query spell on Tuya device %s", self.endpoint.device.ieee
601+
)
568602

569603

570604
class TuyaOnOff(TuyaEnchantableCluster, OnOff):

zhaquirks/tuya/mcu/__init__.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import datetime
44
from typing import Any, Callable, Dict, Optional, Tuple, Union
55

6-
from zigpy.quirks import CustomDevice
76
import zigpy.types as t
87
from zigpy.zcl import foundation
98
from zigpy.zcl.clusters.general import LevelControl, OnOff
@@ -24,6 +23,9 @@
2423
TuyaTimePayload,
2524
)
2625

26+
# add EnchantedDevice import for custom quirks backwards compatibility
27+
from zhaquirks.tuya import EnchantedDevice # noqa: F401
28+
2729
# New manufacturer attributes
2830
ATTR_MCU_VERSION = 0xEF00
2931

@@ -720,13 +722,3 @@ class TuyaLevelControlManufCluster(TuyaMCUCluster):
720722
17: "_dp_2_attr_update",
721723
18: "_dp_2_attr_update",
722724
}
723-
724-
725-
class EnchantedDevice(CustomDevice):
726-
"""Class for Tuya devices which need to be unlocked by casting a 'spell'. This happens during binding.
727-
728-
To make sure the spell is cast, the device needs to implement a subclass of `TuyaEnchantableCluster`.
729-
For more information, see the documentation of `TuyaEnchantableCluster`.
730-
"""
731-
732-
TUYA_SPELL = True

0 commit comments

Comments
 (0)