33from __future__ import annotations
44
55from base64 import b64decode
6- from dataclasses import dataclass
7- from enum import StrEnum
6+ from typing import Any
87
98from tuya_sharing import CustomerDevice , Manager
109
2120from . import TuyaConfigEntry
2221from .const import TUYA_DISCOVERY_NEW , DeviceCategory , DPCode
2322from .entity import TuyaEntity
24- from .models import DPCodeEnumWrapper
25- from .util import get_dpcode
23+ from .models import DPCodeBase64Wrapper , DPCodeEnumWrapper
2624
27-
28- @dataclass (frozen = True )
29- class TuyaAlarmControlPanelEntityDescription (AlarmControlPanelEntityDescription ):
30- """Describe a Tuya Alarm Control Panel entity."""
31-
32- master_state : DPCode | None = None
33- alarm_msg : DPCode | None = None
34-
35-
36- class Mode (StrEnum ):
37- """Alarm modes."""
38-
39- ARM = "arm"
40- DISARMED = "disarmed"
41- HOME = "home"
42- SOS = "sos"
25+ ALARM : dict [DeviceCategory , tuple [AlarmControlPanelEntityDescription , ...]] = {
26+ DeviceCategory .MAL : (
27+ AlarmControlPanelEntityDescription (
28+ key = DPCode .MASTER_MODE ,
29+ name = "Alarm" ,
30+ ),
31+ )
32+ }
4333
4434
45- class State ( StrEnum ):
46- """Alarm states."""
35+ class _AlarmChangedByWrapper ( DPCodeBase64Wrapper ):
36+ """Wrapper for changed_by.
4737
48- NORMAL = "normal"
49- ALARM = "alarm "
38+ Decode base64 to utf-16be string, but only if alarm has been triggered.
39+ "" "
5040
41+ def read_device_status (self , device : CustomerDevice ) -> str | None :
42+ """Read the device status."""
43+ if (
44+ device .status .get (DPCode .MASTER_STATE ) != "alarm"
45+ or (data := self .read_bytes (device )) is None
46+ ):
47+ return None
48+ return data .decode ("utf-16be" )
49+
50+
51+ class _AlarmModeWrapper (DPCodeEnumWrapper ):
52+ """Wrapper for the alarm mode of a device.
53+
54+ Handles alarm mode enum values and determines the alarm state,
55+ including logic for detecting when the alarm is triggered and
56+ distinguishing triggered state from battery warnings.
57+ """
58+
59+ _ACTION_MAPPINGS = {
60+ # Home Assistant action => Tuya device mode
61+ "arm_home" : "home" ,
62+ "arm_away" : "arm" ,
63+ "disarm" : "disarmed" ,
64+ "trigger" : "sos" ,
65+ }
66+ _STATE_MAPPINGS = {
67+ # Tuya device mode => Home Assistant panel state
68+ "disarmed" : AlarmControlPanelState .DISARMED ,
69+ "arm" : AlarmControlPanelState .ARMED_AWAY ,
70+ "home" : AlarmControlPanelState .ARMED_HOME ,
71+ "sos" : AlarmControlPanelState .TRIGGERED ,
72+ }
73+
74+ def read_panel_state (self , device : CustomerDevice ) -> AlarmControlPanelState | None :
75+ """Read the device status."""
76+ # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
77+ # The 'mode' doesn't change, and stays as 'arm' or 'home'.
78+ if device .status .get (DPCode .MASTER_STATE ) == "alarm" :
79+ # Only report as triggered if NOT a battery warning
80+ if not (
81+ (encoded_msg := device .status .get (DPCode .ALARM_MSG ))
82+ and (decoded_message := b64decode (encoded_msg ).decode ("utf-16be" ))
83+ and "Sensor Low Battery" in decoded_message
84+ ):
85+ return AlarmControlPanelState .TRIGGERED
5186
52- STATE_MAPPING : dict [str , AlarmControlPanelState ] = {
53- Mode .DISARMED : AlarmControlPanelState .DISARMED ,
54- Mode .ARM : AlarmControlPanelState .ARMED_AWAY ,
55- Mode .HOME : AlarmControlPanelState .ARMED_HOME ,
56- Mode .SOS : AlarmControlPanelState .TRIGGERED ,
57- }
87+ if (status := self .read_device_status (device )) is None :
88+ return None
89+ return self ._STATE_MAPPINGS .get (status )
5890
91+ def supports_action (self , action : str ) -> bool :
92+ """Return if action is supported."""
93+ return (
94+ mapped_value := self ._ACTION_MAPPINGS .get (action )
95+ ) is not None and mapped_value in self .type_information .range
5996
60- ALARM : dict [DeviceCategory , tuple [TuyaAlarmControlPanelEntityDescription , ...]] = {
61- DeviceCategory .MAL : (
62- TuyaAlarmControlPanelEntityDescription (
63- key = DPCode .MASTER_MODE ,
64- master_state = DPCode .MASTER_STATE ,
65- alarm_msg = DPCode .ALARM_MSG ,
66- name = "Alarm" ,
67- ),
68- )
69- }
97+ def _convert_value_to_raw_value (self , device : CustomerDevice , value : Any ) -> Any :
98+ """Convert value to raw value."""
99+ if (
100+ mapped_value := self ._ACTION_MAPPINGS .get (value )
101+ ) is not None and mapped_value in self .type_information .range :
102+ return mapped_value
103+ raise ValueError (f"Unsupported value { value } for { self .dpcode } " )
70104
71105
72106async def async_setup_entry (
@@ -89,15 +123,15 @@ def async_discover_device(device_ids: list[str]) -> None:
89123 device ,
90124 manager ,
91125 description ,
92- action_dpcode_wrapper = action_dpcode_wrapper ,
93- state_dpcode_wrapper = DPCodeEnumWrapper .find_dpcode (
94- device , description . master_state
126+ mode_wrapper = mode_wrapper ,
127+ changed_by_wrapper = _AlarmChangedByWrapper .find_dpcode (
128+ device , DPCode . ALARM_MSG
95129 ),
96130 )
97131 for description in descriptions
98132 if (
99- action_dpcode_wrapper := DPCodeEnumWrapper .find_dpcode (
100- device , description . key , prefer_function = True
133+ mode_wrapper := _AlarmModeWrapper .find_dpcode (
134+ device , DPCode . MASTER_MODE , prefer_function = True
101135 )
102136 )
103137 )
@@ -115,79 +149,55 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
115149
116150 _attr_name = None
117151 _attr_code_arm_required = False
118- _alarm_msg_dpcode : DPCode | None = None
119152
120153 def __init__ (
121154 self ,
122155 device : CustomerDevice ,
123156 device_manager : Manager ,
124- description : TuyaAlarmControlPanelEntityDescription ,
157+ description : AlarmControlPanelEntityDescription ,
125158 * ,
126- action_dpcode_wrapper : DPCodeEnumWrapper ,
127- state_dpcode_wrapper : DPCodeEnumWrapper | None ,
159+ mode_wrapper : _AlarmModeWrapper ,
160+ changed_by_wrapper : _AlarmChangedByWrapper | None ,
128161 ) -> None :
129162 """Init Tuya Alarm."""
130163 super ().__init__ (device , device_manager )
131164 self .entity_description = description
132165 self ._attr_unique_id = f"{ super ().unique_id } { description .key } "
133- self ._action_dpcode_wrapper = action_dpcode_wrapper
134- self ._state_dpcode_wrapper = state_dpcode_wrapper
166+ self ._mode_wrapper = mode_wrapper
167+ self ._changed_by_wrapper = changed_by_wrapper
135168
136- # Determine supported modes
137- if Mode . HOME in action_dpcode_wrapper . type_information . range :
169+ # Determine supported modes
170+ if mode_wrapper . supports_action ( "arm_home" ) :
138171 self ._attr_supported_features |= AlarmControlPanelEntityFeature .ARM_HOME
139- if Mode . ARM in action_dpcode_wrapper . type_information . range :
172+ if mode_wrapper . supports_action ( "arm_away" ) :
140173 self ._attr_supported_features |= AlarmControlPanelEntityFeature .ARM_AWAY
141- if Mode . SOS in action_dpcode_wrapper . type_information . range :
174+ if mode_wrapper . supports_action ( "trigger" ) :
142175 self ._attr_supported_features |= AlarmControlPanelEntityFeature .TRIGGER
143176
144- # Determine alarm message
145- if dp_code := get_dpcode (self .device , description .alarm_msg ):
146- self ._alarm_msg_dpcode = dp_code
147-
148177 @property
149178 def alarm_state (self ) -> AlarmControlPanelState | None :
150179 """Return the state of the device."""
151- # When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
152- # The 'mode' doesn't change, and stays as 'arm' or 'home'.
153- if (
154- self ._state_dpcode_wrapper is not None
155- and self .device .status .get (self ._state_dpcode_wrapper .dpcode ) == State .ALARM
156- ):
157- # Only report as triggered if NOT a battery warning
158- if (
159- changed_by := self .changed_by
160- ) is None or "Sensor Low Battery" not in changed_by :
161- return AlarmControlPanelState .TRIGGERED
162-
163- if not (status := self .device .status .get (self .entity_description .key )):
164- return None
165- return STATE_MAPPING .get (status )
180+ return self ._mode_wrapper .read_panel_state (self .device )
166181
167182 @property
168183 def changed_by (self ) -> str | None :
169184 """Last change triggered by."""
170- if (
171- self ._state_dpcode_wrapper is not None
172- and self ._alarm_msg_dpcode is not None
173- and self .device .status .get (self ._state_dpcode_wrapper .dpcode ) == State .ALARM
174- and (encoded_msg := self .device .status .get (self ._alarm_msg_dpcode ))
175- ):
176- return b64decode (encoded_msg ).decode ("utf-16be" )
177- return None
185+ if self ._changed_by_wrapper is None :
186+ return None
187+ return self ._changed_by_wrapper .read_device_status (self .device )
178188
179189 async def async_alarm_disarm (self , code : str | None = None ) -> None :
180190 """Send Disarm command."""
181- await self ._async_send_dpcode_update (self ._action_dpcode_wrapper , Mode . DISARMED )
191+ await self ._async_send_dpcode_update (self ._mode_wrapper , "disarm" )
182192
183193 async def async_alarm_arm_home (self , code : str | None = None ) -> None :
184194 """Send Home command."""
185- await self ._async_send_dpcode_update (self ._action_dpcode_wrapper , Mode . HOME )
195+ await self ._async_send_dpcode_update (self ._mode_wrapper , "arm_home" )
186196
187197 async def async_alarm_arm_away (self , code : str | None = None ) -> None :
188198 """Send Arm command."""
189- await self ._async_send_dpcode_update (self ._action_dpcode_wrapper , Mode . ARM )
199+ await self ._async_send_dpcode_update (self ._mode_wrapper , "arm_away" )
190200
191201 async def async_alarm_trigger (self , code : str | None = None ) -> None :
192202 """Send SOS command."""
193- await self ._async_send_dpcode_update (self ._action_dpcode_wrapper , Mode . SOS )
203+ await self ._async_send_dpcode_update (self ._mode_wrapper , "trigger" )
0 commit comments