Skip to content

Commit d92fa7f

Browse files
authored
Move more logic from entity to wrapper in Tuya alarm (home-assistant#156450)
1 parent 0c45b7f commit d92fa7f

File tree

1 file changed

+96
-86
lines changed

1 file changed

+96
-86
lines changed

homeassistant/components/tuya/alarm_control_panel.py

Lines changed: 96 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
from __future__ import annotations
44

55
from base64 import b64decode
6-
from dataclasses import dataclass
7-
from enum import StrEnum
6+
from typing import Any
87

98
from tuya_sharing import CustomerDevice, Manager
109

@@ -21,52 +20,87 @@
2120
from . import TuyaConfigEntry
2221
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
2322
from .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

72106
async 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

Comments
 (0)