From 693aa6d2aeaba955aa7c6c2bb207646f065c9b6d Mon Sep 17 00:00:00 2001 From: prairiesnpr Date: Mon, 7 Oct 2024 12:10:12 +0000 Subject: [PATCH] Initial GX02 Irrigation Valve (V2Quirk) --- requirements_test.txt | 2 +- tests/test_tuya_valve.py | 57 +++++- zhaquirks/tuya/mcu/__init__.py | 2 +- zhaquirks/tuya/ts0601_valve.py | 322 +++++++++++++++++++++++---------- 4 files changed, 286 insertions(+), 97 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index f1256d22d7..fa98f852ab 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,5 +13,5 @@ pytest-sugar pytest-timeout pytest-asyncio pytest>=7.1.3 -zigpy>=0.66.0 +zigpy>=0.67.0 ruff==0.0.261 diff --git a/tests/test_tuya_valve.py b/tests/test_tuya_valve.py index 028b096ffa..1675c5977f 100644 --- a/tests/test_tuya_valve.py +++ b/tests/test_tuya_valve.py @@ -1,12 +1,17 @@ """Tests for Tuya quirks.""" +from datetime import datetime, timezone from unittest import mock +from unittest.mock import patch import pytest -from zigpy.zcl import foundation +from zigpy.quirks.v2 import EntityMetadata +from zigpy.zcl import ClusterType, foundation from tests.common import ClusterListener, wait_for_zigpy_tasks import zhaquirks +import zhaquirks.tuya +import zhaquirks.tuya.ts0601_valve zhaquirks.setup() @@ -114,3 +119,53 @@ async def test_report_values_psbzs(zigpy_device_from_quirk, quirk): assert tuya_listener.attribute_updates[2][1] == 0 # frost lock state is inverted assert tuya_listener.attribute_updates[3][0] == 0xEF13 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_dt("--:--:--") 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_dt("20:12:01") + == datetime.fromisoformat("2024-10-02T12:10:23+04:00").timestamp() + + zhaquirks.tuya.ts0601_valve.UNIX_EPOCH_TO_ZCL_EPOCH + ) diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py index 1e550c0c74..0d0c66ca66 100644 --- a/zhaquirks/tuya/mcu/__init__.py +++ b/zhaquirks/tuya/mcu/__init__.py @@ -66,7 +66,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 fbb29a09bd..730ebf3c4b 100644 --- a/zhaquirks/tuya/ts0601_valve.py +++ b/zhaquirks/tuya/ts0601_valve.py @@ -1,10 +1,25 @@ """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 QuirkBuilder +from zigpy.quirks.v2.homeassistant import UnitOfTime +from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass import zigpy.types as t from zigpy.zcl import foundation -from zigpy.zcl.clusters.general import Basic, Groups, Identify, OnOff, Ota, Scenes, Time +from zigpy.zcl.clusters.general import ( + Basic, + Groups, + Identify, + OnOff, + Ota, + PowerConfiguration, + Scenes, + Time, +) +from zigpy.zcl.clusters.measurement import TemperatureMeasurement from zigpy.zcl.clusters.smartenergy import Metering from zhaquirks import DoublingPowerConfigurationCluster @@ -287,29 +302,98 @@ class ParksidePSBZS(EnchantedDevice): } -# info from https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/devices/giex.js +class GiexPowerConfigurationCluster4AA(TuyaPowerConfigurationCluster): + """PowerConfiguration cluster for devices with 4 AA.""" + + _CONSTANT_ATTRIBUTES = { + PowerConfiguration.AttributeDefs.battery_size.id: 3, + PowerConfiguration.AttributeDefs.battery_rated_voltage.id: 15, + PowerConfiguration.AttributeDefs.battery_quantity.id: 4, + } + + +class TuyaTemperatureMeasurement(TemperatureMeasurement, TuyaLocalCluster): + """Tuya local TemperatureMeasurement cluster.""" + + +class GiexValveWaterConsumed(TuyaValveWaterConsumed): + """Giex Valve Water consumed cluster.""" + + def __init__(self, *args, **kwargs): + """Init a GiexValveWaterConsumed cluster.""" + super().__init__(*args, **kwargs) + self.add_unsupported_attribute(Metering.AttributeDefs.instantaneous_demand.id) + + 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_WX_DELAY_ATTR = 0xEF6B # Weather Delay [0-3] No Delay, 24hr,48hr, 72hr +GIEX_CIRC_IRR_PARAM = 0xEF6D # Circular irrigation parameters +GIEX_RT_DURATION_SEC = 0xEF6E # Real-time cumulative duration (seconds) +GIEX_OTHER_EXT = 0xEF70 # Other extensions string +GIEX_TIMEING_FUNC = 0xEF71 # Timing Function,Add or remove schedule. GIEX_DURATION_ATTR = 0xEF72 # Last irrigation duration +GIEX_TZ_ATTR = 0xEF73 # Offset in hours +GIEX_12HRS_AS_SEC = 43200 +GIEX_24HRS_AS_MIN = 1440 +UNIX_EPOCH_TO_ZCL_EPOCH = 946684800 + + +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 + + +def giex_string_to_dt(v: str) -> datetime | 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 dev_dt.timestamp() + UNIX_EPOCH_TO_ZCL_EPOCH class GiexValveManufCluster(TuyaMCUCluster): """GiEX valve manufacturer cluster.""" + class GiexIrrigationMode(t.enum8): + """Giex Irrigation Mode Enum.""" + + Duration = 0x00 + Capacity = 0x01 + + class GiexIrrigationWeatherDelay(t.enum8): + """Giex Irrigation Weather Delay Enum.""" + + NoDelay = 0x00 + TwentyFourHourDelay = 0x01 + FortyEightHourDelay = 0x02 + SeventyTwoHourDelay = 0x03 + attributes = TuyaMCUCluster.attributes.copy() attributes.update( { GIEX_MODE_ATTR: ("irrigation_mode", t.Bool, True), - GIEX_START_TIME_ATTR: ("irrigation_start_time", t.uint32_t, True), - GIEX_END_TIME_ATTR: ("irrigation_end_time", t.uint32_t, True), + GIEX_START_TIME_ATTR: ("irrigation_start_time", t.CharacterString, True), + GIEX_END_TIME_ATTR: ("irrigation_end_time", t.CharacterString, True), GIEX_NUM_TIMES_ATTR: ("irrigation_num_times", t.uint32_t, True), GIEX_TARGET_ATTR: ("irrigation_target", t.uint32_t, True), GIEX_INTERVAL_ATTR: ("irrigation_interval", t.uint32_t, True), + GIEX_WX_DELAY_ATTR: ("weather_delay", t.uint8_t, True), + GIEX_CIRC_IRR_PARAM: ("irrigation_circ_parm", t.LVBytes, True), + GIEX_RT_DURATION_SEC: ("irrigation_accu_secs", t.uint32_t, True), + GIEX_OTHER_EXT: ("irrigation_other_ext", t.CharacterString, True), + GIEX_TIMEING_FUNC: ("irrigation_timeing_func", t.LVBytes, True), GIEX_DURATION_ATTR: ("irrigation_duration", t.uint32_t, True), + GIEX_TZ_ATTR: ("device_tz", t.uint8_t, True), } ) @@ -325,10 +409,12 @@ class GiexValveManufCluster(TuyaMCUCluster): 101: DPToAttributeMapping( TuyaMCUCluster.ep_attribute, "irrigation_start_time", + converter=lambda x: giex_string_to_dt(x), ), 102: DPToAttributeMapping( TuyaMCUCluster.ep_attribute, "irrigation_end_time", + converter=lambda x: giex_string_to_dt(x), ), 103: DPToAttributeMapping( TuyaMCUCluster.ep_attribute, @@ -342,20 +428,48 @@ class GiexValveManufCluster(TuyaMCUCluster): TuyaMCUCluster.ep_attribute, "irrigation_interval", ), + 106: DPToAttributeMapping( + TuyaTemperatureMeasurement.ep_attribute, + "measured_value", + ), + 107: DPToAttributeMapping( + TuyaMCUCluster.ep_attribute, + "weather_delay", + ), 108: DPToAttributeMapping( - TuyaPowerConfigurationCluster.ep_attribute, + GiexPowerConfigurationCluster4AA.ep_attribute, "battery_percentage_remaining", ), + 109: DPToAttributeMapping( + TuyaMCUCluster.ep_attribute, + "irrigation_circ_parm", + ), + 110: DPToAttributeMapping( + TuyaMCUCluster.ep_attribute, + "irrigation_accu_secs", + ), 111: DPToAttributeMapping( TuyaValveWaterConsumed.ep_attribute, "current_summ_delivered", ), + 112: DPToAttributeMapping( + TuyaMCUCluster.ep_attribute, + "irrigation_other_ext", + ), + 113: DPToAttributeMapping( + TuyaMCUCluster.ep_attribute, + "irrigation_timeing_func", + ), 114: DPToAttributeMapping( TuyaMCUCluster.ep_attribute, "irrigation_duration", + converter=lambda x: giex_string_to_td(x), + ), + 115: DPToAttributeMapping( + TuyaMCUCluster.ep_attribute, + "device_tz", ), } - data_point_handlers = { 1: "_dp_2_attr_update", 2: "_dp_2_attr_update", @@ -364,9 +478,16 @@ class GiexValveManufCluster(TuyaMCUCluster): 103: "_dp_2_attr_update", 104: "_dp_2_attr_update", 105: "_dp_2_attr_update", + 106: "_dp_2_attr_update", + 107: "_dp_2_attr_update", 108: "_dp_2_attr_update", + 109: "_dp_2_attr_update", + 110: "_dp_2_attr_update", 111: "_dp_2_attr_update", + 112: "_dp_2_attr_update", + 113: "_dp_2_attr_update", 114: "_dp_2_attr_update", + 115: "_dp_2_attr_update", } async def write_attributes(self, attributes, manufacturer=None): @@ -377,93 +498,106 @@ async def write_attributes(self, attributes, manufacturer=None): ) -class GiexValve(CustomDevice): - """GiEX valve device.""" - - signature = { - MODELS_INFO: [ - ("_TZE200_sh1btabb", "TS0601"), - ("_TZE200_a7sghmms", "TS0601"), - ("_TZE204_7ytb3h8u", "TS0601"), - ("_TZE200_7ytb3h8u", "TS0601"), - ], - ENDPOINTS: { - # - 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], - } - } - } - +class GX02BaseQuirk: + """Giex GX02 Valve Base Quirk.""" + + base_quirk: QuirkBuilder + + def __init__(self, max_duration: int) -> None: + """Init a base quirk that applies to all GX02 valves.""" + time_unit: int = UnitOfTime.SECONDS + + if max_duration == GIEX_24HRS_AS_MIN: + time_unit = UnitOfTime.MINUTES + + self.base_quirk = ( + QuirkBuilder("GIEX", "GX02") + .replaces(GiexValveManufCluster) + .removes(TUYA_CLUSTER_ED00_ID) + .adds(TuyaOnOffNM) + .adds(GiexPowerConfigurationCluster4AA) + .adds(GiexValveWaterConsumed) + .number( + "irrigation_num_times", + GiexValveManufCluster.cluster_id, + min_value=0, + max_value=100, + step=1, + translation_key="irrigation_num_times", + fallback_name="Irrigation Num Times", + ) + .enum( + "irrigation_mode", + GiexValveManufCluster.GiexIrrigationMode, + GiexValveManufCluster.cluster_id, + translation_key="irrigation_mode", + fallback_name="Irrigation Mode", + ) + .enum( + "weather_delay", + GiexValveManufCluster.GiexIrrigationWeatherDelay, + GiexValveManufCluster.cluster_id, + translation_key="weather_delay", + fallback_name="Weather Delay", + initially_disabled=True, + ) + .sensor( + "irrigation_duration", + GiexValveManufCluster.cluster_id, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + unit=UnitOfTime.SECONDS, + translation_key="irrigation_duration", + fallback_name="Last Irrigation Duration", + ) + .sensor( + "irrigation_start_time", + GiexValveManufCluster.cluster_id, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="irrigation_start_time", + fallback_name="Irrigation Start Time", + ) + .sensor( + "irrigation_end_time", + GiexValveManufCluster.cluster_id, + device_class=SensorDeviceClass.TIMESTAMP, + translation_key="irrigation_end_time", + fallback_name="Irrigation End Time", + ) + .number( + "irrigation_target", + GiexValveManufCluster.cluster_id, + min_value=0, + max_value=max_duration, + step=1, + translation_key="irrigation_target", + fallback_name="Irrigation Target", + ) + .number( + "irrigation_interval", + GiexValveManufCluster.cluster_id, + min_value=0, + max_value=max_duration, + step=1, + unit=time_unit, + translation_key="irrigation_interval", + fallback_name="Irrigation Interval", + ) + ) + self.base_quirk.manufacturer_model_metadata = [] -class GiexValveVar02(CustomDevice): - """GiEX valve device, variant 2.""" - signature = { - MODELS_INFO: [ - ("_TZE284_7ytb3h8u", "TS0601"), - ], - ENDPOINTS: { - # - 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], - } - } - } +( + GX02BaseQuirk(GIEX_24HRS_AS_MIN) + .base_quirk.also_applies_to("_TZE200_sh1btabb", "TS0601") + .add_to_registry() +) +( + GX02BaseQuirk(GIEX_12HRS_AS_SEC) + .base_quirk.also_applies_to("_TZE200_a7sghmms", "TS0601") + .also_applies_to("_TZE204_a7sghmms", "TS0601") + .also_applies_to("_TZE200_7ytb3h8u", "TS0601") + .also_applies_to("_TZE204_7ytb3h8u", "TS0601") + .also_applies_to("_TZE284_7ytb3h8u", "TS0601") + .add_to_registry() +)