Repository: zigpy/zha-device-handlers
Pull request #3443 (2024-10-19 07:00:20)

Fix Tuya issue with `LocalDataCluster._VALID_ATTRIBUTES`
## Proposed change

This PR makes multiple changes:

- This fixes an issue where Tuya datapoint mappings affected `LocalDataCluster._VALID_ATTRIBUTES` globally (for all clusters), as we never created an instance variable. So, attributes were basically marked as valid for all `LocalDataCluster`s, which we do not want.

  Normally, I'd expect `LocalDataCluster._VALID_ATTRIBUTES` to be used like `_CONSTANT_ATTRIBUTES` and have it be constant per class*. It'll  be used like that for Aqara plugs in the future, for example.
However, for Tuya devices, we automatically populate `_VALID_ATTRIBUTES` based on the datapoint mappings to virtual/local clusters. If there is a datapoint mapping to a virtual ZCL cluster, it's added to `_VALID_ATTRIBUTES` of that specific virtual cluster instance.
In that case, the previous code always modified the existing `_VALID_ATTRIBUTES` class variable in `LocalDataCluster`.

  Now, we make sure to first create an instance variable for `_VALID_ATTRIBUTES` if there isn't one for the cluster already**.

- This PR also adds a test confirming `_VALID_ATTRIBUTES` are populated as expected for a Tuya sensor using datapoint mappings to virtual/local ZCL clusters. (This is how I found the issue. Probably should have added this test in the initial PR..)

- This changes `LocalDataCluster._VALID_ATTRIBUTES` to be a `set`, instead of a `list`.

*: Another fix would be to just always create an instance variable in `LocalDataCluster` in `__init__`.
However, we'll likely rarely need that and I'd ideally like to keep the behavior similar to `_CONSTANT_ATTRIBUTES`.

**: We need to access `cluster.__dict__` to check if there's already an instance variable for that cluster instance. We **cannot** use `hasattr`/`getattr`, as that would return `True` and the class variable, which we should not modify.

## Additional information
Follow-up to:
- https://github.com/zigpy/zha-device-handlers/pull/3415

## Checklist

- [x] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [x] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3443?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 89.37%. Comparing base [(`78b2c87`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/78b2c87daa7245d08929a9f1bc9752d17786d294?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`1396e11`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/1396e115aa4f10afa357f0535af10a48203baf88?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##              dev    #3443   +/-   ##
=======================================
  Coverage   89.36%   89.37%           
=======================================
  Files         308      308           
  Lines        9989     9991    +2     
=======================================
+ Hits         8927     8929    +2     
  Misses       1062     1062           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3443?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).


Patch:
diff --git a/zhaquirks/__init__.py b/zhaquirks/__init__.py
index 9b212bd..28d501e 100644
--- a/zhaquirks/__init__.py
+++ b/zhaquirks/__init__.py
@@ -69,7 +69,7 @@ class LocalDataCluster(CustomCluster):
     """
 
     _CONSTANT_ATTRIBUTES: dict[int, typing.Any] = {}
-    _VALID_ATTRIBUTES: list[int] = []
+    _VALID_ATTRIBUTES: set[int] = set()
 
     async def bind(self):
         """Prevent bind."""
diff --git a/zhaquirks/philips/soc001.py b/zhaquirks/philips/soc001.py
new file mode 100644
index 0000000..66ef3a1
--- /dev/null
+++ b/zhaquirks/philips/soc001.py
@@ -0,0 +1,58 @@
+"""Signify SOC001 device."""
+
+from zigpy import types
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityType, QuirkBuilder
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+
+class PhilipsContactCluster(CustomCluster):
+    """Philips manufacturer specific cluster for contact sensor."""
+
+    cluster_id = 64518  # 0xfc06
+    name = "Philips contact cluster"
+    ep_attribute = "philips_contact_cluster"
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        contact = ZCLAttributeDef(
+            id=0x0100,
+            type=types.enum8,
+            is_manufacturer_specific=True,
+        )
+        last_contact_change = ZCLAttributeDef(
+            id=0x0101,
+            type=types.uint32_t,
+            is_manufacturer_specific=True,
+        )
+        tamper = ZCLAttributeDef(
+            id=0x0102,
+            type=types.enum8,
+            is_manufacturer_specific=True,
+        )
+        last_tamper_change = ZCLAttributeDef(
+            id=0x0103,
+            type=types.uint32_t,
+            is_manufacturer_specific=True,
+        )
+
+
+(
+    #  <SimpleDescriptor endpoint=2 profile=260 device_type=1026
+    #  device_version=0
+    #  input_clusters=[0, 1, 3, 64518]
+    #  output_clusters=[0, 3, 6, 25]>
+    QuirkBuilder("Signify Netherlands B.V.", "SOC001")
+    .replaces(PhilipsContactCluster, endpoint_id=2)
+    .binary_sensor(
+        "tamper",
+        PhilipsContactCluster.cluster_id,
+        endpoint_id=2,
+        device_class=BinarySensorDeviceClass.TAMPER,
+        entity_type=EntityType.DIAGNOSTIC,
+        translation_key="tamper",
+        fallback_name="Tamper",
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py
index c86c301..347a6c7 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -1534,7 +1534,11 @@ class TuyaNewManufCluster(CustomCluster):
             # mark mapped to attribute as valid if existing and if on a LocalDataCluster
             attr = cluster.attributes_by_name.get(dp_map.attribute_name)
             if attr and isinstance(cluster, LocalDataCluster):
-                cluster._VALID_ATTRIBUTES.append(attr.id)
+                # _VALID_ATTRIBUTES is only a class variable, but as want to modify it
+                # per instance here, we need to create an instance variable first
+                if "_VALID_ATTRIBUTES" not in cluster.__dict__:
+                    cluster._VALID_ATTRIBUTES = set()
+                cluster._VALID_ATTRIBUTES.add(attr.id)
 
     def handle_cluster_request(
         self,
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index c31c1f4..a902fd4 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -63,7 +63,10 @@ class TuyaQuirkBuilder(QuirkBuilder):
     """Tuya QuirkBuilder."""
 
     def __init__(
-        self, manufacturer: str, model: str, registry: DeviceRegistry = _DEVICE_REGISTRY
+        self,
+        manufacturer: str | None = None,
+        model: str | None = None,
+        registry: DeviceRegistry = _DEVICE_REGISTRY,
     ) -> None:
         """Init the TuyaQuirkBuilder."""
         self.tuya_data_point_handlers: dict[int, str] = {}
diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py
index 26a11fa..e9d73d3 100644
--- a/zhaquirks/tuya/mcu/__init__.py
+++ b/zhaquirks/tuya/mcu/__init__.py
@@ -65,7 +65,7 @@ class TuyaClusterData(t.Struct):
     endpoint_id: int
     cluster_name: str
     cluster_attr: str
-    attr_value: int  # Maybe also others types?
+    attr_value: int | str  # Maybe also others types?
     expect_reply: bool
     manufacturer: int
 
diff --git a/zhaquirks/tuya/ts0601_valve.py b/zhaquirks/tuya/ts0601_valve.py
index b4a2885..2b4a09f 100644
--- a/zhaquirks/tuya/ts0601_valve.py
+++ b/zhaquirks/tuya/ts0601_valve.py
@@ -1,5 +1,7 @@
 """Collection of Tuya Valve devices e.g. water valves, gas valve etc."""
 
+from datetime import datetime, timedelta, timezone
+
 from zigpy.profiles import zha
 from zigpy.quirks import CustomDevice
 from zigpy.quirks.v2 import EntityPlatform, EntityType
@@ -20,7 +22,7 @@ from zhaquirks.const import (
     PROFILE_ID,
 )
 from zhaquirks.tuya import (
-    TUYA_CLUSTER_ED00_ID,
+    TUYA_CLUSTER_ID,
     EnchantedDevice,
     TuyaLocalCluster,
     TuyaPowerConfigurationCluster4AA,
@@ -30,7 +32,6 @@ from zhaquirks.tuya.mcu import (
     DPToAttributeMapping,
     TuyaMCUCluster,
     TuyaOnOff,
-    TuyaOnOffNM,
     TuyaPowerConfigurationCluster,
 )
 
@@ -310,198 +311,169 @@ class ParksidePSBZS(EnchantedDevice):
     }
 
 
-# info from https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/devices/giex.js
-GIEX_MODE_ATTR = 0xEF01  # Mode [0] duration [1] capacity
-GIEX_START_TIME_ATTR = 0xEF65  # Last irrigation start time (GMT)
-GIEX_END_TIME_ATTR = 0xEF66  # Last irrigation end time (GMT)
-GIEX_NUM_TIMES_ATTR = 0xEF67  # Number of cycle irrigation times min=0 max=100
-GIEX_TARGET_ATTR = 0xEF68  # Irrigation target, duration in sec or capacity in litres (depending on mode) min=0 max=3600
-GIEX_INTERVAL_ATTR = 0xEF69  # Cycle irrigation interval in seconds min=0 max=3600
-GIEX_DURATION_ATTR = 0xEF72  # Last irrigation duration
-
-
-class GiexValveManufCluster(TuyaMCUCluster):
-    """GiEX valve manufacturer cluster."""
+GIEX_12HRS_AS_SEC = 43200
+GIEX_24HRS_AS_MIN = 1440
+UNIX_EPOCH_TO_ZCL_EPOCH = 946684800
 
-    class AttributeDefs(TuyaMCUCluster.AttributeDefs):
-        """Attribute Definitions."""
 
-        irrigation_mode = foundation.ZCLAttributeDef(
-            id=GIEX_MODE_ATTR, type=t.Bool, is_manufacturer_specific=True
-        )
-        irrigation_start_time = foundation.ZCLAttributeDef(
-            id=GIEX_START_TIME_ATTR, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        irrigation_end_time = foundation.ZCLAttributeDef(
-            id=GIEX_END_TIME_ATTR, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        irrigation_num_times = foundation.ZCLAttributeDef(
-            id=GIEX_NUM_TIMES_ATTR, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        irrigation_target = foundation.ZCLAttributeDef(
-            id=GIEX_TARGET_ATTR, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        irrigation_interval = foundation.ZCLAttributeDef(
-            id=GIEX_INTERVAL_ATTR, type=t.uint32_t, is_manufacturer_specific=True
-        )
-        irrigation_duration = foundation.ZCLAttributeDef(
-            id=GIEX_DURATION_ATTR, type=t.uint32_t, is_manufacturer_specific=True
-        )
+class GiexIrrigationMode(t.enum8):
+    """Giex Irrigation Mode Enum."""
 
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {
-        1: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_mode",
-        ),
-        2: DPToAttributeMapping(
-            TuyaOnOffNM.ep_attribute,
-            "on_off",
-        ),
-        101: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_start_time",
-        ),
-        102: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_end_time",
-        ),
-        103: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_num_times",
-        ),
-        104: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_target",
-        ),
-        105: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_interval",
-        ),
-        108: DPToAttributeMapping(
-            TuyaPowerConfigurationCluster.ep_attribute,
-            "battery_percentage_remaining",
-        ),
-        111: DPToAttributeMapping(
-            TuyaValveWaterConsumed.ep_attribute,
-            "current_summ_delivered",
-        ),
-        114: DPToAttributeMapping(
-            TuyaMCUCluster.ep_attribute,
-            "irrigation_duration",
-        ),
-    }
+    Duration = 0x00
+    Capacity = 0x01
 
-    data_point_handlers = {
-        1: "_dp_2_attr_update",
-        2: "_dp_2_attr_update",
-        101: "_dp_2_attr_update",
-        102: "_dp_2_attr_update",
-        103: "_dp_2_attr_update",
-        104: "_dp_2_attr_update",
-        105: "_dp_2_attr_update",
-        108: "_dp_2_attr_update",
-        111: "_dp_2_attr_update",
-        114: "_dp_2_attr_update",
-    }
 
-    async def write_attributes(self, attributes, manufacturer=None):
-        """Overwrite to force manufacturer code."""
+class GiexIrrigationWeatherDelay(t.enum8):
+    """Giex Irrigation Weather Delay Enum."""
 
-        return await super().write_attributes(
-            attributes, manufacturer=foundation.ZCLHeader.NO_MANUFACTURER_ID
-        )
+    NoDelay = 0x00
+    TwentyFourHourDelay = 0x01
+    FortyEightHourDelay = 0x02
+    SeventyTwoHourDelay = 0x03
 
 
-class GiexValve(CustomDevice):
-    """GiEX valve device."""
+def giex_string_to_td(v: str) -> int:
+    """Convert Giex String Duration to seconds."""
+    dt = datetime.strptime(v, "%H:%M:%S,%f")
+    return timedelta(hours=dt.hour, minutes=dt.minute, seconds=dt.second).seconds
 
-    signature = {
-        MODELS_INFO: [
-            ("_TZE200_sh1btabb", "TS0601"),
-            ("_TZE200_a7sghmms", "TS0601"),
-            ("_TZE204_7ytb3h8u", "TS0601"),
-            ("_TZE200_7ytb3h8u", "TS0601"),
-        ],
-        ENDPOINTS: {
-            # <SimpleDescriptor endpoint=1 profile=260 device_type=0x0051
-            # input_clusters=[0x0000, 0x0004, 0x0005, 0xef00]
-            # output_clusters=[0x000a, 0x0019]>
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    GiexValveManufCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            }
-        },
-    }
 
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TuyaOnOffNM,
-                    TuyaPowerConfigurationCluster,
-                    TuyaValveWaterConsumed,
-                    GiexValveManufCluster,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            }
-        }
-    }
+def giex_string_to_ts(v: str) -> int | None:
+    """Convert Giex String Duration datetime."""
+    dev_tz = timezone(timedelta(hours=4))
+    dev_dt = datetime.now(dev_tz)
+    try:
+        dt = datetime.strptime(v, "%H:%M:%S").replace(tzinfo=dev_tz)
+        dev_dt.replace(hour=dt.hour, minute=dt.minute, second=dt.second)
+    except ValueError:
+        return None  # on initial start the device will return '--:--:--'
+    return int(dev_dt.timestamp() + UNIX_EPOCH_TO_ZCL_EPOCH)
 
 
-class GiexValveVar02(CustomDevice):
-    """GiEX valve device, variant 2."""
+gx02_base_quirk = (
+    TuyaQuirkBuilder()
+    .tuya_battery(dp_id=108, power_cfg=TuyaPowerConfigurationCluster4AA)
+    .tuya_metering(dp_id=111)
+    .tuya_onoff(dp_id=2)
+    .tuya_number(
+        dp_id=103,
+        attribute_name="irrigation_cycles",
+        type=t.uint8_t,
+        min_value=0,
+        max_value=100,
+        step=1,
+        translation_key="irrigation_cycles",
+        fallback_name="Irrigation cycles",
+    )
+    .tuya_dp_attribute(
+        dp_id=1,
+        attribute_name="irrigation_mode",
+        type=t.Bool,
+    )
+    .enum(
+        attribute_name="irrigation_mode",
+        cluster_id=TUYA_CLUSTER_ID,
+        enum_class=GiexIrrigationMode,
+        translation_key="irrigation_mode",
+        fallback_name="Irrigation mode",
+    )
+    .tuya_enum(
+        dp_id=107,
+        attribute_name="weather_delay",
+        enum_class=GiexIrrigationWeatherDelay,
+        translation_key="weather_delay",
+        fallback_name="Weather delay",
+        initially_disabled=True,
+    )
+    .tuya_sensor(
+        dp_id=114,
+        attribute_name="irrigation_duration",
+        type=t.uint32_t,
+        converter=lambda x: giex_string_to_td(x),
+        state_class=SensorStateClass.MEASUREMENT,
+        device_class=SensorDeviceClass.DURATION,
+        unit=UnitOfTime.SECONDS,
+        translation_key="irrigation_duration",
+        fallback_name="Last irrigation duration",
+    )
+    .tuya_sensor(
+        dp_id=101,
+        attribute_name="irrigation_start_time",
+        type=t.CharacterString,
+        converter=lambda x: giex_string_to_ts(x),
+        device_class=SensorDeviceClass.TIMESTAMP,
+        translation_key="irrigation_start_time",
+        fallback_name="Irrigation start time",
+    )
+    .tuya_sensor(
+        dp_id=102,
+        attribute_name="irrigation_end_time",
+        type=t.CharacterString,
+        converter=lambda x: giex_string_to_ts(x),
+        device_class=SensorDeviceClass.TIMESTAMP,
+        translation_key="irrigation_end_time",
+        fallback_name="Irrigation end time",
+    )
+    .skip_configuration()
+)
 
-    signature = {
-        MODELS_INFO: [
-            ("_TZE284_7ytb3h8u", "TS0601"),
-        ],
-        ENDPOINTS: {
-            # <SimpleDescriptor endpoint=1 profile=260 device_type=0x0051
-            # input_clusters=[0x0000, 0x0004, 0x0005, 0xed00, 0xef00]
-            # output_clusters=[0x000a, 0x0019]>
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TUYA_CLUSTER_ED00_ID,
-                    GiexValveManufCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            }
-        },
-    }
 
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TuyaOnOffNM,
-                    TuyaPowerConfigurationCluster,
-                    TuyaValveWaterConsumed,
-                    GiexValveManufCluster,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            }
-        }
-    }
+(
+    gx02_base_quirk.clone()
+    .applies_to("_TZE200_sh1btabb", "TS0601")
+    .tuya_number(
+        dp_id=104,
+        attribute_name="irrigation_target",
+        type=t.uint32_t,
+        min_value=0,
+        max_value=GIEX_24HRS_AS_MIN,
+        step=1,
+        translation_key="irrigation_target",
+        fallback_name="Irrigation target",
+    )
+    .tuya_number(
+        dp_id=105,
+        attribute_name="irrigation_interval",
+        min_value=0,
+        type=t.uint32_t,
+        max_value=GIEX_24HRS_AS_MIN,
+        step=1,
+        unit=UnitOfTime.MINUTES,
+        translation_key="irrigation_interval",
+        fallback_name="Irrigation interval",
+    )
+    .add_to_registry()
+)
+(
+    gx02_base_quirk.clone()
+    .applies_to("_TZE200_a7sghmms", "TS0601")
+    .applies_to("_TZE204_a7sghmms", "TS0601")
+    .applies_to("_TZE200_7ytb3h8u", "TS0601")  # Giex GX02 Var 1
+    .applies_to("_TZE204_7ytb3h8u", "TS0601")  # Giex GX02 Var 1
+    .applies_to("_TZE284_7ytb3h8u", "TS0601")  # Giex GX02 Var 3
+    .tuya_number(
+        dp_id=104,
+        attribute_name="irrigation_target",
+        type=t.uint32_t,
+        min_value=0,
+        max_value=GIEX_12HRS_AS_SEC,
+        step=1,
+        translation_key="irrigation_target",
+        fallback_name="Irrigation target",
+    )
+    .tuya_number(
+        dp_id=105,
+        attribute_name="irrigation_interval",
+        type=t.uint32_t,
+        min_value=0,
+        max_value=GIEX_12HRS_AS_SEC,
+        step=1,
+        unit=UnitOfTime.SECONDS,
+        translation_key="irrigation_interval",
+        fallback_name="Irrigation interval",
+    )
+    .add_to_registry()
+)
 
 
 class GiexIrrigationStatus(t.enum8):


Test patch:
diff --git a/tests/test_tuya_sensor.py b/tests/test_tuya_sensor.py
index 0a15bf1..55e9eb9 100644
--- a/tests/test_tuya_sensor.py
+++ b/tests/test_tuya_sensor.py
@@ -2,10 +2,11 @@
 
 import pytest
 from zigpy.zcl import foundation
-from zigpy.zcl.clusters.general import Basic
+from zigpy.zcl.clusters.general import Basic, PowerConfiguration
+from zigpy.zcl.clusters.measurement import RelativeHumidity, TemperatureMeasurement
 
 import zhaquirks
-import zhaquirks.tuya
+from zhaquirks.tuya import TuyaLocalCluster
 from zhaquirks.tuya.mcu import TuyaMCUCluster
 
 zhaquirks.setup()
@@ -115,3 +116,26 @@ async def test_handle_get_data_enum_batt(
 
     status = ep.tuya_manufacturer.handle_get_data(data.data)
     assert status == foundation.Status.UNSUPPORTED_ATTRIBUTE
+
+
+def test_valid_attributes(zigpy_device_from_v2_quirk):
+    """Test that valid attributes on virtual clusters are populated by Tuya datapoints mappings."""
+    quirked = zigpy_device_from_v2_quirk("_TZE200_bjawzodf", "TS0601")
+    ep = quirked.endpoints[1]
+
+    temperature_attr_id = TemperatureMeasurement.AttributeDefs.measured_value.id
+    humidity_attr_id = RelativeHumidity.AttributeDefs.measured_value.id
+    power_attr_id = PowerConfiguration.AttributeDefs.battery_percentage_remaining.id
+
+    temperature_cluster = ep.temperature
+    humidity_cluster = ep.humidity
+    power_config_cluster = ep.power
+
+    assert isinstance(temperature_cluster, TuyaLocalCluster)
+    assert isinstance(humidity_cluster, TuyaLocalCluster)
+    assert isinstance(power_config_cluster, TuyaLocalCluster)
+
+    # check that the virtual clusters have expected valid attributes
+    assert {temperature_attr_id} == temperature_cluster._VALID_ATTRIBUTES
+    assert {humidity_attr_id} == humidity_cluster._VALID_ATTRIBUTES
+    assert {power_attr_id} == power_config_cluster._VALID_ATTRIBUTES
diff --git a/tests/test_tuya_valve.py b/tests/test_tuya_valve.py
index f77b9b0..a76ae10 100644
--- a/tests/test_tuya_valve.py
+++ b/tests/test_tuya_valve.py
@@ -1,10 +1,13 @@
 """Tests for Tuya quirks."""
 
+from datetime import datetime, timezone
 from unittest import mock
+from unittest.mock import patch
 
 import pytest
+from zigpy.quirks.v2 import EntityMetadata
 import zigpy.types as t
-from zigpy.zcl import foundation
+from zigpy.zcl import ClusterType, foundation
 
 from tests.common import ClusterListener, wait_for_zigpy_tasks
 import zhaquirks
@@ -131,6 +134,56 @@ async def test_report_values_psbzs(zigpy_device_from_quirk, quirk):
     assert tuya_listener.attribute_updates[3][1] == 1  # frost lock state is inverted
 
 
+@pytest.mark.parametrize(
+    "model,manuf,use_minutes",
+    [
+        ("_TZE200_sh1btabb", "TS0601", True),
+        ("_TZE200_a7sghmms", "TS0601", False),
+        ("_TZE204_a7sghmms", "TS0601", False),
+        ("_TZE200_7ytb3h8u", "TS0601", False),
+        ("_TZE204_7ytb3h8u", "TS0601", False),
+        ("_TZE284_7ytb3h8u", "TS0601", False),
+    ],
+)
+async def test_giex_02_quirk(zigpy_device_from_v2_quirk, model, manuf, use_minutes):
+    """Test Giex GX02 Valve Quirk."""
+
+    quirked_device = zigpy_device_from_v2_quirk(model, manuf)
+    metering_cluster = quirked_device.endpoints[1].smartenergy_metering
+    assert metering_cluster.unsupported_attributes == {0x0400, "instantaneous_demand"}
+    for entity in range(6, 8):
+        number_metadata: EntityMetadata = quirked_device.exposes_metadata[
+            (1, zhaquirks.tuya.TUYA_CLUSTER_ID, ClusterType.Server)
+        ][entity]
+
+        if not use_minutes:
+            assert number_metadata.max == zhaquirks.tuya.ts0601_valve.GIEX_12HRS_AS_SEC
+        else:
+            assert number_metadata.max == zhaquirks.tuya.ts0601_valve.GIEX_24HRS_AS_MIN
+
+
+async def test_giex_functions():
+    """Test various Giex Valve functions."""
+    assert zhaquirks.tuya.ts0601_valve.giex_string_to_td("12:01:05,3") == 43265
+    assert zhaquirks.tuya.ts0601_valve.giex_string_to_ts("--:--:--") is None
+
+    class MockDatetime:
+        def now(self, tz: timezone):
+            """Mock now."""
+            return datetime(2024, 10, 2, 12, 10, 23, tzinfo=tz)
+
+        def strptime(self, v: str, fmt: str):
+            """Mock strptime."""
+            return datetime.strptime(v, fmt)
+
+    with patch("zhaquirks.tuya.ts0601_valve.datetime", MockDatetime()):
+        assert (
+            zhaquirks.tuya.ts0601_valve.giex_string_to_ts("20:12:01")
+            == datetime.fromisoformat("2024-10-02T12:10:23+04:00").timestamp()
+            + zhaquirks.tuya.ts0601_valve.UNIX_EPOCH_TO_ZCL_EPOCH
+        )
+
+
 @pytest.mark.parametrize(
     "model,manuf",
     [