diff --git a/tests/data/devices/bitron-video-902010-24a.json b/tests/data/devices/bitron-video-902010-24a.json index 5aa72075f..26c0da407 100644 --- a/tests/data/devices/bitron-video-902010-24a.json +++ b/tests/data/devices/bitron-video-902010-24a.json @@ -565,7 +565,7 @@ "unique_id": "ab:cd:ef:12:f4:7b:a3:f4-1-1282", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "AdvancedSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -605,7 +605,7 @@ "supported_features": 31 }, "state": { - "class_name": "Siren", + "class_name": "AdvancedSiren", "available": true, "state": false } diff --git a/tests/data/devices/climaxtechnology-sd8sc-00-00-03-12tc.json b/tests/data/devices/climaxtechnology-sd8sc-00-00-03-12tc.json index 18941faec..0effe9e6a 100644 --- a/tests/data/devices/climaxtechnology-sd8sc-00-00-03-12tc.json +++ b/tests/data/devices/climaxtechnology-sd8sc-00-00-03-12tc.json @@ -542,7 +542,7 @@ "unique_id": "00:12:4b:00:09:6e:5a:cf-1-1282", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "AdvancedSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -582,7 +582,7 @@ "supported_features": 31 }, "state": { - "class_name": "Siren", + "class_name": "AdvancedSiren", "available": true, "state": false } diff --git a/tests/data/devices/frient-a-s-flszb-110.json b/tests/data/devices/frient-a-s-flszb-110.json index 6a10744b5..f427b04d2 100644 --- a/tests/data/devices/frient-a-s-flszb-110.json +++ b/tests/data/devices/frient-a-s-flszb-110.json @@ -583,7 +583,7 @@ "unique_id": "00:15:bc:00:33:00:76:9a-35-1282", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "BasicSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -612,18 +612,11 @@ "endpoint_id": 35, "available": true, "group_id": null, - "available_tones": { - "1": "Burglar", - "2": "Fire", - "3": "Emergency", - "4": "Police Panic", - "5": "Fire Panic", - "6": "Emergency Panic" - }, - "supported_features": 31 + "available_tones": {}, + "supported_features": 19 }, "state": { - "class_name": "Siren", + "class_name": "BasicSiren", "available": true, "state": false } diff --git a/tests/data/devices/frient-a-s-scazb-141.json b/tests/data/devices/frient-a-s-scazb-141.json index 468000fed..b5fddc1f0 100644 --- a/tests/data/devices/frient-a-s-scazb-141.json +++ b/tests/data/devices/frient-a-s-scazb-141.json @@ -923,7 +923,7 @@ "unique_id": "ab:cd:ef:12:18:f2:e7:e6-35-1282", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "BasicSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -952,18 +952,11 @@ "endpoint_id": 35, "available": true, "group_id": null, - "available_tones": { - "1": "Burglar", - "2": "Fire", - "3": "Emergency", - "4": "Police Panic", - "5": "Fire Panic", - "6": "Emergency Panic" - }, - "supported_features": 31 + "available_tones": {}, + "supported_features": 19 }, "state": { - "class_name": "Siren", + "class_name": "BasicSiren", "available": true, "state": false } diff --git a/tests/data/devices/frient-a-s-sirzb-111.json b/tests/data/devices/frient-a-s-sirzb-111.json index 5f2814f00..dd19cc1a8 100644 --- a/tests/data/devices/frient-a-s-sirzb-111.json +++ b/tests/data/devices/frient-a-s-sirzb-111.json @@ -661,7 +661,7 @@ "unique_id": "00:15:bc:00:41:00:6f:8e-43", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "AdvancedSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -701,7 +701,7 @@ "supported_features": 31 }, "state": { - "class_name": "Siren", + "class_name": "AdvancedSiren", "available": true, "state": false } diff --git a/tests/data/devices/frient-a-s-smszb-120.json b/tests/data/devices/frient-a-s-smszb-120.json index d05189cce..27a28c73b 100644 --- a/tests/data/devices/frient-a-s-smszb-120.json +++ b/tests/data/devices/frient-a-s-smszb-120.json @@ -608,7 +608,7 @@ "unique_id": "00:15:bc:00:31:01:f8:92-35-1282", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "BasicSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -637,18 +637,11 @@ "endpoint_id": 35, "available": true, "group_id": null, - "available_tones": { - "1": "Burglar", - "2": "Fire", - "3": "Emergency", - "4": "Police Panic", - "5": "Fire Panic", - "6": "Emergency Panic" - }, - "supported_features": 31 + "available_tones": {}, + "supported_features": 19 }, "state": { - "class_name": "Siren", + "class_name": "BasicSiren", "available": true, "state": false } diff --git a/tests/data/devices/homr-hrmsn01.json b/tests/data/devices/homr-hrmsn01.json index 847efcede..317046451 100644 --- a/tests/data/devices/homr-hrmsn01.json +++ b/tests/data/devices/homr-hrmsn01.json @@ -1127,7 +1127,7 @@ "unique_id": "f4:ce:36:f8:ec:e4:c3:5d-1-1282", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "AdvancedSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -1167,7 +1167,7 @@ "supported_features": 31 }, "state": { - "class_name": "Siren", + "class_name": "AdvancedSiren", "available": true, "state": false } diff --git a/tests/data/devices/tyzb01-8scntis1-ts0216.json b/tests/data/devices/tyzb01-8scntis1-ts0216.json index b5117f244..9f5e1abb3 100644 --- a/tests/data/devices/tyzb01-8scntis1-ts0216.json +++ b/tests/data/devices/tyzb01-8scntis1-ts0216.json @@ -620,7 +620,7 @@ "unique_id": "ab:cd:ef:12:52:26:72:dc-1", "migrate_unique_ids": [], "platform": "siren", - "class_name": "Siren", + "class_name": "AdvancedSiren", "translation_key": null, "translation_placeholders": null, "device_class": null, @@ -660,7 +660,7 @@ "supported_features": 31 }, "state": { - "class_name": "Siren", + "class_name": "AdvancedSiren", "available": true, "state": false } diff --git a/tests/test_siren.py b/tests/test_siren.py index 746a79926..f8161b2b6 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import patch +from zhaquirks.quirk_ids import SIREN_BASIC from zigpy.const import SIG_EP_PROFILE from zigpy.profiles import zha from zigpy.zcl.clusters import general, security @@ -25,6 +26,7 @@ async def siren_mock( zha_gateway: Gateway, + basic: bool = False, ) -> tuple[Device, security.IasWd]: """Siren fixture.""" @@ -40,6 +42,9 @@ async def siren_mock( }, ) + if basic: + zigpy_device.quirk_id = {SIREN_BASIC} + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) return zha_device, zigpy_device.endpoints[1].ias_wd @@ -119,6 +124,79 @@ async def test_siren(zha_gateway: Gateway) -> None: assert entity.state["state"] is True +async def test_basic_siren(zha_gateway: Gateway) -> None: + """Test zha basic siren.""" + + zha_device, cluster = await siren_mock(zha_gateway, basic=True) + assert cluster is not None + + entity = get_entity(zha_device, platform=Platform.SIREN) + assert entity.supported_features == ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + ) + + assert entity.state["state"] is False + + # turn on from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=[0x00, zcl_f.Status.SUCCESS], + ): + await entity.async_turn_on() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 18 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to on + assert entity.state["state"] is True + + # turn off from client + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await entity.async_turn_off() + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 2 # bitmask for default args + assert cluster.request.call_args[0][4] == 5 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to off + assert entity.state["state"] is False + + # turn on from client with duration option + with patch( + "zigpy.zcl.Cluster.request", + return_value=mock_coro([0x00, zcl_f.Status.SUCCESS]), + ): + await entity.async_turn_on(duration=100) + await zha_gateway.async_block_till_done() + assert len(cluster.request.mock_calls) == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0 + assert cluster.request.call_args[0][3] == 18 # bitmask for specified args + assert cluster.request.call_args[0][4] == 100 # duration in seconds + assert cluster.request.call_args[0][5] == 0 + assert cluster.request.call_args[0][6] == 2 + cluster.request.reset_mock() + + # test that the state has changed to on + assert entity.state["state"] is True + + async def test_siren_timed_off(zha_gateway: Gateway) -> None: """Test zha siren platform.""" zha_device, cluster = await siren_mock(zha_gateway) diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index 8a9c5d888..9da3fc16c 100644 --- a/zha/application/platforms/__init__.py +++ b/zha/application/platforms/__init__.py @@ -75,6 +75,9 @@ class PlatformFeatureGroup(StrEnum): # Prefer OTA client update entities over OTA server update entities OTA_UPDATE = "ota_update" + # IAS WD siren entity selection + SIREN = "siren" + @dataclasses.dataclass(frozen=True) class ClusterHandlerMatch: diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index fca20acb7..eb2a673fa 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -10,6 +10,7 @@ import functools from typing import TYPE_CHECKING, Any, Final, cast +from zhaquirks.quirk_ids import SIREN_BASIC from zigpy.profiles import zha from zigpy.zcl.clusters.security import IasWd @@ -31,6 +32,7 @@ BaseEntityInfo, ClusterHandlerMatch, PlatformEntity, + PlatformFeatureGroup, register_entity, ) from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD @@ -121,16 +123,11 @@ async def async_turn_off(self) -> None: """Turn off siren.""" -@register_entity(IasWd.cluster_id) -class Siren(BaseSiren): - """Representation of a ZHA siren.""" - - _attr_fallback_name: str = "Siren" - _attr_primary_weight = 4 +class BaseZclSiren(BaseSiren, ABC): + """Base class for ZHA IAS WD siren entities with shared ZCL logic.""" - _cluster_handler_match = ClusterHandlerMatch( - cluster_handlers=frozenset({CLUSTER_HANDLER_IAS_WD}), - ) + _cluster_handler: IasWdClusterHandler + _off_listener: asyncio.TimerHandle | None def __init__( self, @@ -139,10 +136,9 @@ def __init__( device: Device, **kwargs: Any, ) -> None: - """Init this siren.""" - self._cluster_handler: IasWdClusterHandler = cast( - IasWdClusterHandler, cluster_handlers[0] - ) + """Init ZCL siren base.""" + self._cluster_handler = cast(IasWdClusterHandler, cluster_handlers[0]) + self._off_listener = None legacy_discovery_unique_id = ( f"{endpoint.device.ieee}-{endpoint.id}" @@ -156,9 +152,57 @@ def __init__( cluster_handlers, endpoint, device, - **kwargs, legacy_discovery_unique_id=legacy_discovery_unique_id, + **kwargs, + ) + + def _cancel_off_listener(self) -> None: + """Cancel and clean up the off listener.""" + if self._off_listener: + self._off_listener.cancel() + + with contextlib.suppress(ValueError): + self._tracked_handles.remove(self._off_listener) + + self._off_listener = None + + async def async_turn_off(self) -> None: + """Turn off siren.""" + await self._cluster_handler.issue_start_warning( + mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO ) + self._cancel_off_listener() + self._attr_is_on = False + self.maybe_emit_state_changed_event() + + def _async_set_off(self) -> None: + """Set is_on to False and write HA state.""" + self._attr_is_on = False + self._cancel_off_listener() + self.maybe_emit_state_changed_event() + + +@register_entity(IasWd.cluster_id) +class AdvancedSiren(BaseZclSiren): + """Representation of a ZHA siren with full tone, level, and strobe support.""" + + _attr_fallback_name: str = "Siren" + _attr_primary_weight = 4 + + _cluster_handler_match = ClusterHandlerMatch( + cluster_handlers=frozenset({CLUSTER_HANDLER_IAS_WD}), + feature_priority=(PlatformFeatureGroup.SIREN, 0), + ) + + def __init__( + self, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> None: + """Init this siren.""" + super().__init__(cluster_handlers, endpoint, device, **kwargs) self._attr_supported_features = ( SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF @@ -174,7 +218,6 @@ def __init__( WARNING_DEVICE_MODE_FIRE_PANIC: "Fire Panic", WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } - self._off_listener: asyncio.TimerHandle | None = None async def async_turn_on( self, @@ -183,9 +226,7 @@ async def async_turn_on( volume_level: int | None = None, ) -> None: """Turn on siren.""" - if self._off_listener: - self._off_listener.cancel() - self._off_listener = None + self._cancel_off_listener() tone_cache = self._cluster_handler.data_cache.get( IasWd.Warning.WarningMode.__name__ ) @@ -229,27 +270,62 @@ async def async_turn_on( ) self._attr_is_on = True self._off_listener = asyncio.get_running_loop().call_later( - siren_duration, self.async_set_off + siren_duration, self._async_set_off ) self._tracked_handles.append(self._off_listener) self.maybe_emit_state_changed_event() - async def async_turn_off(self) -> None: - """Turn off siren.""" - await self._cluster_handler.issue_start_warning( - mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO - ) - self._attr_is_on = False - self.maybe_emit_state_changed_event() - def async_set_off(self) -> None: - """Set is_on to False and write HA state.""" - self._attr_is_on = False - if self._off_listener: - self._off_listener.cancel() +@register_entity(IasWd.cluster_id) +class BasicSiren(BaseZclSiren): + """Representation of a basic ZHA siren with fixed tone, level, and strobe.""" - with contextlib.suppress(ValueError): - self._tracked_handles.remove(self._off_listener) + _attr_fallback_name: str = "Siren" + _attr_primary_weight = 4 - self._off_listener = None + _cluster_handler_match = ClusterHandlerMatch( + cluster_handlers=frozenset({CLUSTER_HANDLER_IAS_WD}), + exposed_features=frozenset({SIREN_BASIC}), + feature_priority=(PlatformFeatureGroup.SIREN, 1), + ) + + def __init__( + self, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> None: + """Init this basic siren.""" + super().__init__(cluster_handlers, endpoint, device, **kwargs) + self._attr_supported_features = ( + SirenEntityFeature.TURN_ON + | SirenEntityFeature.TURN_OFF + | SirenEntityFeature.DURATION + ) + self._attr_available_tones: dict[int, str] = {} + + async def async_turn_on( + self, + duration: int | None = None, + tone: int | None = None, + volume_level: int | None = None, + ) -> None: + """Turn on siren with fixed tone, level, and strobe.""" + self._cancel_off_listener() + siren_duration = duration if duration is not None else DEFAULT_DURATION + await self._cluster_handler.issue_start_warning( + # some Frient sensors send INVALID_VALUE for EMERGENCY + mode=WARNING_DEVICE_MODE_BURGLAR, + warning_duration=siren_duration, + siren_level=WARNING_DEVICE_SOUND_HIGH, + strobe=WARNING_DEVICE_STROBE_NO, + strobe_duty_cycle=0, + strobe_intensity=WARNING_DEVICE_STROBE_HIGH, + ) + self._attr_is_on = True + self._off_listener = asyncio.get_running_loop().call_later( + siren_duration, self._async_set_off + ) + self._tracked_handles.append(self._off_listener) self.maybe_emit_state_changed_event()