diff --git a/tests/test_develco.py b/tests/test_develco.py index dd95d12130..3c72057797 100644 --- a/tests/test_develco.py +++ b/tests/test_develco.py @@ -1,6 +1,7 @@ """Tests for Develco/Frient.""" from unittest import mock +from unittest.mock import MagicMock import zigpy.types as t from zigpy.zcl import ClusterType, foundation @@ -8,6 +9,7 @@ from tests.common import ClusterListener import zhaquirks +from zhaquirks.develco.motion import FrientTamperIasZone zhaquirks.setup() @@ -187,3 +189,27 @@ async def test_mfg_cluster_events(zigpy_device_from_v2_quirk): assert ( metering_cluster.get(Metering.AttributeDefs.current_summ_delivered.id) == 1234 ) + + +def test_frient_tamper_zone_updates_tamper_state(): + """Verify tamper attribute mirrors zone status bit 2.""" + endpoint = MagicMock() + cluster = FrientTamperIasZone(endpoint) + + cluster._update_attribute( + FrientTamperIasZone.AttributeDefs.zone_status.id, + 0x0000, + ) + assert cluster._attr_cache[FrientTamperIasZone.AttributeDefs.tamper.id] is False + + cluster._update_attribute( + FrientTamperIasZone.AttributeDefs.zone_status.id, + 0x0004, + ) + assert cluster._attr_cache[FrientTamperIasZone.AttributeDefs.tamper.id] is True + + cluster._update_attribute( + FrientTamperIasZone.AttributeDefs.zone_status.id, + 0x0000, + ) + assert cluster._attr_cache[FrientTamperIasZone.AttributeDefs.tamper.id] is False diff --git a/zhaquirks/develco/motion.py b/zhaquirks/develco/motion.py index 758e37f8d1..6174074ed3 100644 --- a/zhaquirks/develco/motion.py +++ b/zhaquirks/develco/motion.py @@ -1,21 +1,207 @@ -"""Develco Motion Sensor Pro.""" +"""frient Motion Sensor/Pro/PET.""" +from typing import Final + +from zigpy.quirks import CustomCluster from zigpy.quirks.v2 import QuirkBuilder +from zigpy.quirks.v2.homeassistant import UnitOfTime +import zigpy.types as t +from zigpy.types import uint16_t from zigpy.zcl.clusters.general import BinaryInput +from zigpy.zcl.clusters.measurement import OccupancySensing +from zigpy.zcl.clusters.security import IasZone +from zigpy.zcl.foundation import ZCLAttributeDef from zhaquirks.develco import DEVELCO, FRIENT, DevelcoIasZone, DevelcoPowerConfiguration + +class FrientOccupancySensing(CustomCluster, OccupancySensing): + """Custom occupancy sensing cluster for frient motion sensors.""" + + class AttributeDefs(OccupancySensing.AttributeDefs): + """Attribute definitions.""" + + pir_o_to_u_delay: Final = ZCLAttributeDef( + id=0x0010, + type=uint16_t, + access="rw", + ) + + pir_u_to_o_delay: Final = ZCLAttributeDef( + id=0x0011, + type=uint16_t, + access="rw", + ) + + +class FrientTamperIasZone(DevelcoIasZone): + """Custom IAS Zone cluster for frient motion sensors with tamper support.""" + + def _update_attribute(self, attrid, value): + super()._update_attribute(attrid, value) + if attrid == self.AttributeDefs.zone_status.id: + # Update tamper state from zone_status bit 2 + tamper_state = bool(value & 0b00000100) + super()._update_attribute(self.AttributeDefs.tamper.id, tamper_state) + + class AttributeDefs(IasZone.AttributeDefs): + """Attribute definitions.""" + + tamper: Final = ZCLAttributeDef( + id=0xFFF2, # Custom attribute ID + type=t.Bool, + ) + + +class FrientPETSensitivityIasZone(DevelcoIasZone): + """Custom IAS Zone cluster for frient PET motion sensor with sensitivity levels.""" + + class AttributeDefs(IasZone.AttributeDefs): + """Attribute definitions.""" + + number_of_zone_sensitivity_levels_supported: Final = ZCLAttributeDef( + id=0x0012, + type=t.uint8_t, + access="r", + ) + + current_zone_sensitivity_level: Final = ZCLAttributeDef( + id=0x0013, + type=t.uint8_t, + access="rw", + ) + + +# MOSZB-140 (Motion Sensor Pro) - has tamper sensor, no sensitivity levels ( - QuirkBuilder("frient A/S", "MOSZB-153") + QuirkBuilder(FRIENT, "MOSZB-140") .applies_to(DEVELCO, "MOSZB-140") - .applies_to(FRIENT, "MOSZB-140") + .replaces(DevelcoPowerConfiguration, endpoint_id=35) + .replaces(FrientTamperIasZone, endpoint_id=35) + .replaces( + FrientOccupancySensing, cluster_id=OccupancySensing.cluster_id, endpoint_id=34 + ) + .binary_sensor( + attribute_name="tamper", + cluster_id=IasZone.cluster_id, + endpoint_id=35, + entity_type="tamper", + translation_key="tamper", + fallback_name="Tamper", + ) + .number( + attribute_name="pir_o_to_u_delay", + cluster_id=OccupancySensing.cluster_id, + endpoint_id=34, + min_value=0, + max_value=65535, + step=1, + translation_key="occupancy_delay", + fallback_name="Occupied to unoccupied delay", + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name="pir_u_to_o_delay", + cluster_id=OccupancySensing.cluster_id, + endpoint_id=34, + min_value=0, + max_value=65535, + step=1, + translation_key="unoccupancy_delay", + fallback_name="Unoccupied to occupied delay", + unit=UnitOfTime.SECONDS, + ) + .prevent_default_entity_creation(endpoint_id=35, cluster_id=BinaryInput.cluster_id) + .prevent_default_entity_creation(endpoint_id=40) + .prevent_default_entity_creation(endpoint_id=41) + .add_to_registry() +) + +# MOSZB-141 (Motion Sensor) - no tamper sensor, no sensitivity levels +( + QuirkBuilder(FRIENT, "MOSZB-141") + .applies_to(DEVELCO, "MOSZB-141") .replaces(DevelcoPowerConfiguration, endpoint_id=35) .replaces(DevelcoIasZone, endpoint_id=35) - # This entity does not do anything + .replaces( + FrientOccupancySensing, cluster_id=OccupancySensing.cluster_id, endpoint_id=34 + ) + .number( + attribute_name="pir_o_to_u_delay", + cluster_id=OccupancySensing.cluster_id, + endpoint_id=34, + min_value=0, + max_value=65535, + step=1, + translation_key="occupancy_delay", + fallback_name="Occupied to unoccupied delay", + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name="pir_u_to_o_delay", + cluster_id=OccupancySensing.cluster_id, + endpoint_id=34, + min_value=0, + max_value=65535, + step=1, + translation_key="unoccupancy_delay", + fallback_name="Unoccupied to occupied delay", + unit=UnitOfTime.SECONDS, + ) + .prevent_default_entity_creation(endpoint_id=35, cluster_id=BinaryInput.cluster_id) + .prevent_default_entity_creation(endpoint_id=40) + .prevent_default_entity_creation(endpoint_id=41) + .add_to_registry() +) + +# MOSZB-153 (Motion Sensor 2 PET) - no tamper sensor, but has sensitivity levels +( + QuirkBuilder(FRIENT, "MOSZB-153") + .replaces(DevelcoPowerConfiguration, endpoint_id=35) + .replaces(FrientPETSensitivityIasZone, endpoint_id=35) + .replaces( + FrientOccupancySensing, cluster_id=OccupancySensing.cluster_id, endpoint_id=34 + ) + .number( + attribute_name="current_zone_sensitivity_level", + cluster_id=IasZone.cluster_id, + endpoint_id=35, + min_value=1, + max_value=4, + step=1, + translation_key="sensitivity_level", + fallback_name="Sensitivity level (1-4)", + ) + .number( + attribute_name="pir_o_to_u_delay", + cluster_id=OccupancySensing.cluster_id, + endpoint_id=34, + min_value=0, + max_value=65535, + step=1, + translation_key="occupancy_delay", + fallback_name="Occupied to unoccupied delay", + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name="pir_u_to_o_delay", + cluster_id=OccupancySensing.cluster_id, + endpoint_id=34, + min_value=0, + max_value=65535, + step=1, + translation_key="unoccupancy_delay", + fallback_name="Unoccupied to occupied delay", + unit=UnitOfTime.SECONDS, + ) + .sensor( + attribute_name="number_of_zone_sensitivity_levels_supported", + cluster_id=IasZone.cluster_id, + endpoint_id=35, + translation_key="sensitivity_levels_supported", + fallback_name="Sensitivity levels supported", + ) .prevent_default_entity_creation(endpoint_id=35, cluster_id=BinaryInput.cluster_id) - # This endpoint holds only an occupancy cluster that updates unusably slowly - .prevent_default_entity_creation(endpoint_id=34) - # These endpoints are duplicates of 35 and do not create useful entities .prevent_default_entity_creation(endpoint_id=40) .prevent_default_entity_creation(endpoint_id=41) .add_to_registry()