diff --git a/setup.py b/setup.py index f8c4830f57..dd3a7a6c9c 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import find_packages, setup -VERSION = "0.0.94" +VERSION = "0.0.95" setup( diff --git a/tests/test_konke.py b/tests/test_konke.py index d5c6ca42c3..2df6f74c10 100644 --- a/tests/test_konke.py +++ b/tests/test_konke.py @@ -14,7 +14,7 @@ OFF, ON, PRESS_TYPE, - ZONE_STATE, + ZONE_STATUS_CHANGE_COMMAND, ) import zhaquirks.konke.motion @@ -46,7 +46,7 @@ async def test_konke_motion(zigpy_device_from_quirk, quirk): assert len(motion_listener.cluster_commands) == 1 assert len(motion_listener.attribute_updates) == 0 - assert motion_listener.cluster_commands[0][1] == ZONE_STATE + assert motion_listener.cluster_commands[0][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[0][2][0] == ON assert len(occupancy_listener.cluster_commands) == 0 @@ -57,7 +57,7 @@ async def test_konke_motion(zigpy_device_from_quirk, quirk): await asyncio.sleep(0.1) assert len(motion_listener.cluster_commands) == 2 - assert motion_listener.cluster_commands[1][1] == ZONE_STATE + assert motion_listener.cluster_commands[1][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[1][2][0] == OFF assert len(occupancy_listener.cluster_commands) == 0 diff --git a/tests/test_orvibo.py b/tests/test_orvibo.py index 6728ca8f11..b18d5d7eeb 100644 --- a/tests/test_orvibo.py +++ b/tests/test_orvibo.py @@ -6,7 +6,7 @@ import pytest import zhaquirks -from zhaquirks.const import OFF, ON, ZONE_STATE +from zhaquirks.const import OFF, ON, ZONE_STATUS_CHANGE_COMMAND import zhaquirks.orvibo.motion from tests.common import ZCL_IAS_MOTION_COMMAND, ClusterListener @@ -35,7 +35,7 @@ async def test_orvibo_motion(zigpy_device_from_quirk, quirk): assert len(motion_listener.cluster_commands) == 1 assert len(motion_listener.attribute_updates) == 0 - assert motion_listener.cluster_commands[0][1] == ZONE_STATE + assert motion_listener.cluster_commands[0][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[0][2][0] == ON assert len(occupancy_listener.cluster_commands) == 0 @@ -46,7 +46,7 @@ async def test_orvibo_motion(zigpy_device_from_quirk, quirk): await asyncio.sleep(0.1) assert len(motion_listener.cluster_commands) == 2 - assert motion_listener.cluster_commands[1][1] == ZONE_STATE + assert motion_listener.cluster_commands[1][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[1][2][0] == OFF assert len(occupancy_listener.cluster_commands) == 0 diff --git a/tests/test_tuya.py b/tests/test_tuya.py index bcbcb48283..67471a1b4d 100644 --- a/tests/test_tuya.py +++ b/tests/test_tuya.py @@ -2,13 +2,25 @@ import asyncio import datetime +import itertools from unittest import mock import pytest +import zigpy from zigpy.profiles import zha from zigpy.quirks import CustomDevice, get_device import zigpy.types as t from zigpy.zcl import foundation +from zigpy.zcl.clusters import CLUSTERS_BY_ID +from zigpy.zcl.clusters.general import ( + Basic, + GreenPowerProxy, + Groups, + Identify, + Ota, + PowerConfiguration, +) +from zigpy.zcl.clusters.lightlink import LightLink import zhaquirks from zhaquirks.const import ( @@ -20,9 +32,17 @@ ON, OUTPUT_CLUSTERS, PROFILE_ID, - ZONE_STATE, + ZONE_STATUS_CHANGE_COMMAND, +) +from zhaquirks.tuya import ( + Data, + TuyaEnchantableCluster, + TuyaManufClusterAttributes, + TuyaNewManufCluster, ) -from zhaquirks.tuya import Data, TuyaManufClusterAttributes, TuyaNewManufCluster +import zhaquirks.tuya.sm0202_motion +import zhaquirks.tuya.ts011f_plug +import zhaquirks.tuya.ts0041 import zhaquirks.tuya.ts0042 import zhaquirks.tuya.ts0043 import zhaquirks.tuya.ts0501_fan_switch @@ -100,14 +120,13 @@ async def test_motion(zigpy_device_from_quirk, quirk): tuya_cluster.handle_message(hdr, args) assert len(motion_listener.cluster_commands) == 1 - assert len(motion_listener.attribute_updates) == 1 - assert motion_listener.cluster_commands[0][1] == ZONE_STATE + assert motion_listener.cluster_commands[0][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[0][2][0] == ON await asyncio.gather(asyncio.sleep(0), asyncio.sleep(0), asyncio.sleep(0)) assert len(motion_listener.cluster_commands) == 2 - assert motion_listener.cluster_commands[1][1] == ZONE_STATE + assert motion_listener.cluster_commands[1][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[1][2][0] == OFF @@ -1527,3 +1546,147 @@ async def test_fan_switch_writes_attributes(zigpy_device_from_quirk, quirk): 1, b"\x00\x01\x02\x01\x000\x00", ) + + +async def test_sm0202_motion_sensor_signature(assert_signature_matches_quirk): + """Test LH992ZB motion sensor remote signature is matched to its quirk.""" + signature = { + "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4098, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)", + "endpoints": { + "1": { + "profile_id": 260, + "device_type": "0x0402", + "in_clusters": ["0x0000", "0x0001", "0x0003", "0x0500", "0xeeff"], + "out_clusters": ["0x0019"], + } + }, + "manufacturer": "_TYZB01_z2umiwvq", + "model": "SM0202", + "class": "zhaquirks.tuya.lh992zb.TuyaMotionSM0202", + } + assert_signature_matches_quirk(zhaquirks.tuya.sm0202_motion.SM0202Motion, signature) + + +@pytest.mark.parametrize( + "quirk", + (zhaquirks.tuya.ts0041.TuyaSmartRemote0041TOPlusA,), +) +async def test_power_config_no_bind(zigpy_device_from_quirk, quirk): + """Test that the power configuration cluster is not bound and no attribute reporting is set up.""" + + device = zigpy_device_from_quirk(quirk) + power_cluster = device.endpoints[1].power + + request_patch = mock.patch("zigpy.zcl.Cluster.request", mock.AsyncMock()) + bind_patch = mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) + + with request_patch as request_mock, bind_patch as bind_mock: + request_mock.return_value = (foundation.Status.SUCCESS, "done") + + await power_cluster.bind() + await power_cluster.configure_reporting( + PowerConfiguration.attributes_by_name["battery_percentage_remaining"].id, + 3600, + 10800, + 1, + ) + + assert len(request_mock.mock_calls) == 0 + assert len(bind_mock.mock_calls) == 0 + + +ENCHANTED_QUIRKS = [] +for manufacturer in zigpy.quirks._DEVICE_REGISTRY._registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk_entry in model_quirk_list: + if quirk_entry in ENCHANTED_QUIRKS: + continue + # right now, this basically includes `issubclass(quirk, EnchantedDevice)`, as that sets `TUYA_SPELL` + if getattr(quirk_entry, "TUYA_SPELL", False): + ENCHANTED_QUIRKS.append(quirk_entry) + +del quirk_entry, model_quirk_list, manufacturer + + +@mock.patch("zigpy.zcl.Cluster.bind", mock.AsyncMock()) +async def test_tuya_spell(zigpy_device_from_quirk): + """Test that enchanted Tuya devices have their spell applied when binding OnOff cluster.""" + non_bindable_cluster_ids = [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Ota.cluster_id, + GreenPowerProxy.cluster_id, + LightLink.cluster_id, + ] + + request_patch = mock.patch("zigpy.zcl.Cluster.request", mock.AsyncMock()) + with request_patch as request_mock: + request_mock.return_value = (foundation.Status.SUCCESS, "done") + + for quirk in ENCHANTED_QUIRKS: + device = zigpy_device_from_quirk(quirk) + + for cluster in itertools.chain( + device.endpoints[1].in_clusters.values(), + device.endpoints[1].out_clusters.values(), + ): + + # emulate ZHA calling bind() on most default clusters with an unchanged ep_attribute + if ( + not isinstance(cluster, int) + and cluster.cluster_id not in non_bindable_cluster_ids + and cluster.cluster_id in CLUSTERS_BY_ID + and CLUSTERS_BY_ID[cluster.cluster_id].ep_attribute + == cluster.ep_attribute + ): + await cluster.bind() + + # check that exactly one Tuya spell was cast + if len(request_mock.mock_calls) == 0: + pytest.fail( + f"Enchanted quirk {quirk} did not cast a Tuya spell. " + f"One bindable cluster subclassing `TuyaEnchantableCluster` on endpoint 1 needs to be implemented. " + f"Also check that enchanted bindable clusters do not modify their `ep_attribute`, " + f"as ZHA will not call bind() in that case." + ) + elif len(request_mock.mock_calls) > 1: + pytest.fail( + f"Enchanted quirk {quirk} cast more than one Tuya spell. " + f"Make sure to only implement one cluster subclassing `TuyaEnchantableCluster` on endpoint 1." + ) + + assert ( + request_mock.mock_calls[0][1][1] + == foundation.GeneralCommand.Read_Attributes + ) # read attributes + assert request_mock.mock_calls[0][1][3] == [4, 0, 1, 5, 7, 65534] # spell + request_mock.reset_mock() + + +def test_tuya_spell_devices_valid(): + """Test that all enchanted Tuya devices implement at least one enchanted cluster.""" + + for quirk in ENCHANTED_QUIRKS: + enchanted_clusters = 0 + + # iterate over all clusters in the replacement + for endpoint_id, endpoint in quirk.replacement[ENDPOINTS].items(): + if endpoint_id != 1: # spell is only activated on endpoint 1 for now + continue + for cluster in endpoint[INPUT_CLUSTERS] + endpoint[OUTPUT_CLUSTERS]: + if not isinstance(cluster, int) and issubclass( + cluster, TuyaEnchantableCluster + ): + enchanted_clusters += 1 + + # one EnchantedDevice must have exactly one enchanted cluster on endpoint 1 + if enchanted_clusters == 0: + pytest.fail( + f"{quirk} does not have a cluster subclassing `TuyaEnchantableCluster` on endpoint 1 " + f"as required by the Tuya spell." + ) + elif enchanted_clusters > 1: + pytest.fail( + f"{quirk} has more than one cluster subclassing `TuyaEnchantableCluster` on endpoint 1" + ) diff --git a/tests/test_tuya_air.py b/tests/test_tuya_air.py index 80e3acbc75..427edc423b 100644 --- a/tests/test_tuya_air.py +++ b/tests/test_tuya_air.py @@ -49,6 +49,16 @@ def air_quality_device(zigpy_device_from_quirk): "temperature", 2880, ), + ( + b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\xff\xfb", + "temperature", + -50, + ), + ( + b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\xff\xef", + "temperature", + -170, + ), ), ) def test_co2_sensor(air_quality_device, data, ep_attr, expected_value): diff --git a/tests/test_tuya_valve.py b/tests/test_tuya_valve.py index d07ded2f80..5a205bdbb2 100644 --- a/tests/test_tuya_valve.py +++ b/tests/test_tuya_valve.py @@ -12,7 +12,6 @@ zhaquirks.setup() -@mock.patch("zhaquirks.tuya.mcu.EnchantedDevice.spell", mock.AsyncMock()) @pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_valve.ParksidePSBZS,)) async def test_command_psbzs(zigpy_device_from_quirk, quirk): """Test executing cluster commands for PARKSIDE water valve.""" @@ -41,7 +40,6 @@ async def test_command_psbzs(zigpy_device_from_quirk, quirk): assert rsp.status == foundation.Status.SUCCESS -@mock.patch("zhaquirks.tuya.mcu.EnchantedDevice.spell", mock.AsyncMock()) @pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0601_valve.ParksidePSBZS,)) async def test_write_attr_psbzs(zigpy_device_from_quirk, quirk): """Test write cluster attributes for PARKSIDE water valve.""" diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py index b7d51901ec..e46f0199c8 100644 --- a/tests/test_xiaomi.py +++ b/tests/test_xiaomi.py @@ -6,6 +6,9 @@ import zigpy.device import zigpy.types as t from zigpy.zcl import foundation +from zigpy.zcl.clusters.general import PowerConfiguration +from zigpy.zcl.clusters.hvac import Thermostat +from zigpy.zcl.clusters.security import IasZone import zhaquirks from zhaquirks.const import ( @@ -19,7 +22,7 @@ ON, OUTPUT_CLUSTERS, PROFILE_ID, - ZONE_STATE, + ZONE_STATUS_CHANGE_COMMAND, ) from zhaquirks.xiaomi import ( CONSUMPTION_REPORTED, @@ -51,6 +54,7 @@ import zhaquirks.xiaomi.aqara.motion_aq2 import zhaquirks.xiaomi.aqara.motion_aq2b import zhaquirks.xiaomi.aqara.plug_eu +import zhaquirks.xiaomi.aqara.smoke import zhaquirks.xiaomi.mija.motion from tests.common import ZCL_OCC_ATTR_RPT_OCC, ClusterListener @@ -110,8 +114,7 @@ async def test_xiaomi_motion(zigpy_device_from_quirk, quirk): occupancy_cluster.handle_message(hdr, args) assert len(motion_listener.cluster_commands) == 1 - assert len(motion_listener.attribute_updates) == 1 - assert motion_listener.cluster_commands[0][1] == ZONE_STATE + assert motion_listener.cluster_commands[0][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[0][2][0] == ON assert len(occupancy_listener.cluster_commands) == 0 @@ -122,7 +125,7 @@ async def test_xiaomi_motion(zigpy_device_from_quirk, quirk): await asyncio.sleep(0.1) assert len(motion_listener.cluster_commands) == 2 - assert motion_listener.cluster_commands[1][1] == ZONE_STATE + assert motion_listener.cluster_commands[1][1] == ZONE_STATUS_CHANGE_COMMAND assert motion_listener.cluster_commands[1][2][0] == OFF assert len(occupancy_listener.cluster_commands) == 0 @@ -699,3 +702,250 @@ class Listener: assert cluster_listener.attribute_updated.call_count == call_count for call in calls: assert call in cluster_listener.attribute_updated.mock_calls + + +@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.smoke.LumiSensorSmokeAcn03,)) +async def test_aqara_smoke_sensor_attribute_update(zigpy_device_from_quirk, quirk): + """Test update_attribute on Aqara smoke sensor.""" + + device = zigpy_device_from_quirk(quirk) + + opple_cluster = device.endpoints[1].opple_cluster + opple_listener = ClusterListener(opple_cluster) + + ias_cluster = device.endpoints[1].ias_zone + ias_listener = ClusterListener(ias_cluster) + + zone_status_id = IasZone.attributes_by_name["zone_status"].id + + # check that updating Xiaomi smoke attribute also updates zone status on the Ias Zone cluster + + # turn on smoke alarm + opple_cluster._update_attribute(0x013A, 1) + assert len(opple_listener.attribute_updates) == 1 + assert len(ias_listener.attribute_updates) == 1 + assert ias_listener.attribute_updates[0][0] == zone_status_id + assert ias_listener.attribute_updates[0][1] == IasZone.ZoneStatus.Alarm_1 + + # turn off smoke alarm + opple_cluster._update_attribute(0x013A, 0) + assert len(opple_listener.attribute_updates) == 2 + assert len(ias_listener.attribute_updates) == 2 + assert ias_listener.attribute_updates[1][0] == zone_status_id + assert ias_listener.attribute_updates[1][1] == 0 + + # check if fake dB/m smoke density attribute is also updated + opple_cluster._update_attribute(0x013B, 10) + assert len(opple_listener.attribute_updates) == 4 + assert opple_listener.attribute_updates[2][0] == 0x013B + assert opple_listener.attribute_updates[2][1] == 10 + assert opple_listener.attribute_updates[3][0] == 0x1403 # fake attribute + assert opple_listener.attribute_updates[3][1] == 0.125 + + +@pytest.mark.parametrize( + "raw_report, expected_zone_status", + ( + ( + "1C5F11E10AF700413E0121360C0328190421A81305211E0006240200000000082111010A21" + "00000C20016620036720016821A800A0210000A12000A22000A32000A42000A52000", + 0, + ), + ), +) +async def test_aqara_smoke_sensor_xiaomi_attribute_report( + zigpy_device_from_quirk, raw_report, expected_zone_status +): + """Test that a Xiaomi attribute report changes the IAS zone status on Aqara smoke sensor.""" + raw_report = bytes.fromhex(raw_report) + + device = zigpy_device_from_quirk(zhaquirks.xiaomi.aqara.smoke.LumiSensorSmokeAcn03) + + opple_cluster = device.endpoints[1].opple_cluster + opple_listener = ClusterListener(opple_cluster) + + ias_cluster = device.endpoints[1].ias_zone + ias_listener = ClusterListener(ias_cluster) + + device.handle_message( + 260, + opple_cluster.cluster_id, + opple_cluster.endpoint.endpoint_id, + opple_cluster.endpoint.endpoint_id, + raw_report, + ) + + # check that Xiaomi attribute report also updates attribute cache + assert len(opple_listener.attribute_updates) == 1 + assert opple_listener.attribute_updates[0][0] == 0x00F7 + + # check that Xiaomi attribute report resets smoke zone status + assert len(ias_listener.attribute_updates) == 1 + assert ( + ias_listener.attribute_updates[0][0] + == IasZone.attributes_by_name["zone_status"].id + ) + assert ias_listener.attribute_updates[0][1] == expected_zone_status + + +@pytest.mark.parametrize( + "attr_redirect, attr_no_redirect", + [ + ("system_mode", "unoccupied_heating_setpoint"), + ( + Thermostat.attributes_by_name["system_mode"].id, + Thermostat.attributes_by_name["unoccupied_heating_setpoint"].id, + ), + ], +) +async def test_xiaomi_e1_thermostat_rw_redirection( + zigpy_device_from_quirk, + attr_redirect, + attr_no_redirect, +): + """Test system_mode rw redirection to OppleCluster on Xiaomi E1 thermostat with id and named reads/writes.""" + + device = zigpy_device_from_quirk(zhaquirks.xiaomi.aqara.thermostat_agl001.AGL001) + + opple_cluster = device.endpoints[1].opple_cluster + opple_listener = ClusterListener(opple_cluster) + + thermostat_cluster = device.endpoints[1].thermostat + thermostat_listener = ClusterListener(thermostat_cluster) + + # fake read response for attributes: return 1 for all attributes + def mock_read(attributes, manufacturer=None): + records = [ + foundation.ReadAttributeRecord( + attr, foundation.Status.SUCCESS, foundation.TypeValue(None, 1) + ) + for attr in attributes + ] + return (records,) + + # patch read commands + patch_opple_read = mock.patch.object( + opple_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read) + ) + patch_thermostat_read = mock.patch.object( + thermostat_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read) + ) + + # patch write commands + patch_opple_write = mock.patch.object( + opple_cluster, + "_write_attributes", + mock.AsyncMock( + return_value=( + [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)], + ) + ), + ) + patch_thermostat_write = mock.patch.object( + thermostat_cluster, + "_write_attributes", + mock.AsyncMock( + return_value=( + [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)], + ) + ), + ) + + with patch_opple_read, patch_thermostat_read, patch_opple_write, patch_thermostat_write: + # test reads: + + # read system_mode attribute from thermostat cluster + await thermostat_cluster.read_attributes([attr_redirect]) + + # check that system_mode reads were directed to the Opple cluster + assert len(thermostat_cluster._read_attributes.mock_calls) == 0 + assert len(opple_cluster._read_attributes.mock_calls) == 1 + assert opple_cluster._read_attributes.mock_calls[0][1][0] == [ + 0x0271 + ] # Opple system_mode attribute + assert thermostat_listener.attribute_updates[0] == ( + Thermostat.attributes_by_name["system_mode"].id, + Thermostat.SystemMode.Heat, + ) # check that attributes are correctly mapped and updated on ZCL thermostat cluster + + thermostat_cluster._read_attributes.reset_mock() + opple_cluster._read_attributes.reset_mock() + + # check that other attribute reads are not redirected + await thermostat_cluster.read_attributes([attr_no_redirect]) + + assert len(thermostat_cluster._read_attributes.mock_calls) == 1 + assert len(opple_cluster._read_attributes.mock_calls) == 0 + + thermostat_cluster._read_attributes.reset_mock() + opple_cluster._read_attributes.reset_mock() + + # test writes: + + # write system_mode attribute to thermostat cluster + await thermostat_cluster.write_attributes( + {attr_redirect: Thermostat.SystemMode.Heat} + ) + + # check that system_mode writes were directed to the Opple cluster + assert len(thermostat_cluster._write_attributes.mock_calls) == 0 + assert len(opple_cluster._write_attributes.mock_calls) == 1 + assert opple_listener.attribute_updates[1] == (0x0271, 1) # Opple system_mode + + assert thermostat_listener.attribute_updates[2] == ( + Thermostat.attributes_by_name["system_mode"].id, + Thermostat.SystemMode.Heat, + ) # check ZCL attribute is in correct mode + + thermostat_cluster._write_attributes.reset_mock() + opple_cluster._write_attributes.reset_mock() + + # check that other attribute writes are not redirected + await thermostat_cluster.write_attributes({attr_no_redirect: 2000}) + + assert len(thermostat_cluster._write_attributes.mock_calls) == 1 + assert len(opple_cluster._write_attributes.mock_calls) == 0 + + +@pytest.mark.parametrize("quirk", (zhaquirks.xiaomi.aqara.thermostat_agl001.AGL001,)) +async def test_xiaomi_e1_thermostat_attribute_update(zigpy_device_from_quirk, quirk): + """Test update_attribute on Xiaomi E1 thermostat.""" + + device = zigpy_device_from_quirk(quirk) + + opple_cluster = device.endpoints[1].opple_cluster + opple_listener = ClusterListener(opple_cluster) + + thermostat_cluster = device.endpoints[1].thermostat + thermostat_listener = ClusterListener(thermostat_cluster) + + power_config_cluster = device.endpoints[1].power + power_config_listener = ClusterListener(power_config_cluster) + + zcl_system_mode_id = Thermostat.attributes_by_name["system_mode"].id + zcl_battery_percentage_id = PowerConfiguration.attributes_by_name[ + "battery_percentage_remaining" + ].id + + # check that updating Xiaomi system_mode also updates an attribute on the Thermostat cluster + + # turn off heating + opple_cluster._update_attribute(0x0271, 0) + assert len(opple_listener.attribute_updates) == 1 + assert len(thermostat_listener.attribute_updates) == 1 + assert thermostat_listener.attribute_updates[0][0] == zcl_system_mode_id + assert thermostat_listener.attribute_updates[0][1] == Thermostat.SystemMode.Off + + # turn on heating + opple_cluster._update_attribute(0x0271, 1) + assert len(opple_listener.attribute_updates) == 2 + assert len(thermostat_listener.attribute_updates) == 2 + assert thermostat_listener.attribute_updates[1][0] == zcl_system_mode_id + assert thermostat_listener.attribute_updates[1][1] == Thermostat.SystemMode.Heat + + # check that updating battery_percentage on the OppleCluster also updates the PowerConfiguration cluster + opple_cluster._update_attribute(0x040A, 50) # 50% battery + assert len(opple_listener.attribute_updates) == 3 + assert len(power_config_listener.attribute_updates) == 1 + assert power_config_listener.attribute_updates[0][0] == zcl_battery_percentage_id + assert power_config_listener.attribute_updates[0][1] == 100 # ZCL is doubled diff --git a/zhaquirks/__init__.py b/zhaquirks/__init__.py index d61e19cf7b..a9027a3a1d 100644 --- a/zhaquirks/__init__.py +++ b/zhaquirks/__init__.py @@ -41,7 +41,7 @@ UNKNOWN, VALUE, ZHA_SEND_EVENT, - ZONE_STATE, + ZONE_STATUS_CHANGE_COMMAND, ) _LOGGER = logging.getLogger(__name__) @@ -242,9 +242,10 @@ def __init__(self, *args, **kwargs): def _turn_off(self): self._timer_handle = None - _LOGGER.debug("%s - Resetting motion sensor", self.endpoint.device.ieee) - self.listener_event(CLUSTER_COMMAND, 253, ZONE_STATE, [OFF, 0, 0, 0]) - self._update_attribute(ZONE_STATE, OFF) + self.debug("%s - Resetting motion sensor", self.endpoint.device.ieee) + self.listener_event( + CLUSTER_COMMAND, 253, ZONE_STATUS_CHANGE_COMMAND, [OFF, 0, 0, 0] + ) class MotionWithReset(_Motion): @@ -265,7 +266,8 @@ def handle_cluster_request( ] = None, ): """Handle the cluster command.""" - if hdr.command_id == ZONE_STATE: + # check if the command is for a zone status change of ZoneStatus.Alarm_1 or ZoneStatus.Alarm_2 + if hdr.command_id == ZONE_STATUS_CHANGE_COMMAND and args[0] & 3: if self._timer_handle: self._timer_handle.cancel() self._timer_handle = self._loop.call_later(self.reset_s, self._turn_off) @@ -285,10 +287,11 @@ def __init__(self, *args, **kwargs): def motion_event(self): """Motion event.""" - super().listener_event(CLUSTER_COMMAND, 254, ZONE_STATE, [ON, 0, 0, 0]) - self._update_attribute(ZONE_STATE, ON) + super().listener_event( + CLUSTER_COMMAND, 254, ZONE_STATUS_CHANGE_COMMAND, [ON, 0, 0, 0] + ) - _LOGGER.debug("%s - Received motion event message", self.endpoint.device.ieee) + self.debug("%s - Received motion event message", self.endpoint.device.ieee) if self._timer_handle: self._timer_handle.cancel() diff --git a/zhaquirks/const.py b/zhaquirks/const.py index 2d6cb4a1b4..aa24202f20 100644 --- a/zhaquirks/const.py +++ b/zhaquirks/const.py @@ -37,6 +37,13 @@ COMMAND_FURIOUS = "furious" COMMAND_HOLD = "hold" COMMAND_ID = "command_id" +COMMAND_M_INITIAL_PRESS = "initial_press" +COMMAND_M_LONG_PRESS = "long_press" +COMMAND_M_LONG_RELEASE = "long_release" +COMMAND_M_MULTI_PRESS_COMPLETE = "multi_press_complete" +COMMAND_M_MULTI_PRESS_ONGOING = "multi_press_ongoing" +COMMAND_M_SHORT_RELEASE = "short_release" +COMMAND_M_SWLATCHED = "switch_latched" COMMAND_MOVE = "move" COMMAND_MOVE_COLOR_TEMP = "move_color_temp" COMMAND_MOVE_ON_OFF = "move_with_on_off" @@ -89,6 +96,7 @@ OUTPUT_CLUSTERS = SIG_EP_OUTPUT PARAMS = "params" PRESS_TYPE = "press_type" +PRESSED = "initial_switch_press" PROFILE_ID = SIG_EP_PROFILE QUADRUPLE_PRESS = "remote_button_quadruple_press" QUINTUPLE_PRESS = "remote_button_quintuple_press" @@ -103,12 +111,14 @@ ALT_SHORT_PRESS = "remote_button_alt_short_press" SKIP_CONFIGURATION = SIG_SKIP_CONFIG SHORT_RELEASE = "remote_button_short_release" +TOGGLE = "toggle" TRIPLE_PRESS = "remote_button_triple_press" TURN_OFF = "turn_off" TURN_ON = "turn_on" UNKNOWN = "Unknown" VALUE = "value" ZHA_SEND_EVENT = "zha_send_event" -ZONE_STATE = 0 +ZONE_STATUS_CHANGE_COMMAND = 0x0000 +ZONE_STATE = 0x0000 ZONE_TYPE = 0x0001 ZONE_STATUS = 0x0002 diff --git a/zhaquirks/develco/motion.py b/zhaquirks/develco/motion.py index 2d7d30dd73..bb5f5e909d 100644 --- a/zhaquirks/develco/motion.py +++ b/zhaquirks/develco/motion.py @@ -211,3 +211,188 @@ class MOSZB140(CustomDevice): }, } } + + +class MOSZB140_Var02(CustomDevice): + """Custom device Develco Motion Sensor Pro (variation 02).""" + + manufacturer_id_override = MANUFACTURER + + signature = { + # + # + # + # + # + # + # + MODELS_INFO: [(DEVELCO, "MOSZB-140"), (FRIENT, "MOSZB-140")], + ENDPOINTS: { + 1: { + PROFILE_ID: 0xC0C9, + DEVICE_TYPE: 1, + INPUT_CLUSTERS: [ + Identify.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 34: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 35: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + BinaryInput.cluster_id, + PollControl.cluster_id, + IasZone.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Time.cluster_id, + Ota.cluster_id, + ], + }, + 38: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + TemperatureMeasurement.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 39: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.LIGHT_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + IlluminanceMeasurement.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 40: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 41: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: 0xC0C9, + DEVICE_TYPE: 1, + INPUT_CLUSTERS: [ + Identify.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 34: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 35: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + DevelcoPowerConfiguration, + Identify.cluster_id, + BinaryInput.cluster_id, + PollControl.cluster_id, + DevelcoIasZone, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Time.cluster_id, + Ota.cluster_id, + ], + }, + 38: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + TemperatureMeasurement.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 39: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.LIGHT_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + IlluminanceMeasurement.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 40: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + 41: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.OCCUPANCY_SENSOR, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + OccupancySensing.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + } + } diff --git a/zhaquirks/ikea/__init__.py b/zhaquirks/ikea/__init__.py index 93dccc47b1..8a2c0a7c66 100644 --- a/zhaquirks/ikea/__init__.py +++ b/zhaquirks/ikea/__init__.py @@ -4,10 +4,10 @@ from zigpy.quirks import CustomCluster import zigpy.types as t from zigpy.zcl import foundation -from zigpy.zcl.clusters.general import Scenes +from zigpy.zcl.clusters.general import PowerConfiguration, Scenes from zigpy.zcl.clusters.lightlink import LightLink -from zhaquirks import DoublingPowerConfigurationCluster +from zhaquirks import DoublingPowerConfigurationCluster, EventableCluster _LOGGER = logging.getLogger(__name__) @@ -15,6 +15,18 @@ IKEA_CLUSTER_ID = 0xFC7C # decimal = 64636 WWAH_CLUSTER_ID = 0xFC57 # decimal = 64599 ('Works with all Hubs' cluster) +IKEA_SHORTCUT_CLUSTER_V1_ID = 0xFC7F # decimal = 64639 Shortcut V1 commands +IKEA_MATTER_SWITCH_CLUSTER_ID = 0xFC80 # decimal = 64640 Shortcut V2 commands +COMMAND_SHORTCUT_V1 = "shortcut_v1_events" + +# PowerConfiguration cluster attributes +BATTERY_VOLTAGE = PowerConfiguration.attributes_by_name["battery_voltage"].id +BATTERY_SIZE = PowerConfiguration.attributes_by_name["battery_size"].id +BATTERY_QUANTITY = PowerConfiguration.attributes_by_name["battery_quantity"].id +BATTERY_RATED_VOLTAGE = PowerConfiguration.attributes_by_name[ + "battery_rated_voltage" +].id + class LightLinkCluster(CustomCluster, LightLink): """Ikea LightLink cluster.""" @@ -73,59 +85,153 @@ class ScenesCluster(CustomCluster, Scenes): ) -class PowerConfiguration2AAACluster(DoublingPowerConfigurationCluster): - """Updating Power attributes 2 AAA.""" +class ShortcutV1Cluster(EventableCluster): + """Ikea Shortcut Button Cluster Variant 1.""" + + cluster_id = IKEA_SHORTCUT_CLUSTER_V1_ID + + server_commands = { + 0x01: foundation.ZCLCommandDef( + COMMAND_SHORTCUT_V1, + { + "shortcut_button": t.int8s, + "shortcut_event": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + } + + +class ShortcutV2Cluster(EventableCluster): + """Ikea Shortcut Button Cluster Variant 2.""" + + cluster_id = IKEA_MATTER_SWITCH_CLUSTER_ID + + server_commands = { + 0x00: foundation.ZCLCommandDef( + "switch_latched", + { + "new_position": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + 0x01: foundation.ZCLCommandDef( + "initial_press", + { + "new_position": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + 0x02: foundation.ZCLCommandDef( + "long_press", + { + "previous_position": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + 0x03: foundation.ZCLCommandDef( + "short_release", + { + "previous_position": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + 0x04: foundation.ZCLCommandDef( + "long_release", + { + "previous_position": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + 0x05: foundation.ZCLCommandDef( + "multi_press_ongoing", + { + "new_position": t.int8s, + # "current_number_of_presses_counted": t.int8s, # not implemented + }, + False, + is_manufacturer_specific=True, + ), + 0x06: foundation.ZCLCommandDef( + "multi_press_complete", + { + "previous_position": t.int8s, + "total_number_of_presses_counted": t.int8s, + }, + False, + is_manufacturer_specific=True, + ), + } - BATTERY_SIZES = 0x0031 - BATTERY_QUANTITY = 0x0033 - BATTERY_RATED_VOLTAGE = 0x0034 + +# ZCL compliant IKEA power configuration clusters: +class PowerConfig2AAACluster(CustomCluster, PowerConfiguration): + """Updating power attributes: 2 AAA.""" _CONSTANT_ATTRIBUTES = { - BATTERY_SIZES: 4, + BATTERY_SIZE: 4, BATTERY_QUANTITY: 2, BATTERY_RATED_VOLTAGE: 15, } -class PowerConfiguration2CRCluster(DoublingPowerConfigurationCluster): - """Updating Power attributes 2 CR2032.""" - - BATTERY_SIZES = 0x0031 - BATTERY_QUANTITY = 0x0033 - BATTERY_RATED_VOLTAGE = 0x0034 +class PowerConfig2CRCluster(CustomCluster, PowerConfiguration): + """Updating power attributes: 2 CR2032.""" _CONSTANT_ATTRIBUTES = { - BATTERY_SIZES: 10, + BATTERY_SIZE: 10, BATTERY_QUANTITY: 2, BATTERY_RATED_VOLTAGE: 30, } -class PowerConfiguration1CRCluster(DoublingPowerConfigurationCluster): - """Updating Power attributes 1 CR2032.""" - - BATTERY_SIZES = 0x0031 - BATTERY_QUANTITY = 0x0033 - BATTERY_RATED_VOLTAGE = 0x0034 +class PowerConfig1CRCluster(CustomCluster, PowerConfiguration): + """Updating power attributes: 1 CR2032.""" _CONSTANT_ATTRIBUTES = { - BATTERY_SIZES: 10, + BATTERY_SIZE: 10, BATTERY_QUANTITY: 1, BATTERY_RATED_VOLTAGE: 30, } -class PowerConfiguration1CRXCluster(DoublingPowerConfigurationCluster): - """Updating Power attributes 1 CR2032 and Zero voltage.""" - - BATTERY_VOLTAGE = 0x0020 - BATTERY_SIZES = 0x0031 - BATTERY_QUANTITY = 0x0033 - BATTERY_RATED_VOLTAGE = 0x0034 +class PowerConfig1CRXCluster(CustomCluster, PowerConfiguration): + """Updating power attributes: 1 CR2032 and zero voltage.""" _CONSTANT_ATTRIBUTES = { BATTERY_VOLTAGE: 0, - BATTERY_SIZES: 10, + BATTERY_SIZE: 10, BATTERY_QUANTITY: 1, BATTERY_RATED_VOLTAGE: 30, } + + +# doubling IKEA power configuration clusters: +class DoublingPowerConfig2AAACluster( + DoublingPowerConfigurationCluster, PowerConfig2AAACluster +): + """Doubling power configuration cluster. Updating power attributes: 2 AAA.""" + + +class DoublingPowerConfig2CRCluster( + DoublingPowerConfigurationCluster, PowerConfig2CRCluster +): + """Doubling power configuration cluster. Updating power attributes: 2 CR2032.""" + + +class DoublingPowerConfig1CRCluster( + DoublingPowerConfigurationCluster, PowerConfig1CRCluster +): + """Doubling power configuration cluster. Updating power attributes: 1 CR2032.""" + + +class DoublingPowerConfig1CRXCluster( + DoublingPowerConfigurationCluster, PowerConfig1CRXCluster +): + """Doubling power configuration cluster. Updating power attributes: 1 CR2032 and zero voltage.""" diff --git a/zhaquirks/ikea/dimmer.py b/zhaquirks/ikea/dimmer.py index 29ad66f5f0..fa6f79ff48 100644 --- a/zhaquirks/ikea/dimmer.py +++ b/zhaquirks/ikea/dimmer.py @@ -30,7 +30,7 @@ RIGHT, ROTATED, ) -from zhaquirks.ikea import IKEA, PowerConfiguration1CRXCluster +from zhaquirks.ikea import IKEA, DoublingPowerConfig1CRXCluster class IkeaDimmer(CustomDevice): @@ -72,7 +72,7 @@ class IkeaDimmer(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRXCluster, + DoublingPowerConfig1CRXCluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, diff --git a/zhaquirks/ikea/fivebtnremote.py b/zhaquirks/ikea/fivebtnremote.py index 9532acca05..8b8f773917 100644 --- a/zhaquirks/ikea/fivebtnremote.py +++ b/zhaquirks/ikea/fivebtnremote.py @@ -51,8 +51,9 @@ IKEA, IKEA_CLUSTER_ID, WWAH_CLUSTER_ID, + DoublingPowerConfig1CRCluster, LightLinkCluster, - PowerConfiguration1CRCluster, + PowerConfig1CRCluster, ScenesCluster, ) @@ -244,10 +245,10 @@ class IkeaTradfriRemote2(IkeaTradfriRemote1): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, IKEA_CLUSTER_ID, ], OUTPUT_CLUSTERS: [ @@ -305,7 +306,7 @@ class IkeaTradfriRemote3(IkeaTradfriRemote1): DEVICE_TYPE: zha.DeviceType.COLOR_SCENE_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, Alarms.cluster_id, LightLinkCluster, @@ -365,10 +366,10 @@ class IkeaTradfriRemote4(IkeaTradfriRemote1): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, IKEA_CLUSTER_ID, ], OUTPUT_CLUSTERS: [ @@ -427,10 +428,10 @@ class IkeaTradfriRemote5(IkeaTradfriRemote1): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + PowerConfig1CRCluster, Identify.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, WWAH_CLUSTER_ID, IKEA_CLUSTER_ID, ], diff --git a/zhaquirks/ikea/fourbtnremote.py b/zhaquirks/ikea/fourbtnremote.py index 5ea9d63d07..ee37f4b9b9 100644 --- a/zhaquirks/ikea/fourbtnremote.py +++ b/zhaquirks/ikea/fourbtnremote.py @@ -45,7 +45,7 @@ IKEA, IKEA_CLUSTER_ID, WWAH_CLUSTER_ID, - PowerConfiguration2AAACluster, + DoublingPowerConfig2AAACluster, ScenesCluster, ) @@ -89,7 +89,7 @@ class IkeaTradfriRemoteV1(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration2AAACluster, + DoublingPowerConfig2AAACluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, diff --git a/zhaquirks/ikea/motion.py b/zhaquirks/ikea/motion.py index f53d7ec61b..2a034607d5 100644 --- a/zhaquirks/ikea/motion.py +++ b/zhaquirks/ikea/motion.py @@ -21,7 +21,7 @@ OUTPUT_CLUSTERS, PROFILE_ID, ) -from zhaquirks.ikea import IKEA, LightLinkCluster, PowerConfiguration2CRCluster +from zhaquirks.ikea import IKEA, DoublingPowerConfig2CRCluster, LightLinkCluster class IkeaTradfriMotion(CustomDevice): @@ -63,7 +63,7 @@ class IkeaTradfriMotion(CustomDevice): DEVICE_TYPE: zll.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration2CRCluster, + DoublingPowerConfig2CRCluster, Identify.cluster_id, Alarms.cluster_id, Diagnostic.cluster_id, diff --git a/zhaquirks/ikea/motionzha.py b/zhaquirks/ikea/motionzha.py index f07baadf1b..cac3e359c8 100644 --- a/zhaquirks/ikea/motionzha.py +++ b/zhaquirks/ikea/motionzha.py @@ -27,8 +27,7 @@ IKEA, IKEA_CLUSTER_ID, WWAH_CLUSTER_ID, - LightLinkCluster, - PowerConfiguration2CRCluster, + DoublingPowerConfig2CRCluster, ) @@ -71,11 +70,11 @@ class IkeaTradfriMotionE1745_Var01(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration2CRCluster, + DoublingPowerConfig2CRCluster, Identify.cluster_id, Alarms.cluster_id, Diagnostic.cluster_id, - LightLinkCluster, + LightLink.cluster_id, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -130,11 +129,11 @@ class IkeaTradfriMotionE1745_Var02(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration2CRCluster, + DoublingPowerConfig2CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, IKEA_CLUSTER_ID, ], OUTPUT_CLUSTERS: [ @@ -190,10 +189,10 @@ class IkeaTradfriMotionE1525_Var01(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SENSOR, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration2CRCluster, + DoublingPowerConfig2CRCluster, Identify.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, IKEA_CLUSTER_ID, WWAH_CLUSTER_ID, ], diff --git a/zhaquirks/ikea/opencloseremote.py b/zhaquirks/ikea/opencloseremote.py index aefdd691a9..ba2da52123 100644 --- a/zhaquirks/ikea/opencloseremote.py +++ b/zhaquirks/ikea/opencloseremote.py @@ -35,7 +35,7 @@ SHORT_PRESS, ZHA_SEND_EVENT, ) -from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, PowerConfiguration1CRCluster +from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, DoublingPowerConfig1CRCluster COMMAND_CLOSE = "down_close" COMMAND_STOP_OPENING = "stop_opening" @@ -120,7 +120,7 @@ class IkeaTradfriOpenCloseRemote(CustomDevice): DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, diff --git a/zhaquirks/ikea/shortcutbtn.py b/zhaquirks/ikea/shortcutbtn.py index 2ca9370041..b72d3ce789 100644 --- a/zhaquirks/ikea/shortcutbtn.py +++ b/zhaquirks/ikea/shortcutbtn.py @@ -37,12 +37,7 @@ SHORT_PRESS, TURN_ON, ) -from zhaquirks.ikea import ( - IKEA, - IKEA_CLUSTER_ID, - LightLinkCluster, - PowerConfiguration1CRCluster, -) +from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, DoublingPowerConfig1CRCluster class IkeaTradfriShortcutBtn(CustomDevice): @@ -86,11 +81,11 @@ class IkeaTradfriShortcutBtn(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -163,11 +158,11 @@ class IkeaTradfriShortcutBtn2(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, diff --git a/zhaquirks/ikea/symfonisk.py b/zhaquirks/ikea/symfonisk.py index cb82f7e9e5..1469af1586 100644 --- a/zhaquirks/ikea/symfonisk.py +++ b/zhaquirks/ikea/symfonisk.py @@ -38,7 +38,7 @@ TRIPLE_PRESS, TURN_ON, ) -from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, PowerConfiguration1CRCluster +from zhaquirks.ikea import IKEA, IKEA_CLUSTER_ID, DoublingPowerConfig1CRCluster class IkeaSYMFONISK1(CustomDevice): @@ -80,7 +80,7 @@ class IkeaSYMFONISK1(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, @@ -176,7 +176,7 @@ class IkeaSYMFONISK2(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, PollControl.cluster_id, LightLink.cluster_id, diff --git a/zhaquirks/ikea/symfonisk2.py b/zhaquirks/ikea/symfonisk2.py new file mode 100644 index 0000000000..b4081b5221 --- /dev/null +++ b/zhaquirks/ikea/symfonisk2.py @@ -0,0 +1,347 @@ +"""Device handler for IKEA of Sweden SYMFONISK sound remote gen2.""" +from zigpy.profiles import zha +from zigpy.quirks import CustomDevice +from zigpy.zcl.clusters.general import ( + Basic, + Groups, + Identify, + LevelControl, + OnOff, + Ota, + PollControl, + PowerConfiguration, +) +from zigpy.zcl.clusters.lightlink import LightLink + +from zhaquirks.const import ( + BUTTON_1, + BUTTON_2, + CLUSTER_ID, + COMMAND, + COMMAND_M_INITIAL_PRESS, + COMMAND_M_LONG_PRESS, + COMMAND_M_LONG_RELEASE, + COMMAND_M_MULTI_PRESS_COMPLETE, + COMMAND_M_SHORT_RELEASE, + COMMAND_MOVE, + COMMAND_MOVE_ON_OFF, + COMMAND_STEP, + COMMAND_TOGGLE, + DEVICE_TYPE, + DIM_DOWN, + DIM_UP, + DOUBLE_PRESS, + ENDPOINT_ID, + ENDPOINTS, + INPUT_CLUSTERS, + LEFT, + LONG_PRESS, + LONG_RELEASE, + MODELS_INFO, + OUTPUT_CLUSTERS, + PARAMS, + PRESSED, + PROFILE_ID, + RIGHT, + SHORT_PRESS, + TOGGLE, +) +from zhaquirks.ikea import ( + COMMAND_SHORTCUT_V1, + IKEA, + IKEA_CLUSTER_ID, + WWAH_CLUSTER_ID, + DoublingPowerConfig2AAACluster, + PowerConfig2AAACluster, + ShortcutV1Cluster, + ShortcutV2Cluster, +) + + +class Symfonisk2CommonTriggers: + """IKEA Symfonisk 2 common device triggers.""" + + device_automation_triggers = { + (SHORT_PRESS, TOGGLE): { + COMMAND: COMMAND_TOGGLE, + CLUSTER_ID: 6, + ENDPOINT_ID: 1, + }, + (SHORT_PRESS, DIM_UP): { + COMMAND: COMMAND_MOVE_ON_OFF, + CLUSTER_ID: 8, + ENDPOINT_ID: 1, + PARAMS: {"move_mode": 0}, + }, + (LONG_PRESS, DIM_UP): { + COMMAND: COMMAND_MOVE, + CLUSTER_ID: 8, + ENDPOINT_ID: 1, + PARAMS: {"move_mode": 0}, + }, + (SHORT_PRESS, DIM_DOWN): { + COMMAND: COMMAND_MOVE_ON_OFF, + CLUSTER_ID: 8, + ENDPOINT_ID: 1, + PARAMS: {"move_mode": 1}, + }, + (LONG_PRESS, DIM_DOWN): { + COMMAND: COMMAND_MOVE, + CLUSTER_ID: 8, + ENDPOINT_ID: 1, + PARAMS: {"move_mode": 1}, + }, + (SHORT_PRESS, RIGHT): { + COMMAND: COMMAND_STEP, + CLUSTER_ID: 8, + ENDPOINT_ID: 1, + PARAMS: {"step_mode": 0}, + }, + (SHORT_PRESS, LEFT): { + COMMAND: COMMAND_STEP, + CLUSTER_ID: 8, + ENDPOINT_ID: 1, + PARAMS: {"step_mode": 1}, + }, + } + + +class IkeaSymfoniskGen2v1(CustomDevice): + """Custom device representing IKEA SYMFONISK sound remote gen2 V1.0.012.""" + + signature = { + # + MODELS_INFO: [(IKEA, "SYMFONISK sound remote gen2")], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + PollControl.cluster_id, + LightLink.cluster_id, + WWAH_CLUSTER_ID, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + LightLink.cluster_id, + ShortcutV1Cluster.cluster_id, + ], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + DoublingPowerConfig2AAACluster, + Identify.cluster_id, + PollControl.cluster_id, + LightLink.cluster_id, + WWAH_CLUSTER_ID, + ShortcutV1Cluster, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + LightLink.cluster_id, + ShortcutV1Cluster, + ], + }, + }, + } + + device_automation_triggers = ( + Symfonisk2CommonTriggers.device_automation_triggers.copy() + ) + device_automation_triggers.update( + { + (SHORT_PRESS, BUTTON_1): { + COMMAND: COMMAND_SHORTCUT_V1, + PARAMS: {"shortcut_button": 1, "shortcut_event": 1}, + }, + (DOUBLE_PRESS, BUTTON_1): { + COMMAND: COMMAND_SHORTCUT_V1, + PARAMS: {"shortcut_button": 1, "shortcut_event": 2}, + }, + (LONG_PRESS, BUTTON_1): { + COMMAND: COMMAND_SHORTCUT_V1, + PARAMS: {"shortcut_button": 1, "shortcut_event": 3}, + }, + (SHORT_PRESS, BUTTON_2): { + COMMAND: COMMAND_SHORTCUT_V1, + PARAMS: {"shortcut_button": 2, "shortcut_event": 1}, + }, + (DOUBLE_PRESS, BUTTON_2): { + COMMAND: COMMAND_SHORTCUT_V1, + PARAMS: {"shortcut_button": 2, "shortcut_event": 2}, + }, + (LONG_PRESS, BUTTON_2): { + COMMAND: COMMAND_SHORTCUT_V1, + PARAMS: {"shortcut_button": 2, "shortcut_event": 3}, + }, + }, + ) + + +class IkeaSymfoniskGen2v2(CustomDevice): + """Custom device representing IKEA SYMFONISK sound remote gen2 V1.0.32.""" + + signature = { + # + MODELS_INFO: [(IKEA, "SYMFONISK sound remote gen2")], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + PollControl.cluster_id, + LightLink.cluster_id, + IKEA_CLUSTER_ID, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + LightLink.cluster_id, + ], + }, + # + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ShortcutV2Cluster.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + ShortcutV2Cluster.cluster_id, + ], + }, + # + 3: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ShortcutV2Cluster.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + ShortcutV2Cluster.cluster_id, + ], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfig2AAACluster, + Identify.cluster_id, + PollControl.cluster_id, + LightLink.cluster_id, + IKEA_CLUSTER_ID, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + LightLink.cluster_id, + ], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ShortcutV2Cluster, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + ShortcutV2Cluster, + ], + }, + 3: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ShortcutV2Cluster, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + ShortcutV2Cluster, + ], + }, + }, + } + + device_automation_triggers = ( + Symfonisk2CommonTriggers.device_automation_triggers.copy() + ) + device_automation_triggers.update( + { + (PRESSED, BUTTON_1): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_INITIAL_PRESS}, + (SHORT_PRESS, BUTTON_1): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_SHORT_RELEASE}, + (DOUBLE_PRESS, BUTTON_1): { + ENDPOINT_ID: 2, + COMMAND: COMMAND_M_MULTI_PRESS_COMPLETE, + }, + (LONG_PRESS, BUTTON_1): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_LONG_PRESS}, + (LONG_RELEASE, BUTTON_1): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_LONG_RELEASE}, + (PRESSED, BUTTON_2): {ENDPOINT_ID: 3, COMMAND: COMMAND_M_INITIAL_PRESS}, + (SHORT_PRESS, BUTTON_2): {ENDPOINT_ID: 3, COMMAND: COMMAND_M_SHORT_RELEASE}, + (DOUBLE_PRESS, BUTTON_2): { + ENDPOINT_ID: 3, + COMMAND: COMMAND_M_MULTI_PRESS_COMPLETE, + }, + (LONG_PRESS, BUTTON_2): {ENDPOINT_ID: 3, COMMAND: COMMAND_M_LONG_PRESS}, + (LONG_RELEASE, BUTTON_2): {ENDPOINT_ID: 3, COMMAND: COMMAND_M_LONG_RELEASE}, + }, + ) diff --git a/zhaquirks/ikea/twobtnremote.py b/zhaquirks/ikea/twobtnremote.py index c655cecb5a..0d217d5660 100644 --- a/zhaquirks/ikea/twobtnremote.py +++ b/zhaquirks/ikea/twobtnremote.py @@ -43,8 +43,8 @@ from zhaquirks.ikea import ( IKEA, IKEA_CLUSTER_ID, + DoublingPowerConfig1CRCluster, LightLinkCluster, - PowerConfiguration1CRCluster, ) @@ -90,11 +90,11 @@ class IkeaTradfriRemote2Btn(CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration1CRCluster, + DoublingPowerConfig1CRCluster, Identify.cluster_id, Alarms.cluster_id, PollControl.cluster_id, - LightLinkCluster, + LightLink.cluster_id, IKEA_CLUSTER_ID, ], OUTPUT_CLUSTERS: [ @@ -146,7 +146,7 @@ class IkeaTradfriRemote2BtnZLL(CustomDevice): # device_version=248 # input_clusters=[0, 1, 3, 9, 258, 4096, 64636] # output_clusters=[3, 4, 6, 8, 25, 258, 4096]> - MODELS_INFO: IkeaTradfriRemote2Btn.signature[MODELS_INFO].copy(), + MODELS_INFO: [(IKEA, "TRADFRI on/off switch")], ENDPOINTS: { 1: { PROFILE_ID: zll.PROFILE_ID, @@ -160,25 +160,41 @@ class IkeaTradfriRemote2BtnZLL(CustomDevice): LightLink.cluster_id, IKEA_CLUSTER_ID, ], - OUTPUT_CLUSTERS: IkeaTradfriRemote2Btn.signature[ENDPOINTS][1][ - OUTPUT_CLUSTERS - ].copy(), + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + WindowCovering.cluster_id, + LightLink.cluster_id, + ], } }, } - signature[ENDPOINTS][1][INPUT_CLUSTERS].append(WindowCovering.cluster_id) replacement = { ENDPOINTS: { 1: { PROFILE_ID: zll.PROFILE_ID, DEVICE_TYPE: zll.DeviceType.CONTROLLER, - INPUT_CLUSTERS: IkeaTradfriRemote2Btn.replacement[ENDPOINTS][1][ - INPUT_CLUSTERS - ].copy(), - OUTPUT_CLUSTERS: IkeaTradfriRemote2Btn.replacement[ENDPOINTS][1][ - OUTPUT_CLUSTERS - ].copy(), + INPUT_CLUSTERS: [ + Basic.cluster_id, + DoublingPowerConfig1CRCluster, + Identify.cluster_id, + Alarms.cluster_id, + LightLinkCluster, + IKEA_CLUSTER_ID, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + WindowCovering.cluster_id, + LightLink.cluster_id, + ], } } } diff --git a/zhaquirks/insta/__init__.py b/zhaquirks/insta/__init__.py new file mode 100644 index 0000000000..0b02685671 --- /dev/null +++ b/zhaquirks/insta/__init__.py @@ -0,0 +1,3 @@ +"""Module for Insta quirks implementations.""" + +INSTA = "Insta GmbH" diff --git a/zhaquirks/insta/nexentro_pushbutton_interface.py b/zhaquirks/insta/nexentro_pushbutton_interface.py new file mode 100644 index 0000000000..0c1badfaca --- /dev/null +++ b/zhaquirks/insta/nexentro_pushbutton_interface.py @@ -0,0 +1,206 @@ +"""Device handler for Insta NEXENTRO Pushbutton Interface.""" +from zigpy.profiles import zha +from zigpy.quirks import CustomDevice +from zigpy.zcl.clusters.closures import WindowCovering +from zigpy.zcl.clusters.general import ( + Basic, + GreenPowerProxy, + Groups, + Identify, + LevelControl, + OnOff, + Ota, + Scenes, +) +from zigpy.zcl.clusters.lighting import Color + +from zhaquirks import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + MODELS_INFO, + OUTPUT_CLUSTERS, + PROFILE_ID, +) +from zhaquirks.const import ( + ALT_SHORT_PRESS, + BUTTON, + CLOSE, + COMMAND, + COMMAND_MOVE, + COMMAND_MOVE_ON_OFF, + COMMAND_OFF, + COMMAND_ON, + COMMAND_STOP, + COMMAND_TOGGLE, + DIM_DOWN, + DIM_UP, + ENDPOINT_ID, + OPEN, + SHORT_PRESS, + STOP, + TURN_OFF, + TURN_ON, +) +from zhaquirks.insta import INSTA + +COMMAND_OPEN = "up_open" +COMMAND_CLOSE = "down_close" +COMMAND_STORE = "store" +COMMAND_RECALL = "recall" + + +class InstaNexentroPushbuttonInterface(CustomDevice): + """Insta NEXENTRO Pushbutton Interface device.""" + + signature = { + MODELS_INFO: [(INSTA, "NEXENTRO Pushbutton Interface")], + ENDPOINTS: { + # + 4: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.COLOR_DIMMER_SWITCH, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + Color.cluster_id, + ], + }, + # + 5: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.COLOR_DIMMER_SWITCH, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + Color.cluster_id, + ], + }, + # + 7: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_CONTROLLER, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Ota.cluster_id, + WindowCovering.cluster_id, + ], + }, + # + 242: { + PROFILE_ID: 0xA1E0, + DEVICE_TYPE: 0x0061, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + + replacement = { + ENDPOINTS: { + 4: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.COLOR_DIMMER_SWITCH, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + Color.cluster_id, + ], + }, + 5: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.COLOR_DIMMER_SWITCH, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + LevelControl.cluster_id, + Ota.cluster_id, + Color.cluster_id, + ], + }, + 7: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_CONTROLLER, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Ota.cluster_id, + WindowCovering.cluster_id, + ], + }, + 242: { + PROFILE_ID: 0xA1E0, + DEVICE_TYPE: 0x0061, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + } + } + + device_automation_triggers = { + (SHORT_PRESS, TURN_ON): {COMMAND: COMMAND_ON, ENDPOINT_ID: 4}, + (ALT_SHORT_PRESS, TURN_ON): {COMMAND: COMMAND_ON, ENDPOINT_ID: 5}, + (SHORT_PRESS, TURN_OFF): {COMMAND: COMMAND_OFF, ENDPOINT_ID: 4}, + (ALT_SHORT_PRESS, TURN_OFF): {COMMAND: COMMAND_OFF, ENDPOINT_ID: 5}, + (SHORT_PRESS, BUTTON): {COMMAND: COMMAND_TOGGLE, ENDPOINT_ID: 4}, + (ALT_SHORT_PRESS, BUTTON): {COMMAND: COMMAND_TOGGLE, ENDPOINT_ID: 5}, + (SHORT_PRESS, OPEN): {COMMAND: COMMAND_OPEN}, + (SHORT_PRESS, CLOSE): {COMMAND: COMMAND_CLOSE}, + (SHORT_PRESS, DIM_UP): {COMMAND: COMMAND_MOVE_ON_OFF, ENDPOINT_ID: 4}, + (ALT_SHORT_PRESS, DIM_UP): {COMMAND: COMMAND_MOVE_ON_OFF, ENDPOINT_ID: 5}, + (SHORT_PRESS, DIM_DOWN): {COMMAND: COMMAND_MOVE, ENDPOINT_ID: 4}, + (ALT_SHORT_PRESS, DIM_DOWN): {COMMAND: COMMAND_MOVE, ENDPOINT_ID: 5}, + (SHORT_PRESS, STOP): {COMMAND: COMMAND_STOP, ENDPOINT_ID: 4}, + (ALT_SHORT_PRESS, STOP): {COMMAND: COMMAND_STOP, ENDPOINT_ID: 5}, + } diff --git a/zhaquirks/sinope/__init__.py b/zhaquirks/sinope/__init__.py index 14ed5a1da1..7e0d63dc3f 100644 --- a/zhaquirks/sinope/__init__.py +++ b/zhaquirks/sinope/__init__.py @@ -1,2 +1,60 @@ """Module for Sinope quirks implementations.""" +from zhaquirks.const import ( + ARGS, + ATTRIBUTE_ID, + ATTRIBUTE_NAME, + CLUSTER_ID, + COMMAND, + COMMAND_BUTTON_DOUBLE, + COMMAND_BUTTON_HOLD, + COMMAND_BUTTON_SINGLE, + DOUBLE_PRESS, + ENDPOINT_ID, + LONG_PRESS, + SHORT_PRESS, + TURN_OFF, + TURN_ON, + VALUE, +) + SINOPE = "Sinope Technologies" +ATTRIBUTE_ACTION = "actionReport" + +LIGHT_DEVICE_TRIGGERS = { + (SHORT_PRESS, TURN_ON): { + ENDPOINT_ID: 1, + CLUSTER_ID: 65281, + COMMAND: COMMAND_BUTTON_SINGLE, + ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 2}, + }, + (SHORT_PRESS, TURN_OFF): { + ENDPOINT_ID: 1, + CLUSTER_ID: 65281, + COMMAND: COMMAND_BUTTON_SINGLE, + ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 18}, + }, + (DOUBLE_PRESS, TURN_ON): { + ENDPOINT_ID: 1, + CLUSTER_ID: 65281, + COMMAND: COMMAND_BUTTON_DOUBLE, + ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 4}, + }, + (DOUBLE_PRESS, TURN_OFF): { + ENDPOINT_ID: 1, + CLUSTER_ID: 65281, + COMMAND: COMMAND_BUTTON_DOUBLE, + ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 20}, + }, + (LONG_PRESS, TURN_ON): { + ENDPOINT_ID: 1, + CLUSTER_ID: 65281, + COMMAND: COMMAND_BUTTON_HOLD, + ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 3}, + }, + (LONG_PRESS, TURN_OFF): { + ENDPOINT_ID: 1, + CLUSTER_ID: 65281, + COMMAND: COMMAND_BUTTON_HOLD, + ARGS: {ATTRIBUTE_ID: 84, ATTRIBUTE_NAME: ATTRIBUTE_ACTION, VALUE: 19}, + }, +} diff --git a/zhaquirks/sinope/light.py b/zhaquirks/sinope/light.py index 161c5c661d..8a124384a7 100644 --- a/zhaquirks/sinope/light.py +++ b/zhaquirks/sinope/light.py @@ -21,6 +21,7 @@ from zigpy.zcl.clusters.homeautomation import Diagnostic, ElectricalMeasurement from zigpy.zcl.clusters.smartenergy import Metering +from zhaquirks import EventableCluster from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, @@ -29,7 +30,7 @@ OUTPUT_CLUSTERS, PROFILE_ID, ) -from zhaquirks.sinope import SINOPE +from zhaquirks.sinope import LIGHT_DEVICE_TRIGGERS, SINOPE SINOPE_MANUFACTURER_CLUSTER_ID = 0xFF01 @@ -37,21 +38,47 @@ class SinopeTechnologiesManufacturerCluster(CustomCluster): """SinopeTechnologiesManufacturerCluster manufacturer cluster.""" + class KeypadLock(t.enum8): + """keypad_lockout values.""" + + Unlocked = 0x00 + Locked = 0x01 + + class Action(t.enum8): + """action_report values.""" + + Single_on = 0x01 + Single_release_on = 0x02 + Long_on = 0x03 + Double_on = 0x04 + Single_off = 0x11 + Single_release_off = 0x12 + Long_off = 0x13 + Double_off = 0x14 + cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID name = "Sinopé Technologies Manufacturer specific" ep_attribute = "sinope_manufacturer_specific" attributes = { - 0x0002: ("KeypadLock", t.enum8, True), - 0x0050: ("onLedColor", t.uint24_t, True), - 0x0051: ("offLedColor", t.uint24_t, True), - 0x0052: ("onLedIntensity", t.uint8_t, True), - 0x0053: ("offLedIntensity", t.uint8_t, True), - 0x0055: ("minIntensity", t.uint16_t, True), - 0x00A0: ("Timer", t.uint32_t, True), - 0x0119: ("ConnectedLoad", t.uint16_t, True), + 0x0002: ("keypad_lockout", KeypadLock, True), + 0x0004: ("firmware_version", t.CharacterString, True), + 0x0050: ("on_led_color", t.uint24_t, True), + 0x0051: ("off_led_color", t.uint24_t, True), + 0x0052: ("on_led_intensity", t.uint8_t, True), + 0x0053: ("off_led_intensity", t.uint8_t, True), + 0x0054: ("action_report", Action, True), + 0x0055: ("min_intensity", t.uint16_t, True), + 0x00A0: ("timer", t.uint32_t, True), + 0x0119: ("connected_load", t.uint16_t, True), + 0x0200: ("unknown", t.bitmap32, True), + 0xFFFD: ("cluster_revision", t.uint16_t, True), } +class LightManufacturerCluster(EventableCluster, SinopeTechnologiesManufacturerCluster): + """LightManufacturerCluster: fire events corresponding to press type.""" + + class SinopeTechnologieslight(CustomDevice): """SinopeTechnologiesLight custom device.""" @@ -98,7 +125,7 @@ class SinopeTechnologieslight(CustomDevice): OnOff.cluster_id, Metering.cluster_id, Diagnostic.cluster_id, - SinopeTechnologiesManufacturerCluster, + LightManufacturerCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -109,6 +136,8 @@ class SinopeTechnologieslight(CustomDevice): } } + device_automation_triggers = LIGHT_DEVICE_TRIGGERS + class SinopeDM2500ZB(SinopeTechnologieslight): """DM2500ZB Dimmer.""" @@ -158,7 +187,7 @@ class SinopeDM2500ZB(SinopeTechnologieslight): LevelControl.cluster_id, Metering.cluster_id, Diagnostic.cluster_id, - SinopeTechnologiesManufacturerCluster, + LightManufacturerCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -169,6 +198,8 @@ class SinopeDM2500ZB(SinopeTechnologieslight): } } + device_automation_triggers = LIGHT_DEVICE_TRIGGERS + class SinopeDM2550ZB(SinopeTechnologieslight): """DM2550ZB Dimmer.""" @@ -221,7 +252,7 @@ class SinopeDM2550ZB(SinopeTechnologieslight): Metering.cluster_id, ElectricalMeasurement.cluster_id, Diagnostic.cluster_id, - SinopeTechnologiesManufacturerCluster, + LightManufacturerCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -232,3 +263,5 @@ class SinopeDM2550ZB(SinopeTechnologieslight): } } } + + device_automation_triggers = LIGHT_DEVICE_TRIGGERS diff --git a/zhaquirks/sinope/sensor.py b/zhaquirks/sinope/sensor.py index bec5fe43ca..2048d9aecc 100644 --- a/zhaquirks/sinope/sensor.py +++ b/zhaquirks/sinope/sensor.py @@ -30,13 +30,31 @@ SINOPE_MANUFACTURER_CLUSTER_ID = 0xFF01 +class SinopeManufacturerCluster(CustomCluster): + """SinopeManufacturerCluster manufacturer cluster.""" + + cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID + name = "Sinopé Manufacturer specific" + ep_attribute = "sinope_manufacturer_specific" + attributes = { + 0x0004: ("firmware_version", t.CharacterString, True), + 0xFFFD: ("cluster_revision", t.uint16_t, True), + } + + class SinopeTechnologiesIasZoneCluster(CustomCluster, IasZone): """SinopeTechnologiesIasZoneCluster custom cluster.""" + class ZoneStatus(t.enum8): + """zone_status values.""" + + Dry = 0x00 + Leak = 0x01 + attributes = IasZone.attributes.copy() attributes.update( { - 0x0030: ("zoneStatus", t.enum8, True), + 0x0030: ("zone_status", ZoneStatus, True), } ) @@ -85,7 +103,7 @@ class SinopeTechnologiesSensor(CustomDevice): TemperatureMeasurement.cluster_id, SinopeTechnologiesIasZoneCluster, Diagnostic.cluster_id, - SINOPE_MANUFACTURER_CLUSTER_ID, + SinopeManufacturerCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -142,7 +160,7 @@ class SinopeTechnologiesSensor2(CustomDevice): TemperatureMeasurement.cluster_id, SinopeTechnologiesIasZoneCluster, Diagnostic.cluster_id, - SINOPE_MANUFACTURER_CLUSTER_ID, + SinopeManufacturerCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, diff --git a/zhaquirks/sinope/switch.py b/zhaquirks/sinope/switch.py index 55a1b570e7..638e790c7c 100644 --- a/zhaquirks/sinope/switch.py +++ b/zhaquirks/sinope/switch.py @@ -42,18 +42,41 @@ class SinopeManufacturerCluster(CustomCluster): """SinopeManufacturerCluster manufacturer cluster.""" + class KeypadLock(t.enum8): + """keypad_lockout values.""" + + Unlocked = 0x00 + Locked = 0x01 + + class TankSize(t.enum8): + """tank_size values.""" + + Gal_40 = 0x01 + Gal_50 = 0x02 + Gal_60 = 0x03 + Gal_80 = 0x04 + + class ColdStatus(t.enum8): + """cold_load_pickup_status values.""" + + Active = 0x00 + Off = 0x01 + cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID name = "Sinopé Manufacturer specific" ep_attribute = "sinope_manufacturer_specific" attributes = { - 0x0002: ("KeyboardLock", t.enum8, True), - 0x0060: ("ConnectedLoad", t.uint16_t, True), - 0x0070: ("CurrentLoad", t.bitmap8, True), - 0x0076: ("drConfigWaterTempMin", t.uint8_t, True), - 0x0077: ("drConfigWaterTempTime", t.uint8_t, True), - 0x0078: ("drWTTimeOn", t.uint16_t, True), - 0x00A0: ("Timer", t.uint32_t, True), - 0x0283: ("ColdLoadPickupStatus", t.uint8_t, True), + 0x0002: ("keypad_lockout", KeypadLock, True), + 0x0004: ("firmware_version", t.CharacterString, True), + 0x0013: ("tank_size", TankSize, True), + 0x0060: ("connected_load", t.uint16_t, True), + 0x0070: ("current_load", t.bitmap8, True), + 0x0076: ("dr_config_water_temp_min", t.uint8_t, True), + 0x0077: ("dr_config_water_temp_time", t.uint8_t, True), + 0x0078: ("dr_wt_time_on", t.uint16_t, True), + 0x00A0: ("timer", t.uint32_t, True), + 0x0283: ("cold_load_pickup_status", ColdStatus, True), + 0xFFFD: ("cluster_revision", t.uint16_t, True), } @@ -102,7 +125,7 @@ class SinopeTechnologiesSwitch(CustomDevice): OnOff.cluster_id, CustomMeteringCluster, ElectricalMeasurement.cluster_id, - SINOPE_MANUFACTURER_CLUSTER_ID, + SinopeManufacturerCluster, ], OUTPUT_CLUSTERS: [Ota.cluster_id], } @@ -220,7 +243,7 @@ class SinopeTechnologiesValve(CustomDevice): OnOff.cluster_id, LevelControl.cluster_id, Diagnostic.cluster_id, - SINOPE_MANUFACTURER_CLUSTER_ID, + SinopeManufacturerCluster, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, @@ -432,7 +455,7 @@ class SinopeTechnologiesNewSwitch(CustomDevice): CustomMeteringCluster, ElectricalMeasurement.cluster_id, LightLink.cluster_id, - SINOPE_MANUFACTURER_CLUSTER_ID, + SinopeManufacturerCluster, ], OUTPUT_CLUSTERS: [ Ota.cluster_id, diff --git a/zhaquirks/sinope/thermostat.py b/zhaquirks/sinope/thermostat.py index 6ab07c11b2..2a8fe5b9ae 100644 --- a/zhaquirks/sinope/thermostat.py +++ b/zhaquirks/sinope/thermostat.py @@ -37,40 +37,106 @@ class SinopeTechnologiesManufacturerCluster(CustomCluster): """SinopeTechnologiesManufacturerCluster manufacturer cluster.""" + class KeypadLock(t.enum8): + """keypad_lockout values.""" + + Unlocked = 0x00 + Locked = 0x01 + + class Display(t.enum8): + """config_2nd_display values.""" + + Auto = 0x00 + Outside_temperature = 0x01 + Setpoint = 0x02 + + class FloorMode(t.enum8): + """air_floor_mode values.""" + + Air_by_floor = 0x01 + Floor = 0x02 + + class AuxMode(t.enum8): + """aux_output_mode values.""" + + Off = 0x00 + On = 0x01 + + class SensorType(t.enum8): + """temp_sensor_type values.""" + + Sensor_10k = 0x00 + Sensor_12k = 0x01 + + class TimeFormat(t.enum8): + """time_format values.""" + + Format_24h = 0x00 + Format_12h = 0x01 + + class GfciStatus(t.enum8): + """gfci_status values.""" + + Ok = 0x00 + Error = 0x01 + cluster_id = SINOPE_MANUFACTURER_CLUSTER_ID name = "Sinopé Technologies Manufacturer specific" ep_attribute = "sinope_manufacturer_specific" attributes = { + 0x0002: ("keypad_lockout", KeypadLock, True), + 0x0004: ("firmware_version", t.CharacterString, True), 0x0010: ("outdoor_temp", t.int16s, True), 0x0011: ("outdoor_temp_timeout", t.uint16_t, True), + 0x0012: ("config_2nd_display", Display, True), 0x0020: ("secs_since_2k", t.uint32_t, True), - 0x0070: ("currentLoad", t.bitmap8, True), - 0x0105: ("airFloorMode", t.enum8, True), - 0x0106: ("auxOutputMode", t.enum8, True), - 0x0108: ("airMaxLimit", t.int16s, True), - 0x0109: ("floorMinSetpoint", t.int16s, True), - 0x010A: ("floorMaxSetpoint", t.int16s, True), - 0x010B: ("tempSensorType", t.enum8, True), - 0x010C: ("floorLimitStatus", t.uint8_t, True), - 0x0114: ("timeFormat", t.enum8, True), - 0x0115: ("gfciStatus", t.enum8, True), - 0x0118: ("auxConnectedLoad", t.uint16_t, True), - 0x0119: ("ConnectedLoad", t.uint16_t, True), - 0x0128: ("pumpProtection", t.uint8_t, True), - 0x012D: ("reportLocalTemperature", t.int16s, True), + 0x0070: ("current_load", t.bitmap8, True), + 0x0071: ("eco_mode", t.int8s, True), + 0x0072: ("eco_mode_1", t.uint8_t, True), + 0x0073: ("eco_mode_2", t.uint8_t, True), + 0x0104: ("setpoint", t.int16s, True), + 0x0105: ("air_floor_mode", FloorMode, True), + 0x0106: ("aux_output_mode", AuxMode, True), + 0x0107: ("floor_temperature", t.int16s, True), + 0x0108: ("air_max_limit", t.int16s, True), + 0x0109: ("floor_min_setpoint", t.int16s, True), + 0x010A: ("floor_max_setpoint", t.int16s, True), + 0x010B: ("temp_sensor_type", SensorType, True), + 0x010C: ("floor_limit_status", t.uint8_t, True), + 0x010D: ("room_temperature", t.int16s, True), + 0x0114: ("time_format", TimeFormat, True), + 0x0115: ("gfci_status", GfciStatus, True), + 0x0118: ("aux_connected_load", t.uint16_t, True), + 0x0119: ("connected_load", t.uint16_t, True), + 0x0128: ("pump_protection", t.uint8_t, True), + 0x012D: ("report_local_temperature", t.int16s, True), + 0xFFFD: ("cluster_revision", t.uint16_t, True), } class SinopeTechnologiesThermostatCluster(CustomCluster, Thermostat): """SinopeTechnologiesThermostatCluster custom cluster.""" + class Occupancy(t.enum8): + """set_occupancy values.""" + + Away = 0x01 + Home = 0x02 + + class Backlight(t.enum8): + """backlight_auto_dim_param values.""" + + On_demand = 0x00 + Always_on = 0x01 + attributes = Thermostat.attributes.copy() attributes.update( { - 0x0400: ("set_occupancy", t.enum8, True), - 0x0401: ("mainCycleOutput", t.uint16_t, True), - 0x0402: ("backlightAutoDimParam", t.enum8, True), - 0x0404: ("auxCycleOutput", t.uint16_t, True), + 0x0400: ("set_occupancy", Occupancy, True), + 0x0401: ("main_cycle_output", t.uint16_t, True), + 0x0402: ("backlight_auto_dim_param", Backlight, True), + 0x0404: ("aux_cycle_output", t.uint16_t, True), + 0xFFFD: ("cluster_revision", t.uint16_t, True), } ) @@ -120,20 +186,23 @@ class SinopeTechnologiesThermostat(CustomDevice): ENDPOINTS: { 1: { INPUT_CLUSTERS: [ - Basic, - Identify, - Groups, - Scenes, - UserInterface, - TemperatureMeasurement, - ElectricalMeasurement, - Diagnostic, + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + UserInterface.cluster_id, + TemperatureMeasurement.cluster_id, + ElectricalMeasurement.cluster_id, + Diagnostic.cluster_id, SinopeTechnologiesThermostatCluster, SinopeTechnologiesManufacturerCluster, ], - OUTPUT_CLUSTERS: [Ota, SINOPE_MANUFACTURER_CLUSTER_ID], + OUTPUT_CLUSTERS: [ + Ota.cluster_id, + SINOPE_MANUFACTURER_CLUSTER_ID, + ], }, - 196: {INPUT_CLUSTERS: [PowerConfiguration]}, + 196: {INPUT_CLUSTERS: [PowerConfiguration.cluster_id]}, } } @@ -175,18 +244,22 @@ class SinopeTH1400ZB(SinopeTechnologiesThermostat): ENDPOINTS: { 1: { INPUT_CLUSTERS: [ - Basic, - Identify, - Groups, - Scenes, - UserInterface, - TemperatureMeasurement, - Metering, - Diagnostic, + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + UserInterface.cluster_id, + TemperatureMeasurement.cluster_id, + Metering.cluster_id, + Diagnostic.cluster_id, SinopeTechnologiesThermostatCluster, SinopeTechnologiesManufacturerCluster, ], - OUTPUT_CLUSTERS: [Time, Ota, SINOPE_MANUFACTURER_CLUSTER_ID], + OUTPUT_CLUSTERS: [ + Time.cluster_id, + Ota.cluster_id, + SINOPE_MANUFACTURER_CLUSTER_ID, + ], } } } diff --git a/zhaquirks/terncy/__init__.py b/zhaquirks/terncy/__init__.py index 5a3646c215..5b305ea368 100644 --- a/zhaquirks/terncy/__init__.py +++ b/zhaquirks/terncy/__init__.py @@ -29,7 +29,7 @@ TRIPLE_PRESS, VALUE, ZHA_SEND_EVENT, - ZONE_STATE, + ZONE_STATUS_CHANGE_COMMAND, ) CLICK_TYPES = {1: "single", 2: "double", 3: "triple", 4: "quadruple", 5: "quintuple"} @@ -91,7 +91,9 @@ class MotionCluster(LocalDataCluster, _Motion): def motion_event(self): """Motion event.""" - super().listener_event(CLUSTER_COMMAND, 254, ZONE_STATE, [ON, 0, 0, 0]) + super().listener_event( + CLUSTER_COMMAND, 254, ZONE_STATUS_CHANGE_COMMAND, [ON, 0, 0, 0] + ) if self._timer_handle: self._timer_handle.cancel() diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py index a24f4cb31c..a6d90e079d 100644 --- a/zhaquirks/tuya/__init__.py +++ b/zhaquirks/tuya/__init__.py @@ -527,7 +527,47 @@ async def write_attributes(self, attributes, manufacturer=None): return [[foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)]] -class TuyaOnOff(CustomCluster, OnOff): +class TuyaEnchantableCluster(CustomCluster): + """Tuya cluster that casts a magic spell if `TUYA_SPELL` is set. + + Preferably, make the device inherit from `EnchantedDevice` and use a subclass of this class in the replacement. + + This will only work for clusters that ZHA calls bind() on. + At the moment, ZHA does NOT do this for: + - Basic cluster + - Identify cluster + - Groups cluster + - OTA cluster + - GreenPowerProxy cluster + - LightLink cluster + - non-registered manufacturer specific clusters + - clusters which would be bound, but that changed their ep_attribute + + Make sure to add a subclass of TuyaEnchantableCluster to the quirk replacement. Tests will fail if this is not done. + Classes like TuyaOnOff, TuyaZBOnOffAttributeCluster, TuyaSmartRemoteOnOffCluster already inherit from this class. + """ + + async def bind(self): + """Bind cluster and start casting the spell if necessary.""" + # check if the device needs to have the spell cast + # and since the cluster can be used on multiple endpoints, check that it's endpoint 1 + if ( + getattr(self.endpoint.device, "TUYA_SPELL", False) + and self.endpoint.endpoint_id == 1 + ): + await self.spell() + return await super().bind() + + async def spell(self): + """Cast spell, so the Tuya device works correctly.""" + self.debug("Executing spell on Tuya device %s", self.endpoint.device.ieee) + attr_to_read = [4, 0, 1, 5, 7, 0xFFFE] + basic_cluster = self.endpoint.device.endpoints[1].in_clusters[0] + await basic_cluster.read_attributes(attr_to_read) + self.debug("Executed spell on Tuya device %s", self.endpoint.device.ieee) + + +class TuyaOnOff(TuyaEnchantableCluster, OnOff): """Tuya On/Off cluster for On/Off device.""" def __init__(self, *args, **kwargs): @@ -831,6 +871,31 @@ def update_attribute(self, attr_name: str, value: Any) -> None: return self._update_attribute(attr.id, value) +class _TuyaNoBindPowerConfigurationCluster(CustomCluster, PowerConfiguration): + """PowerConfiguration cluster that prevents setting up binding/attribute reports in order to stop battery drain. + + Note: Use the `TuyaNoBindPowerConfigurationCluster` class instead of this one. + """ + + async def bind(self): + """Prevent bind.""" + return (foundation.Status.SUCCESS,) + + async def _configure_reporting(self, *args, **kwargs): # pylint: disable=W0221 + """Prevent remote configure reporting.""" + return (foundation.ConfigureReportingResponse.deserialize(b"\x00")[0],) + + +# these classes are needed, so the execution order of bind() is still correct +class TuyaNoBindPowerConfigurationCluster( + TuyaEnchantableCluster, _TuyaNoBindPowerConfigurationCluster +): + """PowerConfiguration cluster that prevents setting up binding/attribute reports in order to stop battery drain. + + This class is also enchantable, so it will cast the Tuya spell if the device inherits from `EnchantedDevice`. + """ + + class TuyaPowerConfigurationCluster(PowerConfiguration, TuyaLocalCluster): """PowerConfiguration cluster for battery-operated thermostats.""" @@ -921,7 +986,7 @@ class PowerOnState(t.enum8): LastState = 0x02 -class TuyaZBOnOffAttributeCluster(CustomCluster, OnOff): +class TuyaZBOnOffAttributeCluster(TuyaEnchantableCluster, OnOff): """Tuya Zigbee On Off cluster with extra attributes.""" attributes = OnOff.attributes.copy() diff --git a/zhaquirks/tuya/air/__init__.py b/zhaquirks/tuya/air/__init__.py index 4543bda9d0..debbe4b94f 100644 --- a/zhaquirks/tuya/air/__init__.py +++ b/zhaquirks/tuya/air/__init__.py @@ -1,6 +1,6 @@ """Tuya Air sensors.""" -from typing import Dict +from typing import Any, Dict import zigpy.types as t from zigpy.zcl.clusters.measurement import ( @@ -31,9 +31,37 @@ class TuyaAirQualityVOC(TuyaLocalCluster): client_commands = {} +class CustomTemperature(t.Struct): + """Custom temperature wrapper.""" + + field_1: t.int16s_be + temperature: t.int16s_be + + @classmethod + def from_value(cls, value): + """Convert from a raw value to a Struct data.""" + return cls.deserialize(value.serialize())[0] + + class TuyaAirQualityTemperature(TemperatureMeasurement, TuyaLocalCluster): """Tuya temperature measurement.""" + attributes = TemperatureMeasurement.attributes.copy() + attributes.update( + { + # ramdom attribute IDs + 0xEF12: ("custom_temperature", CustomTemperature, False), + } + ) + + def update_attribute(self, attr_name: str, value: Any) -> None: + """Calculate the current temperature.""" + + super().update_attribute(attr_name, value) + + if attr_name == "custom_temperature": + super().update_attribute("measured_value", value.temperature * 10) + class TuyaAirQualityHumidity(RelativeHumidity, TuyaLocalCluster): """Tuya relative humidity measurement.""" @@ -57,7 +85,9 @@ class TuyaCO2ManufCluster(TuyaNewManufCluster): lambda x: x * 1e-6, ), 18: DPToAttributeMapping( - TuyaAirQualityTemperature.ep_attribute, "measured_value", lambda x: x * 10 + TuyaAirQualityTemperature.ep_attribute, + "custom_temperature", + lambda x: CustomTemperature.from_value(x), ), 19: DPToAttributeMapping( TuyaAirQualityHumidity.ep_attribute, "measured_value", lambda x: x * 10 diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py index 3574eb333b..4f91503bab 100644 --- a/zhaquirks/tuya/mcu/__init__.py +++ b/zhaquirks/tuya/mcu/__init__.py @@ -1,5 +1,4 @@ """Tuya MCU comunications.""" -import asyncio import dataclasses import datetime from typing import Any, Callable, Dict, Optional, Tuple, Union @@ -19,6 +18,7 @@ PowerOnState, TuyaCommand, TuyaDatapointData, + TuyaEnchantableCluster, TuyaLocalCluster, TuyaNewManufCluster, TuyaTimePayload, @@ -355,7 +355,7 @@ def handle_mcu_connection_status( return foundation.Status.SUCCESS -class TuyaOnOff(OnOff, TuyaLocalCluster): +class TuyaOnOff(TuyaEnchantableCluster, OnOff, TuyaLocalCluster): """Tuya MCU OnOff cluster.""" async def command( @@ -664,15 +664,10 @@ class TuyaLevelControlManufCluster(TuyaMCUCluster): class EnchantedDevice(CustomDevice): - """Class for enchanted Tuya devices which needs to be unlocked by casting a 'spell'.""" + """Class for Tuya devices which need to be unlocked by casting a 'spell'. This happens during binding. - def __init__(self, *args, **kwargs): - """Initialize with task.""" - super().__init__(*args, **kwargs) - self._init_device_task = asyncio.create_task(self.spell()) + To make sure the spell is cast, the device needs to implement a subclass of `TuyaEnchantableCluster`. + For more information, see the documentation of `TuyaEnchantableCluster`. + """ - async def spell(self) -> None: - """Initialize device so that all endpoints become available.""" - attr_to_read = [4, 0, 1, 5, 7, 0xFFFE] - basic_cluster = self.endpoints[1].in_clusters[0] - await basic_cluster.read_attributes(attr_to_read) + TUYA_SPELL = True diff --git a/zhaquirks/tuya/sm0202_motion.py b/zhaquirks/tuya/sm0202_motion.py new file mode 100644 index 0000000000..044a329554 --- /dev/null +++ b/zhaquirks/tuya/sm0202_motion.py @@ -0,0 +1,64 @@ +"""Device handler for Tuya LH-961ZB motion sensor.""" +from zigpy.profiles import zha +from zigpy.quirks import CustomDevice +from zigpy.zcl.clusters.general import Basic, Identify, Ota, PowerConfiguration +from zigpy.zcl.clusters.security import IasZone + +from zhaquirks import MotionWithReset +from zhaquirks.const import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + MODELS_INFO, + OUTPUT_CLUSTERS, + PROFILE_ID, +) + + +class MotionCluster(MotionWithReset): + """Motion cluster.""" + + reset_s: int = 60 + + +class SM0202Motion(CustomDevice): + """Quirk for LH-961ZB motion sensor.""" + + signature = { + # "endpoint": 1 + # "profile_id": 260, + # "device_type": "0x0402", + # "in_clusters": ["0x0000","0x0001","0x0003", "0x0500", "0xeeff"], + # "out_clusters": ["0x0019"] + MODELS_INFO: [("_TYZB01_z2umiwvq", "SM0202")], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + IasZone.cluster_id, + 0xEEFF, # Unknown + ], + OUTPUT_CLUSTERS: [Ota.cluster_id], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + MotionCluster, + ], + OUTPUT_CLUSTERS: [Ota.cluster_id], + }, + }, + } diff --git a/zhaquirks/tuya/ts000x.py b/zhaquirks/tuya/ts000x.py index f3a8546a04..6964680235 100644 --- a/zhaquirks/tuya/ts000x.py +++ b/zhaquirks/tuya/ts000x.py @@ -1,7 +1,6 @@ """tuya TS000X Switches.""" from zigpy.profiles import zha -from zigpy.quirks import CustomDevice from zigpy.zcl.clusters.general import ( Basic, GreenPowerProxy, @@ -31,7 +30,7 @@ from zhaquirks.tuya.mcu import EnchantedDevice -class Switch_1G_GPP(EnchantedDevice, CustomDevice): +class Switch_1G_GPP(EnchantedDevice): """Tuya 1 gang switch module with restore power state support.""" signature = { @@ -87,7 +86,7 @@ class Switch_1G_GPP(EnchantedDevice, CustomDevice): } -class Switch_1G_Metering(EnchantedDevice, CustomDevice): +class Switch_1G_Metering(EnchantedDevice): """Tuya 1 gang switch with metering support.""" signature = { @@ -146,7 +145,7 @@ class Switch_1G_Metering(EnchantedDevice, CustomDevice): } -class Switch_2G_GPP(EnchantedDevice, CustomDevice): +class Switch_2G_GPP(EnchantedDevice): """Tuya 2 gang switch module with restore power state support.""" signature = { @@ -234,7 +233,7 @@ class Switch_2G_GPP(EnchantedDevice, CustomDevice): } -class Switch_2G_Metering(EnchantedDevice, CustomDevice): +class Switch_2G_Metering(EnchantedDevice): """Tuya 2 gang switch with metering support.""" signature = { @@ -322,7 +321,99 @@ class Switch_2G_Metering(EnchantedDevice, CustomDevice): } -class Switch_3G_GPP(EnchantedDevice, CustomDevice): +class Switch_2G_Var03(EnchantedDevice): + """Tuya 2 gang (variation 03).""" + + signature = { + MODEL: "TS0002", + ENDPOINTS: { + # + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + TuyaZBE000Cluster.cluster_id, + TuyaZBExternalSwitchTypeCluster.cluster_id, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + # + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT, + INPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + OnOff.cluster_id, + TuyaZBE000Cluster.cluster_id, + TuyaZBExternalSwitchTypeCluster.cluster_id, + ], + OUTPUT_CLUSTERS: [], + }, + # + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaZBOnOffAttributeCluster, + TuyaZBE000Cluster, + TuyaZBExternalSwitchTypeCluster, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT, + INPUT_CLUSTERS: [ + Identify.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaZBOnOffAttributeCluster, + TuyaZBE000Cluster, + TuyaZBExternalSwitchTypeCluster, + ], + OUTPUT_CLUSTERS: [], + }, + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + + +class Switch_3G_GPP(EnchantedDevice): """Tuya 3 gang switch module with restore power state support.""" signature = { @@ -436,7 +527,7 @@ class Switch_3G_GPP(EnchantedDevice, CustomDevice): } -class Switch_3G_Metering(EnchantedDevice, CustomDevice): +class Switch_3G_Metering(EnchantedDevice): """Tuya 3 gang switch with metering support.""" signature = { @@ -547,7 +638,7 @@ class Switch_3G_Metering(EnchantedDevice, CustomDevice): } -class Switch_4G_GPP(EnchantedDevice, CustomDevice): +class Switch_4G_GPP(EnchantedDevice): """Tuya 4 gang switch module with restore power state support.""" signature = { @@ -687,7 +778,7 @@ class Switch_4G_GPP(EnchantedDevice, CustomDevice): } -class Switch_4G_Metering(EnchantedDevice, CustomDevice): +class Switch_4G_Metering(EnchantedDevice): """Tuya 4 gang switch with metering support.""" signature = { diff --git a/zhaquirks/tuya/ts0041.py b/zhaquirks/tuya/ts0041.py index 37f59e1f55..c362651fd4 100644 --- a/zhaquirks/tuya/ts0041.py +++ b/zhaquirks/tuya/ts0041.py @@ -18,7 +18,11 @@ PROFILE_ID, SHORT_PRESS, ) -from zhaquirks.tuya import TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster +from zhaquirks.tuya import ( + TuyaNoBindPowerConfigurationCluster, + TuyaSmartRemoteOnOffCluster, + TuyaZBE000Cluster, +) class TuyaSmartRemote0041TO(CustomDevice): @@ -47,7 +51,7 @@ class TuyaSmartRemote0041TO(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [Ota.cluster_id, Time.cluster_id], @@ -89,7 +93,7 @@ class TuyaSmartRemote0041TI(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, Time.cluster_id, ], @@ -163,7 +167,7 @@ class TuyaSmartRemote0041TOPlusA(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaZBE000Cluster, ], OUTPUT_CLUSTERS: [ diff --git a/zhaquirks/tuya/ts0042.py b/zhaquirks/tuya/ts0042.py index a92ffbdbfa..3ea0f8ca11 100644 --- a/zhaquirks/tuya/ts0042.py +++ b/zhaquirks/tuya/ts0042.py @@ -19,7 +19,11 @@ PROFILE_ID, SHORT_PRESS, ) -from zhaquirks.tuya import TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster +from zhaquirks.tuya import ( + TuyaNoBindPowerConfigurationCluster, + TuyaSmartRemoteOnOffCluster, + TuyaZBE000Cluster, +) class TuyaSmartRemote0042TI(CustomDevice): @@ -59,7 +63,7 @@ class TuyaSmartRemote0042TI(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, Time.cluster_id, ], @@ -69,7 +73,7 @@ class TuyaSmartRemote0042TI(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -123,7 +127,7 @@ class TuyaSmartRemote0042TO(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], @@ -132,7 +136,7 @@ class TuyaSmartRemote0042TO(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -207,7 +211,7 @@ class TuyaSmartRemote0042TOPlusA(CustomDevice): DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaZBE000Cluster, ], OUTPUT_CLUSTERS: [ diff --git a/zhaquirks/tuya/ts0043.py b/zhaquirks/tuya/ts0043.py index 0c6e1d1bcc..aae076052b 100644 --- a/zhaquirks/tuya/ts0043.py +++ b/zhaquirks/tuya/ts0043.py @@ -20,7 +20,11 @@ PROFILE_ID, SHORT_PRESS, ) -from zhaquirks.tuya import TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster +from zhaquirks.tuya import ( + TuyaNoBindPowerConfigurationCluster, + TuyaSmartRemoteOnOffCluster, + TuyaZBE000Cluster, +) class TuyaSmartRemote0043TI(CustomDevice): @@ -70,7 +74,7 @@ class TuyaSmartRemote0043TI(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, Time.cluster_id, ], @@ -80,7 +84,7 @@ class TuyaSmartRemote0043TI(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -89,7 +93,7 @@ class TuyaSmartRemote0043TI(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -156,7 +160,7 @@ class TuyaSmartRemote0043TO(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], @@ -165,7 +169,7 @@ class TuyaSmartRemote0043TO(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -174,7 +178,7 @@ class TuyaSmartRemote0043TO(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -252,7 +256,7 @@ class TuyaSmartRemote0043TOPlusA(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster, ], @@ -262,7 +266,7 @@ class TuyaSmartRemote0043TOPlusA(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -271,7 +275,7 @@ class TuyaSmartRemote0043TOPlusA(CustomDevice): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -340,7 +344,7 @@ class TuyaSmartRemote0043TOPlusB(CustomDevice): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, ], OUTPUT_CLUSTERS: [ Time.cluster_id, diff --git a/zhaquirks/tuya/ts0044.py b/zhaquirks/tuya/ts0044.py index fe86e765d6..cb14136e85 100644 --- a/zhaquirks/tuya/ts0044.py +++ b/zhaquirks/tuya/ts0044.py @@ -21,7 +21,11 @@ PROFILE_ID, SHORT_PRESS, ) -from zhaquirks.tuya import TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster +from zhaquirks.tuya import ( + TuyaNoBindPowerConfigurationCluster, + TuyaSmartRemoteOnOffCluster, + TuyaZBE000Cluster, +) class Tuya4ButtonTriggers: @@ -100,7 +104,7 @@ class TuyaSmartRemote0044TI(CustomDevice, Tuya4ButtonTriggers): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, Time.cluster_id, ], @@ -110,7 +114,7 @@ class TuyaSmartRemote0044TI(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -119,7 +123,7 @@ class TuyaSmartRemote0044TI(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -128,7 +132,7 @@ class TuyaSmartRemote0044TI(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -193,7 +197,7 @@ class TuyaSmartRemote0044TO(CustomDevice, Tuya4ButtonTriggers): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], @@ -202,7 +206,7 @@ class TuyaSmartRemote0044TO(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -211,7 +215,7 @@ class TuyaSmartRemote0044TO(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -220,7 +224,7 @@ class TuyaSmartRemote0044TO(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -286,7 +290,7 @@ class TuyaSmartRemote0044TOPlusA(CustomDevice, Tuya4ButtonTriggers): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster, ], @@ -296,7 +300,7 @@ class TuyaSmartRemote0044TOPlusA(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -305,7 +309,7 @@ class TuyaSmartRemote0044TOPlusA(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -314,7 +318,7 @@ class TuyaSmartRemote0044TOPlusA(CustomDevice, Tuya4ButtonTriggers): PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaSmartRemoteOnOffCluster, ], OUTPUT_CLUSTERS: [], @@ -382,7 +386,7 @@ class TuyaSmartRemote0044TOPlusB(CustomDevice, Tuya4ButtonTriggers): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, ], OUTPUT_CLUSTERS: [ Time.cluster_id, diff --git a/zhaquirks/tuya/ts0046.py b/zhaquirks/tuya/ts0046.py index 62ab18eb98..68c95bf936 100644 --- a/zhaquirks/tuya/ts0046.py +++ b/zhaquirks/tuya/ts0046.py @@ -22,7 +22,11 @@ PROFILE_ID, SHORT_PRESS, ) -from zhaquirks.tuya import TuyaSmartRemoteOnOffCluster, TuyaZBE000Cluster +from zhaquirks.tuya import ( + TuyaNoBindPowerConfigurationCluster, + TuyaSmartRemoteOnOffCluster, + TuyaZBE000Cluster, +) from zhaquirks.tuya.mcu import EnchantedDevice @@ -108,7 +112,7 @@ class TuyaSmartRemote0046(EnchantedDevice, Tuya6ButtonTriggers): DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, TuyaZBE000Cluster, ], OUTPUT_CLUSTERS: [ diff --git a/zhaquirks/tuya/ts004f.py b/zhaquirks/tuya/ts004f.py index ca35fb5d0d..1d41678723 100644 --- a/zhaquirks/tuya/ts004f.py +++ b/zhaquirks/tuya/ts004f.py @@ -58,7 +58,10 @@ TURN_OFF, TURN_ON, ) -from zhaquirks.tuya import TuyaSmartRemoteOnOffCluster, TuyaZBOnOffAttributeCluster +from zhaquirks.tuya import ( + TuyaNoBindPowerConfigurationCluster, + TuyaSmartRemoteOnOffCluster, +) from zhaquirks.tuya.mcu import EnchantedDevice _LOGGER = logging.getLogger(__name__) @@ -110,7 +113,7 @@ class TuyaSmartRemote004FROK(EnchantedDevice, CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, Identify.cluster_id, Groups.cluster_id, # Is needed for adding group then binding is not working. LightLink.cluster_id, @@ -226,7 +229,7 @@ class TuyaSmartRemote004FDMS(EnchantedDevice, CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, Identify.cluster_id, Groups.cluster_id, # Is needed for adding group then binding is not working. LightLink.cluster_id, @@ -357,7 +360,7 @@ class TuyaSmartRemote004F(EnchantedDevice, CustomDevice): DEVICE_TYPE: zha.DeviceType.NON_COLOR_CONTROLLER, INPUT_CLUSTERS: [ Basic.cluster_id, - PowerConfiguration.cluster_id, + TuyaNoBindPowerConfigurationCluster, Identify.cluster_id, Groups.cluster_id, # Is needed for adding group then binding is not working. LightLink.cluster_id, @@ -368,7 +371,7 @@ class TuyaSmartRemote004F(EnchantedDevice, CustomDevice): Identify.cluster_id, Groups.cluster_id, Scenes.cluster_id, - TuyaZBOnOffAttributeCluster, + TuyaSmartRemoteOnOffCluster, LevelControl.cluster_id, LightLink.cluster_id, ], diff --git a/zhaquirks/tuya/ts0601_dimmer.py b/zhaquirks/tuya/ts0601_dimmer.py index 27af8248ac..d6c71d6441 100644 --- a/zhaquirks/tuya/ts0601_dimmer.py +++ b/zhaquirks/tuya/ts0601_dimmer.py @@ -148,6 +148,7 @@ class TuyaSingleSwitchDimmerGP(TuyaDimmerSwitch): MODELS_INFO: [ ("_TZE200_3p5ydos3", "TS0601"), ("_TZE200_ip2akl4w", "TS0601"), + ("_TZE200_vucankjx", "TS0601"), # Loratap ], ENDPOINTS: { # + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + WindowCovering.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Time.cluster_id, + Ota.cluster_id, + ], + }, + # + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + replacement = { + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.WINDOW_COVERING_DEVICE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaCoveringCluster, + ], + OUTPUT_CLUSTERS: [ + Time.cluster_id, + Ota.cluster_id, + ], + }, + 242: { + PROFILE_ID: 41440, + DEVICE_TYPE: 97, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + + class TuyaTS130FTI2(CustomDevice): """Tuya smart curtain roller shutter Time In.""" diff --git a/zhaquirks/xiaomi/__init__.py b/zhaquirks/xiaomi/__init__.py index 2c94a7a584..79711d8d38 100644 --- a/zhaquirks/xiaomi/__init__.py +++ b/zhaquirks/xiaomi/__init__.py @@ -45,6 +45,7 @@ UNKNOWN, VALUE, ZHA_SEND_EVENT, + ZONE_STATUS, ) BATTERY_LEVEL = "battery_level" @@ -84,6 +85,13 @@ VOLTAGE_REPORTED = "voltage_reported" ILLUMINANCE_MEASUREMENT = "illuminance_measurement" ILLUMINANCE_REPORTED = "illuminance_reported" +SMOKE = "smoke" +SMOKE_DENSITY = "smoke_density" +SELF_TEST = "self_test" +BUZZER_MANUAL_MUTE = "buzzer_manual_mute" +HEARTBEAT_INDICATOR = "heartbeat_indicator" +LINKAGE_ALARM = "linkage_alarm" +LINKAGE_ALARM_STATE = "linkage_alarm_state" XIAOMI_AQARA_ATTRIBUTE = 0xFF01 XIAOMI_AQARA_ATTRIBUTE_E1 = 0x00F7 XIAOMI_ATTR_3 = "X-attrib-3" @@ -299,6 +307,8 @@ def _update_attribute(self, attrid, value): "update_battery_percentage", attributes[BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE], ) + if SMOKE in attributes: + self.endpoint.ias_zone.update_attribute(ZONE_STATUS, attributes[SMOKE]) def _parse_aqara_attributes(self, value): """Parse non standard attributes.""" @@ -363,6 +373,13 @@ def _parse_aqara_attributes(self, value): attribute_names.update({323: PRESENCE_EVENT}) attribute_names.update({324: MONITORING_MODE}) attribute_names.update({326: APPROACH_DISTANCE}) + elif self.endpoint.device.model == "lumi.sensor_smoke.acn03": + attribute_names.update({160: SMOKE}) + attribute_names.update({161: SMOKE_DENSITY}) + attribute_names.update({162: SELF_TEST}) + attribute_names.update({163: BUZZER_MANUAL_MUTE}) + attribute_names.update({164: HEARTBEAT_INDICATOR}) + attribute_names.update({165: LINKAGE_ALARM}) result = {} # Some attribute reports end with a stray null byte diff --git a/zhaquirks/xiaomi/aqara/smoke.py b/zhaquirks/xiaomi/aqara/smoke.py new file mode 100644 index 0000000000..77f9c32a49 --- /dev/null +++ b/zhaquirks/xiaomi/aqara/smoke.py @@ -0,0 +1,131 @@ +"""Quirk for LUMI lumi.sensor_smoke.acn03 smoke sensor.""" +from typing import Any + +from zigpy.profiles import zha +from zigpy.quirks import CustomDevice +import zigpy.types as types +from zigpy.zcl.clusters.general import Basic, Identify, Ota, PowerConfiguration +from zigpy.zcl.clusters.security import IasZone +from zigpy.zdo.types import NodeDescriptor + +from zhaquirks import Bus, LocalDataCluster +from zhaquirks.const import ( + DEVICE_TYPE, + ENDPOINTS, + INPUT_CLUSTERS, + MODELS_INFO, + NODE_DESCRIPTOR, + OUTPUT_CLUSTERS, + PROFILE_ID, + ZONE_STATUS, +) +from zhaquirks.xiaomi import LUMI, XiaomiAqaraE1Cluster, XiaomiPowerConfiguration + +BUZZER_MANUAL_MUTE = 0x0126 +SELF_TEST = 0x0127 +SMOKE = 0x013A +SMOKE_DENSITY = 0x013B +HEARTBEAT_INDICATOR = 0x013C +BUZZER_MANUAL_ALARM = 0x013D +BUZZER = 0x013E +LINKAGE_ALARM = 0x014B +LINKAGE_ALARM_STATE = 0x014C +SMOKE_DENSITY_DBM = 0x1403 # fake attribute for smoke density in dB/m + +SMOKE_DENSITY_DBM_MAP = { + 0: 0, + 1: 0.085, + 2: 0.088, + 3: 0.093, + 4: 0.095, + 5: 0.100, + 6: 0.105, + 7: 0.110, + 8: 0.115, + 9: 0.120, + 10: 0.125, +} + + +class OppleCluster(XiaomiAqaraE1Cluster): + """Opple cluster.""" + + ep_attribute = "opple_cluster" + attributes = { + BUZZER_MANUAL_MUTE: ("buzzer_manual_mute", types.uint8_t, True), + SELF_TEST: ("self_test", types.Bool, True), + SMOKE: ("smoke", types.uint8_t, True), + SMOKE_DENSITY: ("smoke_density", types.uint8_t, True), + HEARTBEAT_INDICATOR: ("heartbeat_indicator", types.uint8_t, True), + BUZZER_MANUAL_ALARM: ("buzzer_manual_alarm", types.uint8_t, True), + BUZZER: ("buzzer", types.uint32_t, True), + LINKAGE_ALARM: ("linkage_alarm", types.uint8_t, True), + LINKAGE_ALARM_STATE: ("linkage_alarm_state", types.uint8_t, True), + SMOKE_DENSITY_DBM: ("smoke_density_dbm", types.Single, True), + } + + def _update_attribute(self, attrid: int, value: Any) -> None: + """Pass attribute update to another cluster if necessary.""" + super()._update_attribute(attrid, value) + if attrid == SMOKE: + self.endpoint.ias_zone.update_attribute(ZONE_STATUS, value) + elif attrid == SMOKE_DENSITY: + self.update_attribute(SMOKE_DENSITY_DBM, SMOKE_DENSITY_DBM_MAP[value]) + + +class LocalIasZone(LocalDataCluster, IasZone): + """Local IAS Zone cluster.""" + + _CONSTANT_ATTRIBUTES = { + IasZone.attributes_by_name["zone_type"].id: IasZone.ZoneType.Fire_Sensor + } + + +class LumiSensorSmokeAcn03(CustomDevice): + """lumi.sensor_smoke.acn03 smoke sensor.""" + + def __init__(self, *args, **kwargs): + """Init.""" + self.battery_bus = Bus() + super().__init__(*args, **kwargs) + + signature = { + MODELS_INFO: [(LUMI, "lumi.sensor_smoke.acn03")], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + PowerConfiguration.cluster_id, + Identify.cluster_id, + IasZone.cluster_id, + ], + OUTPUT_CLUSTERS: [ + Ota.cluster_id, + ], + } + }, + } + + replacement = { + NODE_DESCRIPTOR: NodeDescriptor( + 0x02, 0x40, 0x80, 0x115F, 0x7F, 0x0064, 0x2C00, 0x0064, 0x00 + ), + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ + Basic.cluster_id, + XiaomiPowerConfiguration, + Identify.cluster_id, + LocalIasZone, + OppleCluster, + ], + OUTPUT_CLUSTERS: [ + Ota.cluster_id, + ], + } + }, + } diff --git a/zhaquirks/xiaomi/aqara/thermostat_agl001.py b/zhaquirks/xiaomi/aqara/thermostat_agl001.py index 8af6a7b557..84ff889eb3 100644 --- a/zhaquirks/xiaomi/aqara/thermostat_agl001.py +++ b/zhaquirks/xiaomi/aqara/thermostat_agl001.py @@ -1,9 +1,12 @@ """Aqara E1 Radiator Thermostat Quirk.""" +from __future__ import annotations + +from typing import Any from zigpy.profiles import zha from zigpy.quirks import CustomCluster import zigpy.types as t -from zigpy.zcl.clusters.general import Basic, Identify, Time +from zigpy.zcl.clusters.general import Basic, Identify, Ota, Time from zigpy.zcl.clusters.hvac import Thermostat from zhaquirks.const import ( @@ -21,34 +24,139 @@ XiaomiPowerConfiguration, ) +ZCL_SYSTEM_MODE = Thermostat.attributes_by_name["system_mode"].id + +XIAOMI_SYSTEM_MODE_MAP = { + 0: Thermostat.SystemMode.Off, + 1: Thermostat.SystemMode.Heat, +} + +SYSTEM_MODE = 0x0271 +PRESET = 0x0272 +WINDOW_DETECTION = 0x0273 +VALVE_DETECTION = 0x0274 +VALVE_ALARM = 0x0275 +CHILD_LOCK = 0x0277 +AWAY_PRESET_TEMPERATURE = 0x0279 +WINDOW_OPEN = 0x027A +CALIBRATED = 0x027B +SCHEDULE = 0x027D +SENSOR = 0x027E +BATTERY_PERCENTAGE = 0x040A + +XIAOMI_CLUSTER_ID = 0xFCC0 + class ThermostatCluster(CustomCluster, Thermostat): """Thermostat cluster.""" - _CONSTANT_ATTRIBUTES = {0x001B: 0x02} + # remove cooling mode + _CONSTANT_ATTRIBUTES = { + Thermostat.attributes_by_name[ + "ctrl_sequence_of_oper" + ].id: Thermostat.ControlSequenceOfOperation.Heating_Only + } + async def read_attributes( + self, + attributes: list[int | str], + allow_cache: bool = False, + only_cache: bool = False, + manufacturer: int | t.uint16_t | None = None, + ): + """Pass reading attributes to Xiaomi cluster if applicable.""" + successful_r, failed_r = {}, {} + remaining_attributes = attributes.copy() + + # read system_mode from Xiaomi cluster (can be numeric or string) + if ZCL_SYSTEM_MODE in attributes or "system_mode" in attributes: + self.debug("Passing 'system_mode' read to Xiaomi cluster") + + if ZCL_SYSTEM_MODE in attributes: + remaining_attributes.remove(ZCL_SYSTEM_MODE) + if "system_mode" in attributes: + remaining_attributes.remove("system_mode") + + successful_r, failed_r = await self.endpoint.opple_cluster.read_attributes( + [SYSTEM_MODE], allow_cache, only_cache, manufacturer + ) + # convert Xiaomi system_mode to ZCL attribute + if SYSTEM_MODE in successful_r: + successful_r[ZCL_SYSTEM_MODE] = XIAOMI_SYSTEM_MODE_MAP[ + successful_r.pop(SYSTEM_MODE) + ] + # read remaining attributes from thermostat cluster + if remaining_attributes: + remaining_result = await super().read_attributes( + remaining_attributes, allow_cache, only_cache, manufacturer + ) + successful_r.update(remaining_result[0]) + failed_r.update(remaining_result[1]) + return successful_r, failed_r + + async def write_attributes( + self, attributes: dict[str | int, Any], manufacturer: int | None = None + ) -> list: + """Pass writing attributes to Xiaomi cluster if applicable.""" + result = [] + remaining_attributes = attributes.copy() + system_mode_value = None + + # check if system_mode is being written (can be numeric or string) + if ZCL_SYSTEM_MODE in attributes: + remaining_attributes.pop(ZCL_SYSTEM_MODE) + system_mode_value = attributes.get(ZCL_SYSTEM_MODE) + if "system_mode" in attributes: + remaining_attributes.pop("system_mode") + system_mode_value = attributes.get("system_mode") + + # write system_mode to Xiaomi cluster if applicable + if system_mode_value is not None: + self.debug("Passing 'system_mode' write to Xiaomi cluster") + result += await self.endpoint.opple_cluster.write_attributes( + {SYSTEM_MODE: min(int(system_mode_value), 1)} + ) -XIAOMI_CLUSTER_ID = 0xFCC0 + # write remaining attributes to thermostat cluster + if remaining_attributes: + result += await super().write_attributes(remaining_attributes, manufacturer) + return result class AqaraThermostatSpecificCluster(XiaomiAqaraE1Cluster): """Aqara manufacturer specific settings.""" - ep_attribute = "aqara_cluster" + ep_attribute = "opple_cluster" attributes = XiaomiAqaraE1Cluster.attributes.copy() attributes.update( { - 0x040A: ("battery_percentage", t.uint8_t, True), + SYSTEM_MODE: ("system_mode", t.uint8_t, True), + PRESET: ("preset", t.uint8_t, True), + WINDOW_DETECTION: ("window_detection", t.uint8_t, True), + VALVE_DETECTION: ("valve_detection", t.uint8_t, True), + VALVE_ALARM: ("valve_alarm", t.uint8_t, True), + CHILD_LOCK: ("child_lock", t.uint8_t, True), + AWAY_PRESET_TEMPERATURE: ("away_preset_temperature", t.uint32_t, True), + WINDOW_OPEN: ("window_open", t.uint8_t, True), + CALIBRATED: ("calibrated", t.uint8_t, True), + SCHEDULE: ("schedule", t.uint8_t, True), + SENSOR: ("sensor", t.uint8_t, True), + BATTERY_PERCENTAGE: ("battery_percentage", t.uint8_t, True), } ) def _update_attribute(self, attrid, value): - self.debug("Attribute/Value", attrid, value) - if attrid == 0x040A: + self.debug("Updating attribute on Xiaomi cluster %s with %s", attrid, value) + if attrid == BATTERY_PERCENTAGE: self.endpoint.device.battery_bus.listener_event( "battery_percent_reported", value ) + elif attrid == SYSTEM_MODE: + # update ZCL system_mode attribute (e.g. on attribute reports) + self.endpoint.thermostat.update_attribute( + ZCL_SYSTEM_MODE, XIAOMI_SYSTEM_MODE_MAP[value] + ) super()._update_attribute(attrid, value) @@ -71,12 +179,12 @@ class AGL001(XiaomiCustomDevice): Thermostat.cluster_id, Time.cluster_id, XiaomiPowerConfiguration.cluster_id, - XIAOMI_CLUSTER_ID, + AqaraThermostatSpecificCluster.cluster_id, ], OUTPUT_CLUSTERS: [ Identify.cluster_id, Thermostat.cluster_id, - XIAOMI_CLUSTER_ID, + AqaraThermostatSpecificCluster.cluster_id, ], } }, @@ -97,6 +205,7 @@ class AGL001(XiaomiCustomDevice): Identify.cluster_id, ThermostatCluster, AqaraThermostatSpecificCluster, + Ota.cluster_id, ], } }