Home Assistant Unofficial Reference 2024.12.1
alarm_control_panel.py
Go to the documentation of this file.
1 """Support for manual alarms controllable via MQTT."""
2 
3 from __future__ import annotations
4 
5 import datetime
6 import logging
7 from typing import Any
8 
9 import voluptuous as vol
10 
11 from homeassistant.components import mqtt
13  AlarmControlPanelEntity,
14  AlarmControlPanelEntityFeature,
15  AlarmControlPanelState,
16  CodeFormat,
17 )
18 from homeassistant.const import (
19  CONF_CODE,
20  CONF_DELAY_TIME,
21  CONF_DISARM_AFTER_TRIGGER,
22  CONF_NAME,
23  CONF_PENDING_TIME,
24  CONF_PLATFORM,
25  CONF_TRIGGER_TIME,
26 )
27 from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
28 from homeassistant.exceptions import HomeAssistantError
30 from homeassistant.helpers.entity_platform import AddEntitiesCallback
31 from homeassistant.helpers.event import (
32  async_track_point_in_time,
33  async_track_state_change_event,
34 )
35 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
36 import homeassistant.util.dt as dt_util
37 
38 _LOGGER = logging.getLogger(__name__)
39 
40 CONF_CODE_TEMPLATE = "code_template"
41 CONF_CODE_ARM_REQUIRED = "code_arm_required"
42 
43 CONF_PAYLOAD_DISARM = "payload_disarm"
44 CONF_PAYLOAD_ARM_HOME = "payload_arm_home"
45 CONF_PAYLOAD_ARM_AWAY = "payload_arm_away"
46 CONF_PAYLOAD_ARM_NIGHT = "payload_arm_night"
47 CONF_PAYLOAD_ARM_VACATION = "payload_arm_vacation"
48 CONF_PAYLOAD_ARM_CUSTOM_BYPASS = "payload_arm_custom_bypass"
49 
50 CONF_ALARM_ARMED_AWAY = "armed_away"
51 CONF_ALARM_ARMED_CUSTOM_BYPASS = "armed_custom_bypass"
52 CONF_ALARM_ARMED_HOME = "armed_home"
53 CONF_ALARM_ARMED_NIGHT = "armed_night"
54 CONF_ALARM_ARMED_VACATION = "armed_vacation"
55 CONF_ALARM_DISARMED = "disarmed"
56 CONF_ALARM_PENDING = "pending"
57 CONF_ALARM_TRIGGERED = "triggered"
58 
59 DEFAULT_ALARM_NAME = "HA Alarm"
60 DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0)
61 DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60)
62 DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120)
63 DEFAULT_DISARM_AFTER_TRIGGER = False
64 DEFAULT_ARM_AWAY = "ARM_AWAY"
65 DEFAULT_ARM_HOME = "ARM_HOME"
66 DEFAULT_ARM_NIGHT = "ARM_NIGHT"
67 DEFAULT_ARM_VACATION = "ARM_VACATION"
68 DEFAULT_ARM_CUSTOM_BYPASS = "ARM_CUSTOM_BYPASS"
69 DEFAULT_DISARM = "DISARM"
70 
71 SUPPORTED_STATES = [
72  AlarmControlPanelState.DISARMED,
73  AlarmControlPanelState.ARMED_AWAY,
74  AlarmControlPanelState.ARMED_HOME,
75  AlarmControlPanelState.ARMED_NIGHT,
76  AlarmControlPanelState.ARMED_VACATION,
77  AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
78  AlarmControlPanelState.TRIGGERED,
79 ]
80 
81 SUPPORTED_PRETRIGGER_STATES = [
82  state for state in SUPPORTED_STATES if state != AlarmControlPanelState.TRIGGERED
83 ]
84 
85 SUPPORTED_PENDING_STATES = [
86  state for state in SUPPORTED_STATES if state != AlarmControlPanelState.DISARMED
87 ]
88 
89 ATTR_PRE_PENDING_STATE = "pre_pending_state"
90 ATTR_POST_PENDING_STATE = "post_pending_state"
91 
92 
93 def _state_validator(config):
94  """Validate the state."""
95  for state in SUPPORTED_PRETRIGGER_STATES:
96  if CONF_DELAY_TIME not in config[state]:
97  config[state] = config[state] | {CONF_DELAY_TIME: config[CONF_DELAY_TIME]}
98  if CONF_TRIGGER_TIME not in config[state]:
99  config[state] = config[state] | {
100  CONF_TRIGGER_TIME: config[CONF_TRIGGER_TIME]
101  }
102  for state in SUPPORTED_PENDING_STATES:
103  if CONF_PENDING_TIME not in config[state]:
104  config[state] = config[state] | {
105  CONF_PENDING_TIME: config[CONF_PENDING_TIME]
106  }
107 
108  return config
109 
110 
111 def _state_schema(state):
112  """Validate the state."""
113  schema = {}
114  if state in SUPPORTED_PRETRIGGER_STATES:
115  schema[vol.Optional(CONF_DELAY_TIME)] = vol.All(
116  cv.time_period, cv.positive_timedelta
117  )
118  schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All(
119  cv.time_period, cv.positive_timedelta
120  )
121  if state in SUPPORTED_PENDING_STATES:
122  schema[vol.Optional(CONF_PENDING_TIME)] = vol.All(
123  cv.time_period, cv.positive_timedelta
124  )
125  return vol.Schema(schema)
126 
127 
128 PLATFORM_SCHEMA = vol.Schema(
129  vol.All(
130  mqtt.config.MQTT_BASE_SCHEMA.extend(
131  {
132  vol.Required(CONF_PLATFORM): "manual_mqtt",
133  vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
134  vol.Exclusive(CONF_CODE, "code validation"): cv.string,
135  vol.Exclusive(CONF_CODE_TEMPLATE, "code validation"): cv.template,
136  vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME): vol.All(
137  cv.time_period, cv.positive_timedelta
138  ),
139  vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME): vol.All(
140  cv.time_period, cv.positive_timedelta
141  ),
142  vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME): vol.All(
143  cv.time_period, cv.positive_timedelta
144  ),
145  vol.Optional(
146  CONF_DISARM_AFTER_TRIGGER, default=DEFAULT_DISARM_AFTER_TRIGGER
147  ): cv.boolean,
148  vol.Optional(CONF_ALARM_ARMED_AWAY, default={}): _state_schema(
149  AlarmControlPanelState.ARMED_AWAY
150  ),
151  vol.Optional(CONF_ALARM_ARMED_HOME, default={}): _state_schema(
152  AlarmControlPanelState.ARMED_HOME
153  ),
154  vol.Optional(CONF_ALARM_ARMED_NIGHT, default={}): _state_schema(
155  AlarmControlPanelState.ARMED_NIGHT
156  ),
157  vol.Optional(CONF_ALARM_ARMED_VACATION, default={}): _state_schema(
158  AlarmControlPanelState.ARMED_VACATION
159  ),
160  vol.Optional(CONF_ALARM_ARMED_CUSTOM_BYPASS, default={}): _state_schema(
161  AlarmControlPanelState.ARMED_CUSTOM_BYPASS
162  ),
163  vol.Optional(CONF_ALARM_DISARMED, default={}): _state_schema(
164  AlarmControlPanelState.DISARMED
165  ),
166  vol.Optional(CONF_ALARM_TRIGGERED, default={}): _state_schema(
167  AlarmControlPanelState.TRIGGERED
168  ),
169  vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
170  vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
171  vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean,
172  vol.Optional(
173  CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY
174  ): cv.string,
175  vol.Optional(
176  CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME
177  ): cv.string,
178  vol.Optional(
179  CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT
180  ): cv.string,
181  vol.Optional(
182  CONF_PAYLOAD_ARM_VACATION, default=DEFAULT_ARM_VACATION
183  ): cv.string,
184  vol.Optional(
185  CONF_PAYLOAD_ARM_CUSTOM_BYPASS, default=DEFAULT_ARM_CUSTOM_BYPASS
186  ): cv.string,
187  vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string,
188  }
189  ),
190  _state_validator,
191  )
192 )
193 
194 
196  hass: HomeAssistant,
197  config: ConfigType,
198  add_entities: AddEntitiesCallback,
199  discovery_info: DiscoveryInfoType | None = None,
200 ) -> None:
201  """Set up the manual MQTT alarm platform."""
202  # Make sure MQTT integration is enabled and the client is available
203  # We cannot count on dependencies as the alarm_control_panel platform setup
204  # also will be triggered when mqtt is loading the `alarm_control_panel` platform
205  if not await mqtt.async_wait_for_mqtt_client(hass):
206  _LOGGER.error("MQTT integration is not available")
207  return
208  add_entities(
209  [
211  hass,
212  config[CONF_NAME],
213  config.get(CONF_CODE),
214  config.get(CONF_CODE_TEMPLATE),
215  config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER),
216  config.get(mqtt.CONF_STATE_TOPIC),
217  config.get(mqtt.CONF_COMMAND_TOPIC),
218  config.get(mqtt.CONF_QOS),
219  config.get(CONF_CODE_ARM_REQUIRED),
220  config.get(CONF_PAYLOAD_DISARM),
221  config.get(CONF_PAYLOAD_ARM_HOME),
222  config.get(CONF_PAYLOAD_ARM_AWAY),
223  config.get(CONF_PAYLOAD_ARM_NIGHT),
224  config.get(CONF_PAYLOAD_ARM_VACATION),
225  config.get(CONF_PAYLOAD_ARM_CUSTOM_BYPASS),
226  config,
227  )
228  ]
229  )
230 
231 
233  """Representation of an alarm status.
234 
235  When armed, will be pending for 'pending_time', after that armed.
236  When triggered, will be pending for the triggering state's 'delay_time'
237  plus the triggered state's 'pending_time'.
238  After that will be triggered for 'trigger_time', after that we return to
239  the previous state or disarm if `disarm_after_trigger` is true.
240  A trigger_time of zero disables the alarm_trigger service.
241  """
242 
243  _attr_should_poll = False
244  _attr_supported_features = (
245  AlarmControlPanelEntityFeature.ARM_HOME
246  | AlarmControlPanelEntityFeature.ARM_AWAY
247  | AlarmControlPanelEntityFeature.ARM_NIGHT
248  | AlarmControlPanelEntityFeature.ARM_VACATION
249  | AlarmControlPanelEntityFeature.TRIGGER
250  | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
251  )
252 
253  def __init__(
254  self,
255  hass,
256  name,
257  code,
258  code_template,
259  disarm_after_trigger,
260  state_topic,
261  command_topic,
262  qos,
263  code_arm_required,
264  payload_disarm,
265  payload_arm_home,
266  payload_arm_away,
267  payload_arm_night,
268  payload_arm_vacation,
269  payload_arm_custom_bypass,
270  config,
271  ):
272  """Init the manual MQTT alarm panel."""
273  self._state_state = AlarmControlPanelState.DISARMED
274  self._hass_hass = hass
275  self._attr_name_attr_name = name
276  if code_template:
277  self._code_code = code_template
278  else:
279  self._code_code = code or None
280  self._disarm_after_trigger_disarm_after_trigger = disarm_after_trigger
281  self._previous_state_previous_state = self._state_state
282  self._state_ts_state_ts = None
283 
284  self._delay_time_by_state_delay_time_by_state = {
285  state: config[state][CONF_DELAY_TIME]
286  for state in SUPPORTED_PRETRIGGER_STATES
287  }
288  self._trigger_time_by_state_trigger_time_by_state = {
289  state: config[state][CONF_TRIGGER_TIME]
290  for state in SUPPORTED_PRETRIGGER_STATES
291  }
292  self._pending_time_by_state_pending_time_by_state = {
293  state: config[state][CONF_PENDING_TIME]
294  for state in SUPPORTED_PENDING_STATES
295  }
296 
297  self._state_topic_state_topic = state_topic
298  self._command_topic_command_topic = command_topic
299  self._qos_qos = qos
300  self._attr_code_arm_required_attr_code_arm_required = code_arm_required
301  self._payload_disarm_payload_disarm = payload_disarm
302  self._payload_arm_home_payload_arm_home = payload_arm_home
303  self._payload_arm_away_payload_arm_away = payload_arm_away
304  self._payload_arm_night_payload_arm_night = payload_arm_night
305  self._payload_arm_vacation_payload_arm_vacation = payload_arm_vacation
306  self._payload_arm_custom_bypass_payload_arm_custom_bypass = payload_arm_custom_bypass
307 
308  @property
309  def alarm_state(self) -> AlarmControlPanelState:
310  """Return the state of the device."""
311  if self._state_state == AlarmControlPanelState.TRIGGERED:
312  if self._within_pending_time_within_pending_time(self._state_state):
313  return AlarmControlPanelState.PENDING
314  trigger_time = self._trigger_time_by_state_trigger_time_by_state[self._previous_state_previous_state]
315  if (
316  self._state_ts_state_ts + self._pending_time_pending_time(self._state_state) + trigger_time
317  ) < dt_util.utcnow():
318  if self._disarm_after_trigger_disarm_after_trigger:
319  return AlarmControlPanelState.DISARMED
320  self._state_state = self._previous_state_previous_state
321  return self._state_state
322 
323  if self._state_state in SUPPORTED_PENDING_STATES and self._within_pending_time_within_pending_time(
324  self._state_state
325  ):
326  return AlarmControlPanelState.PENDING
327 
328  return self._state_state
329 
330  @property
331  def _active_state(self):
332  """Get the current state."""
333  if self.statestatestatestate == AlarmControlPanelState.PENDING:
334  return self._previous_state_previous_state
335  return self._state_state
336 
337  def _pending_time(self, state):
338  """Get the pending time."""
339  pending_time = self._pending_time_by_state_pending_time_by_state[state]
340  if state == AlarmControlPanelState.TRIGGERED:
341  pending_time += self._delay_time_by_state_delay_time_by_state[self._previous_state_previous_state]
342  return pending_time
343 
344  def _within_pending_time(self, state):
345  """Get if the action is in the pending time window."""
346  return self._state_ts_state_ts + self._pending_time_pending_time(state) > dt_util.utcnow()
347 
348  @property
349  def code_format(self) -> CodeFormat | None:
350  """Return one or more digits/characters."""
351  if self._code_code is None:
352  return None
353  if isinstance(self._code_code, str) and self._code_code.isdigit():
354  return CodeFormat.NUMBER
355  return CodeFormat.TEXT
356 
357  async def async_alarm_disarm(self, code: str | None = None) -> None:
358  """Send disarm command."""
359  self._async_validate_code_async_validate_code(code, AlarmControlPanelState.DISARMED)
360  self._state_state = AlarmControlPanelState.DISARMED
361  self._state_ts_state_ts = dt_util.utcnow()
362  self.async_write_ha_stateasync_write_ha_state()
363 
364  async def async_alarm_arm_home(self, code: str | None = None) -> None:
365  """Send arm home command."""
366  self._async_validate_code_async_validate_code(code, AlarmControlPanelState.ARMED_HOME)
367  self._async_update_state_async_update_state(AlarmControlPanelState.ARMED_HOME)
368 
369  async def async_alarm_arm_away(self, code: str | None = None) -> None:
370  """Send arm away command."""
371  self._async_validate_code_async_validate_code(code, AlarmControlPanelState.ARMED_AWAY)
372  self._async_update_state_async_update_state(AlarmControlPanelState.ARMED_AWAY)
373 
374  async def async_alarm_arm_night(self, code: str | None = None) -> None:
375  """Send arm night command."""
376  self._async_validate_code_async_validate_code(code, AlarmControlPanelState.ARMED_NIGHT)
377  self._async_update_state_async_update_state(AlarmControlPanelState.ARMED_NIGHT)
378 
379  async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
380  """Send arm vacation command."""
381  self._async_validate_code_async_validate_code(code, AlarmControlPanelState.ARMED_VACATION)
382  self._async_update_state_async_update_state(AlarmControlPanelState.ARMED_VACATION)
383 
384  async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
385  """Send arm custom bypass command."""
386  self._async_validate_code_async_validate_code(code, AlarmControlPanelState.ARMED_CUSTOM_BYPASS)
387  self._async_update_state_async_update_state(AlarmControlPanelState.ARMED_CUSTOM_BYPASS)
388 
389  async def async_alarm_trigger(self, code: str | None = None) -> None:
390  """Send alarm trigger command.
391 
392  No code needed, a trigger time of zero for the current state
393  disables the alarm.
394  """
395  if not self._trigger_time_by_state_trigger_time_by_state[self._active_state_active_state]:
396  return
397  self._async_update_state_async_update_state(AlarmControlPanelState.TRIGGERED)
398 
399  def _async_update_state(self, state: str) -> None:
400  """Update the state."""
401  if self._state_state == state:
402  return
403 
404  self._previous_state_previous_state = self._state_state
405  self._state_state = state
406  self._state_ts_state_ts = dt_util.utcnow()
407  self.async_write_ha_stateasync_write_ha_state()
408 
409  pending_time = self._pending_time_pending_time(state)
410  if state == AlarmControlPanelState.TRIGGERED:
412  self._hass_hass, self.async_scheduled_updateasync_scheduled_update, self._state_ts_state_ts + pending_time
413  )
414 
415  trigger_time = self._trigger_time_by_state_trigger_time_by_state[self._previous_state_previous_state]
417  self._hass_hass,
418  self.async_scheduled_updateasync_scheduled_update,
419  self._state_ts_state_ts + pending_time + trigger_time,
420  )
421  elif state in SUPPORTED_PENDING_STATES and pending_time:
423  self._hass_hass, self.async_scheduled_updateasync_scheduled_update, self._state_ts_state_ts + pending_time
424  )
425 
426  def _async_validate_code(self, code, state):
427  """Validate given code."""
428  if (
429  state != AlarmControlPanelState.DISARMED and not self.code_arm_requiredcode_arm_required
430  ) or self._code_code is None:
431  return
432 
433  if isinstance(self._code_code, str):
434  alarm_code = self._code_code
435  else:
436  alarm_code = self._code_code.async_render(
437  from_state=self._state_state, to_state=state, parse_result=False
438  )
439 
440  if not alarm_code or code == alarm_code:
441  return
442 
443  raise HomeAssistantError("Invalid alarm code provided")
444 
445  @property
446  def extra_state_attributes(self) -> dict[str, Any]:
447  """Return the state attributes."""
448  if self.statestatestatestate != AlarmControlPanelState.PENDING:
449  return {}
450  return {
451  ATTR_PRE_PENDING_STATE: self._previous_state_previous_state,
452  ATTR_POST_PENDING_STATE: self._state_state,
453  }
454 
455  @callback
456  def async_scheduled_update(self, now):
457  """Update state at a scheduled point in time."""
458  self.async_write_ha_stateasync_write_ha_state()
459 
460  async def async_added_to_hass(self) -> None:
461  """Subscribe to MQTT events."""
463  self.hasshass, [self.entity_identity_id], self._async_state_changed_listener_async_state_changed_listener
464  )
465 
466  async def message_received(msg):
467  """Run when new MQTT message has been received."""
468  if msg.payload == self._payload_disarm_payload_disarm:
469  await self.async_alarm_disarmasync_alarm_disarmasync_alarm_disarm(self._code_code)
470  elif msg.payload == self._payload_arm_home_payload_arm_home:
471  await self.async_alarm_arm_homeasync_alarm_arm_homeasync_alarm_arm_home(self._code_code)
472  elif msg.payload == self._payload_arm_away_payload_arm_away:
473  await self.async_alarm_arm_awayasync_alarm_arm_awayasync_alarm_arm_away(self._code_code)
474  elif msg.payload == self._payload_arm_night_payload_arm_night:
475  await self.async_alarm_arm_nightasync_alarm_arm_nightasync_alarm_arm_night(self._code_code)
476  elif msg.payload == self._payload_arm_vacation_payload_arm_vacation:
477  await self.async_alarm_arm_vacationasync_alarm_arm_vacationasync_alarm_arm_vacation(self._code_code)
478  elif msg.payload == self._payload_arm_custom_bypass_payload_arm_custom_bypass:
479  await self.async_alarm_arm_custom_bypassasync_alarm_arm_custom_bypassasync_alarm_arm_custom_bypass(self._code_code)
480  else:
481  _LOGGER.warning("Received unexpected payload: %s", msg.payload)
482  return
483 
484  await mqtt.async_subscribe(
485  self.hasshass, self._command_topic_command_topic, message_received, self._qos_qos
486  )
487 
489  self, event: Event[EventStateChangedData]
490  ) -> None:
491  """Publish state change to MQTT."""
492  if (new_state := event.data["new_state"]) is None:
493  return
494  await mqtt.async_publish(
495  self.hasshass, self._state_topic_state_topic, new_state.state, self._qos_qos, True
496  )
def __init__(self, hass, name, code, code_template, disarm_after_trigger, state_topic, command_topic, qos, code_arm_required, payload_disarm, payload_arm_home, payload_arm_away, payload_arm_night, payload_arm_vacation, payload_arm_custom_bypass, config)
None _async_state_changed_listener(self, Event[EventStateChangedData] event)
None add_entities(AsusWrtRouter router, AddEntitiesCallback async_add_entities, set[str] tracked)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)
Definition: event.py:314
CALLBACK_TYPE async_track_point_in_time(HomeAssistant hass, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action, datetime point_in_time)
Definition: event.py:1462