Repository: zigpy/zha-device-handlers
Pull request #3394 (2024-10-02 13:04:25)

Convert Giex GX02 quirk to V2
## Proposed change
<!--
  Explain your proposed change below.
-->
Convert GX02 Quirk to a V2 Quirk. Add additional functionality.

## Additional information
<!--
  Please include any additional information that is important to this PR.
  For example, if this PR is a potentially breaking change, mention that here.
  If this PR requires other PRs to be merged in HA Core or other projects, mention that.
  Lastly, if this PR fixes a specific issue, please include "Fixes #xxxx".
-->

1. Requires: https://github.com/zigpy/zha/pull/222 
    or https://github.com/zigpy/zha/pull/224
2.  Device reports temperature, but it's always 0, so I'm not adding an entity.
3.  The device supports an onboard schedule, details below in case someone wants to implement it. I don't see a clean way to implement it, nor do I think it would be very useful, so I skipped it.

![image](https://github.com/user-attachments/assets/ab335716-62f6-4b8d-99a6-dffe0286d69a)


## Setting on board schedule.

```
### 14 Byte Data Packet ###
[0-1] Message id, increments by 1 
[2] DP Id (0x71)
[3] DP Type (Raw [0x00])
[4-5] Never changed, always 0x0008
[6] Action 0x10: Duration based, 0x11: Volume Based, 0x01: Delete Schedule
[7] Schedule id, 0 indexed
[8] Days of week schedule is active, bitfield [NA, SUN, MON, TUE, WED, THU, FRI, SAT]
[9] Start time HH, max 23
[10] Start time MM, max 59
[11-13] Set point in time(seconds) or volume, max 86,399 seconds or 6500L
```

Closes: #3052, Closes: #3209

## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [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/3394?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: Patch coverage is `81.57895%` with `7 lines` in your changes missing coverage. Please review.
> Project coverage is 88.69%. Comparing base [(`7816838`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/781683848b43d1d100c82e34c90056a35b2fb090?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`fddec8c`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/fddec8cd53a9f378a93c40c9f2a0822e6910dee7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

| [Files with missing lines](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3394?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [zhaquirks/tuya/ts0601\_valve.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3394?src=pr&el=tree&filepath=zhaquirks%2Ftuya%2Fts0601_valve.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3R1eWEvdHMwNjAxX3ZhbHZlLnB5) | 81.08% | [7 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3394?src=pr&el=tree&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    #3394      +/-   ##
==========================================
- Coverage   88.72%   88.69%   -0.04%     
==========================================
  Files         306      306              
  Lines        9813     9844      +31     
==========================================
+ Hits         8707     8731      +24     
- Misses       1106     1113       +7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3394?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).

TheJulianJES: > After pairing, ZHA must be reloaded for the Summation delivered sensor to show.

That might have been fixed by the PR below. Would be great to get confirmation on that (or that it didn't break anything 😄):
- https://github.com/zigpy/zha-device-handlers/pull/3415
prairiesnpr: > > After pairing, ZHA must be reloaded for the Summation delivered sensor to show.
> 
> That might have been fixed by the PR below. Would be great to get confirmation on that (or that it didn't break anything 😄):
> 
> * [Add `_VALID_ATTRIBUTES` to LocalDataCluster, fix Tuya datapoint mappings on LocalDataClusters #3415](https://github.com/zigpy/zha-device-handlers/pull/3415)

Can confirm, it was created on joining.

Patch:
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_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",
     [