From 5e07673f25e0538ef8a4ddbef78b878757aa8eca Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sat, 21 Feb 2026 05:19:55 +0100 Subject: [PATCH 01/11] Add `BasicSiren` class --- zha/application/platforms/__init__.py | 3 + zha/application/platforms/siren.py | 82 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index 410082a28..4e2d3866e 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..ce75e34bb 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -31,6 +31,7 @@ BaseEntityInfo, ClusterHandlerMatch, PlatformEntity, + PlatformFeatureGroup, register_entity, ) from zha.zigbee.cluster_handlers.const import CLUSTER_HANDLER_IAS_WD @@ -130,6 +131,7 @@ class Siren(BaseSiren): _cluster_handler_match = ClusterHandlerMatch( cluster_handlers=frozenset({CLUSTER_HANDLER_IAS_WD}), + feature_priority=(PlatformFeatureGroup.SIREN, 0), ) def __init__( @@ -253,3 +255,83 @@ def async_set_off(self) -> None: self._off_listener = None self.maybe_emit_state_changed_event() + + +@register_entity(IasWd.cluster_id) +class BasicSiren(BaseSiren): + """Representation of a basic ZHA siren with no tone, level, and strobe.""" + + _attr_fallback_name: str = "Siren" + _attr_primary_weight = 4 + + _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.""" + self._cluster_handler: IasWdClusterHandler = cast( + IasWdClusterHandler, cluster_handlers[0] + ) + 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] = {} + self._off_listener: asyncio.TimerHandle | None = None + + 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.""" + if self._off_listener: + self._off_listener.cancel() + self._off_listener = None + siren_duration = duration if duration is not None else DEFAULT_DURATION + await self._cluster_handler.issue_start_warning( + mode=WARNING_DEVICE_MODE_EMERGENCY, + 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() + + 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() + + with contextlib.suppress(ValueError): + self._tracked_handles.remove(self._off_listener) + + self._off_listener = None + self.maybe_emit_state_changed_event() From ddd670276417d395235fe4535c7db392e95bcb35 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sat, 21 Feb 2026 05:20:04 +0100 Subject: [PATCH 02/11] Add test --- tests/test_siren.py | 77 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/tests/test_siren.py b/tests/test_siren.py index 746a79926..2a361c884 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -25,6 +25,7 @@ async def siren_mock( zha_gateway: Gateway, + basic: bool = False, ) -> tuple[Device, security.IasWd]: """Siren fixture.""" @@ -40,6 +41,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 +123,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] == 50 # 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] == 50 # 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) From 69c3797a000d1a1950daf1aef550d6c7338f43da Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sat, 21 Feb 2026 05:35:57 +0100 Subject: [PATCH 03/11] Also add `BaseZclSiren` class --- zha/application/platforms/siren.py | 98 ++++++++++++++---------------- 1 file changed, 44 insertions(+), 54 deletions(-) diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index ce75e34bb..a86fb867e 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -122,9 +122,48 @@ async def async_turn_off(self) -> None: """Turn off siren.""" +class BaseZclSiren(BaseSiren, ABC): + """Base class for ZHA IAS WD siren entities with shared ZCL logic.""" + + _cluster_handler: IasWdClusterHandler + _off_listener: asyncio.TimerHandle | None + + def __init__( + self, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> None: + """Init ZCL siren base.""" + self._cluster_handler = cast(IasWdClusterHandler, cluster_handlers[0]) + self._off_listener = None + super().__init__(cluster_handlers, endpoint, device, **kwargs) + + 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() + + with contextlib.suppress(ValueError): + self._tracked_handles.remove(self._off_listener) + + self._off_listener = None + self.maybe_emit_state_changed_event() + + @register_entity(IasWd.cluster_id) -class Siren(BaseSiren): - """Representation of a ZHA siren.""" +class AdvancedSiren(BaseZclSiren): + """Representation of a ZHA siren with full tone, level, and strobe support.""" _attr_fallback_name: str = "Siren" _attr_primary_weight = 4 @@ -142,10 +181,6 @@ def __init__( **kwargs: Any, ) -> None: """Init this siren.""" - self._cluster_handler: IasWdClusterHandler = cast( - IasWdClusterHandler, cluster_handlers[0] - ) - legacy_discovery_unique_id = ( f"{endpoint.device.ieee}-{endpoint.id}" if ( @@ -176,7 +211,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, @@ -231,35 +265,15 @@ 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() - - with contextlib.suppress(ValueError): - self._tracked_handles.remove(self._off_listener) - - self._off_listener = None - self.maybe_emit_state_changed_event() - @register_entity(IasWd.cluster_id) -class BasicSiren(BaseSiren): - """Representation of a basic ZHA siren with no tone, level, and strobe.""" +class BasicSiren(BaseZclSiren): + """Representation of a basic ZHA siren with fixed tone, level, and strobe.""" _attr_fallback_name: str = "Siren" _attr_primary_weight = 4 @@ -278,9 +292,6 @@ def __init__( **kwargs: Any, ) -> None: """Init this basic siren.""" - self._cluster_handler: IasWdClusterHandler = cast( - IasWdClusterHandler, cluster_handlers[0] - ) super().__init__(cluster_handlers, endpoint, device, **kwargs) self._attr_supported_features = ( SirenEntityFeature.TURN_ON @@ -288,7 +299,6 @@ def __init__( | SirenEntityFeature.DURATION ) self._attr_available_tones: dict[int, str] = {} - self._off_listener: asyncio.TimerHandle | None = None async def async_turn_on( self, @@ -315,23 +325,3 @@ async def async_turn_on( ) 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() - - with contextlib.suppress(ValueError): - self._tracked_handles.remove(self._off_listener) - - self._off_listener = None - self.maybe_emit_state_changed_event() From d292bf62a55f0a09f89acb5f54c314d71718631c Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 24 Mar 2026 16:10:43 +0100 Subject: [PATCH 04/11] Use exposed feature constant from zha-quirks --- tests/test_siren.py | 3 ++- zha/application/platforms/siren.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_siren.py b/tests/test_siren.py index 2a361c884..8e263cff5 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 @@ -42,7 +43,7 @@ async def siren_mock( ) if basic: - zigpy_device.quirk_id = {"siren_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 diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index a86fb867e..3ade0d0a4 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 @@ -280,7 +281,7 @@ class BasicSiren(BaseZclSiren): _cluster_handler_match = ClusterHandlerMatch( cluster_handlers=frozenset({CLUSTER_HANDLER_IAS_WD}), - exposed_features=frozenset({"siren_basic"}), + exposed_features=frozenset({SIREN_BASIC}), feature_priority=(PlatformFeatureGroup.SIREN, 1), ) From 57923c8ead1d893d7b65595de02fecadb9060eec Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 24 Mar 2026 16:45:59 +0100 Subject: [PATCH 05/11] Change to `WARNING_DEVICE_MODE_BURGLAR` --- zha/application/platforms/siren.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index 3ade0d0a4..820c9c3a6 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -313,7 +313,8 @@ async def async_turn_on( self._off_listener = None siren_duration = duration if duration is not None else DEFAULT_DURATION await self._cluster_handler.issue_start_warning( - mode=WARNING_DEVICE_MODE_EMERGENCY, + # 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, From be6f4d9c9a26b95f2b63dce809d8d100b4bc1e39 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 24 Mar 2026 16:46:05 +0100 Subject: [PATCH 06/11] Update tests --- tests/test_siren.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_siren.py b/tests/test_siren.py index 8e263cff5..f8161b2b6 100644 --- a/tests/test_siren.py +++ b/tests/test_siren.py @@ -149,7 +149,7 @@ async def test_basic_siren(zha_gateway: Gateway) -> None: 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] == 50 # bitmask for default args + 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 @@ -187,7 +187,7 @@ async def test_basic_siren(zha_gateway: Gateway) -> None: 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] == 50 # bitmask for specified args + 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 From cbc223076d3c79686c1627c76de1cac781a1c59d Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Mar 2026 04:04:41 +0100 Subject: [PATCH 07/11] Regenerate diagnostics --- tests/data/devices/bitron-video-902010-24a.json | 4 ++-- .../climaxtechnology-sd8sc-00-00-03-12tc.json | 4 ++-- tests/data/devices/frient-a-s-flszb-110.json | 15 ++++----------- tests/data/devices/frient-a-s-scazb-141.json | 15 ++++----------- tests/data/devices/frient-a-s-sirzb-111.json | 4 ++-- tests/data/devices/frient-a-s-smszb-120.json | 15 ++++----------- tests/data/devices/homr-hrmsn01.json | 4 ++-- tests/data/devices/tyzb01-8scntis1-ts0216.json | 4 ++-- 8 files changed, 22 insertions(+), 43 deletions(-) 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 920b3b557..9dda35e32 100644 --- a/tests/data/devices/frient-a-s-flszb-110.json +++ b/tests/data/devices/frient-a-s-flszb-110.json @@ -782,7 +782,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, @@ -811,18 +811,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 9a21e8081..d88e1fe48 100644 --- a/tests/data/devices/frient-a-s-scazb-141.json +++ b/tests/data/devices/frient-a-s-scazb-141.json @@ -1122,7 +1122,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, @@ -1151,18 +1151,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 a547d5cea..a1d5b1ea4 100644 --- a/tests/data/devices/frient-a-s-smszb-120.json +++ b/tests/data/devices/frient-a-s-smszb-120.json @@ -807,7 +807,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, @@ -836,18 +836,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 } From 0395cd7eb9a673fdfc56fa174d0348ac0baeab46 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Mar 2026 04:12:38 +0100 Subject: [PATCH 08/11] Add `_cancel_off_listener()` method --- zha/application/platforms/siren.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index 820c9c3a6..476083676 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -141,6 +141,16 @@ def __init__( self._off_listener = None super().__init__(cluster_handlers, endpoint, device, **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( @@ -152,13 +162,7 @@ async def async_turn_off(self) -> None: 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() - - with contextlib.suppress(ValueError): - self._tracked_handles.remove(self._off_listener) - - self._off_listener = None + self._cancel_off_listener() self.maybe_emit_state_changed_event() @@ -220,9 +224,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__ ) @@ -308,9 +310,7 @@ async def async_turn_on( volume_level: int | None = None, ) -> None: """Turn on siren with fixed tone, level, and strobe.""" - if self._off_listener: - self._off_listener.cancel() - self._off_listener = None + 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 From 5bccba9cc9779825b36b2abeb8cf278b111a03b0 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Mar 2026 04:13:44 +0100 Subject: [PATCH 09/11] Move `legacy_discovery_unique_id` calculation to `BaseZclSiren` --- zha/application/platforms/siren.py | 33 +++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index 476083676..2035ff67a 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -139,7 +139,22 @@ def __init__( """Init ZCL siren base.""" self._cluster_handler = cast(IasWdClusterHandler, cluster_handlers[0]) self._off_listener = None - super().__init__(cluster_handlers, endpoint, device, **kwargs) + + legacy_discovery_unique_id = ( + f"{endpoint.device.ieee}-{endpoint.id}" + if ( + endpoint.zigpy_endpoint.device_type == zha.DeviceType.IAS_WARNING_DEVICE + ) + else f"{endpoint.device.ieee}-{endpoint.id}-{int(IasWd.cluster_id)}" + ) + + super().__init__( + cluster_handlers, + endpoint, + device, + legacy_discovery_unique_id=legacy_discovery_unique_id, + **kwargs, + ) def _cancel_off_listener(self) -> None: """Cancel and clean up the off listener.""" @@ -186,21 +201,7 @@ def __init__( **kwargs: Any, ) -> None: """Init this siren.""" - legacy_discovery_unique_id = ( - f"{endpoint.device.ieee}-{endpoint.id}" - if ( - endpoint.zigpy_endpoint.device_type == zha.DeviceType.IAS_WARNING_DEVICE - ) - else f"{endpoint.device.ieee}-{endpoint.id}-{int(IasWd.cluster_id)}" - ) - - super().__init__( - cluster_handlers, - endpoint, - device, - **kwargs, - legacy_discovery_unique_id=legacy_discovery_unique_id, - ) + super().__init__(cluster_handlers, endpoint, device, **kwargs) self._attr_supported_features = ( SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF From 5d9f844dcb6f2487de692f4bc6d219fc29a40baf Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Mar 2026 04:17:19 +0100 Subject: [PATCH 10/11] Also cancel off listener when manually turning off during timer --- zha/application/platforms/siren.py | 1 + 1 file changed, 1 insertion(+) diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index 2035ff67a..f5e4185a5 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -168,6 +168,7 @@ def _cancel_off_listener(self) -> None: async def async_turn_off(self) -> None: """Turn off siren.""" + self._cancel_off_listener() await self._cluster_handler.issue_start_warning( mode=WARNING_DEVICE_MODE_STOP, strobe=WARNING_DEVICE_STROBE_NO ) From 0edadeeba804e4f2c18a0739c2fc405685288739 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Mar 2026 04:19:22 +0100 Subject: [PATCH 11/11] Cancel off listener later when turning off --- zha/application/platforms/siren.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/application/platforms/siren.py b/zha/application/platforms/siren.py index f5e4185a5..eb2a673fa 100644 --- a/zha/application/platforms/siren.py +++ b/zha/application/platforms/siren.py @@ -168,10 +168,10 @@ def _cancel_off_listener(self) -> None: async def async_turn_off(self) -> None: """Turn off siren.""" - self._cancel_off_listener() 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()