Home Assistant Unofficial Reference 2024.12.1
device_trigger.py
Go to the documentation of this file.
1 """Provides device automations for MQTT."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 from dataclasses import dataclass, field
7 import logging
8 from typing import TYPE_CHECKING, Any
9 
10 import voluptuous as vol
11 
12 from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
13 from homeassistant.config_entries import ConfigEntry
14 from homeassistant.const import (
15  CONF_DEVICE,
16  CONF_DEVICE_ID,
17  CONF_DOMAIN,
18  CONF_PLATFORM,
19  CONF_TYPE,
20  CONF_VALUE_TEMPLATE,
21 )
22 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
23 from homeassistant.exceptions import HomeAssistantError
24 from homeassistant.helpers import config_validation as cv
25 from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
26 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
27 
28 from . import debug_info, trigger as mqtt_trigger
29 from .config import MQTT_BASE_SCHEMA
30 from .const import (
31  ATTR_DISCOVERY_HASH,
32  CONF_ENCODING,
33  CONF_PAYLOAD,
34  CONF_QOS,
35  CONF_TOPIC,
36  DOMAIN,
37 )
38 from .discovery import MQTTDiscoveryPayload, clear_discovery_hash
39 from .entity import MqttDiscoveryDeviceUpdateMixin, send_discovery_done, update_device
40 from .models import DATA_MQTT
41 from .schemas import MQTT_ENTITY_DEVICE_INFO_SCHEMA
42 
43 _LOGGER = logging.getLogger(__name__)
44 
45 CONF_AUTOMATION_TYPE = "automation_type"
46 CONF_DISCOVERY_ID = "discovery_id"
47 CONF_SUBTYPE = "subtype"
48 DEFAULT_ENCODING = "utf-8"
49 DEVICE = "device"
50 
51 MQTT_TRIGGER_BASE = {
52  # Trigger when MQTT message is received
53  CONF_PLATFORM: DEVICE,
54  CONF_DOMAIN: DOMAIN,
55 }
56 
57 TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
58  {
59  vol.Required(CONF_PLATFORM): DEVICE,
60  vol.Required(CONF_DOMAIN): DOMAIN,
61  vol.Required(CONF_DEVICE_ID): str,
62  # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2.
63  # By default, a MQTT device trigger now will be referenced by
64  # device_id, type and subtype instead.
65  vol.Optional(CONF_DISCOVERY_ID): str,
66  vol.Required(CONF_TYPE): cv.string,
67  vol.Required(CONF_SUBTYPE): cv.string,
68  },
69 )
70 
71 TRIGGER_DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend(
72  {
73  vol.Required(CONF_AUTOMATION_TYPE): str,
74  vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA,
75  vol.Optional(CONF_PAYLOAD, default=None): vol.Any(None, cv.string),
76  vol.Required(CONF_SUBTYPE): cv.string,
77  vol.Required(CONF_TOPIC): cv.string,
78  vol.Required(CONF_TYPE): cv.string,
79  vol.Optional(CONF_VALUE_TEMPLATE, default=None): vol.Any(None, cv.string),
80  },
81  extra=vol.REMOVE_EXTRA,
82 )
83 
84 LOG_NAME = "Device trigger"
85 
86 
87 @dataclass(slots=True)
89  """Attached trigger settings."""
90 
91  action: TriggerActionType
92  trigger_info: TriggerInfo
93  trigger: Trigger
94  remove: CALLBACK_TYPE | None = None
95 
96  async def async_attach_trigger(self) -> None:
97  """Attach MQTT trigger."""
98  mqtt_config: dict[str, Any] = {
99  CONF_PLATFORM: DOMAIN,
100  CONF_TOPIC: self.trigger.topic,
101  CONF_ENCODING: DEFAULT_ENCODING,
102  CONF_QOS: self.trigger.qos,
103  }
104  if self.trigger.payload:
105  mqtt_config[CONF_PAYLOAD] = self.trigger.payload
106  if self.trigger.value_template:
107  mqtt_config[CONF_VALUE_TEMPLATE] = self.trigger.value_template
108  mqtt_config = mqtt_trigger.TRIGGER_SCHEMA(mqtt_config)
109 
110  if self.removeremove:
111  self.removeremove()
112  self.removeremove = await mqtt_trigger.async_attach_trigger(
113  self.trigger.hass,
114  mqtt_config,
115  self.action,
116  self.trigger_info,
117  )
118 
119 
120 @dataclass(slots=True, kw_only=True)
121 class Trigger:
122  """Device trigger settings."""
123 
124  device_id: str
125  discovery_data: DiscoveryInfoType | None = None
126  discovery_id: str | None = None
127  hass: HomeAssistant
128  payload: str | None
129  qos: int | None
130  subtype: str
131  topic: str | None
132  type: str
133  value_template: str | None
134  trigger_instances: list[TriggerInstance] = field(default_factory=list)
135 
136  async def add_trigger(
137  self, action: TriggerActionType, trigger_info: TriggerInfo
138  ) -> Callable[[], None]:
139  """Add MQTT trigger."""
140  instance = TriggerInstance(action, trigger_info, self)
141  self.trigger_instances.append(instance)
142 
143  if self.topictopic is not None:
144  # If we know about the trigger, subscribe to MQTT topic
145  await instance.async_attach_trigger()
146 
147  @callback
148  def async_remove() -> None:
149  """Remove trigger."""
150  if instance not in self.trigger_instances:
151  raise HomeAssistantError("Can't remove trigger twice")
152 
153  if instance.remove:
154  instance.remove()
155  self.trigger_instances.remove(instance)
156 
157  return async_remove
158 
159  async def update_trigger(self, config: ConfigType) -> None:
160  """Update MQTT device trigger."""
161  self.typetype = config[CONF_TYPE]
162  self.subtypesubtype = config[CONF_SUBTYPE]
163  self.payloadpayload = config[CONF_PAYLOAD]
164  self.qosqos = config[CONF_QOS]
165  topic_changed = self.topictopic != config[CONF_TOPIC]
166  self.topictopic = config[CONF_TOPIC]
167  self.value_templatevalue_template = config[CONF_VALUE_TEMPLATE]
168 
169  # Unsubscribe+subscribe if this trigger is in use and topic has changed
170  # If topic is same unsubscribe+subscribe will execute in the wrong order
171  # because unsubscribe is done with help of async_create_task
172  if topic_changed:
173  for trig in self.trigger_instances:
174  await trig.async_attach_trigger()
175 
176  def detach_trigger(self) -> None:
177  """Remove MQTT device trigger."""
178  # Mark trigger as unknown
179  self.topictopic = None
180 
181  # Unsubscribe if this trigger is in use
182  for trig in self.trigger_instances:
183  if trig.remove:
184  trig.remove()
185  trig.remove = None
186 
187 
189  """Setup a MQTT device trigger with auto discovery."""
190 
191  def __init__(
192  self,
193  hass: HomeAssistant,
194  config: ConfigType,
195  device_id: str,
196  discovery_data: DiscoveryInfoType,
197  config_entry: ConfigEntry,
198  ) -> None:
199  """Initialize."""
200  self._config_config = config
201  self._config_entry_config_entry_config_entry = config_entry
202  self.device_iddevice_id = device_id
203  self.discovery_datadiscovery_data = discovery_data
204  self.hasshasshass = hass
205  self._mqtt_data_mqtt_data = hass.data[DATA_MQTT]
206  self.trigger_idtrigger_id = f"{device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}"
207 
208  MqttDiscoveryDeviceUpdateMixin.__init__(
209  self,
210  hass,
211  discovery_data,
212  device_id,
213  config_entry,
214  LOG_NAME,
215  )
216 
217  async def async_setup(self) -> None:
218  """Initialize the device trigger."""
219  discovery_hash = self.discovery_datadiscovery_data[ATTR_DISCOVERY_HASH]
220  discovery_id = discovery_hash[1]
221  # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2.
222  # To make sure old automation keep working we determine the trigger_id
223  # based on the discovery_id if it is set.
224  for trigger_id, trigger in self._mqtt_data_mqtt_data.device_triggers.items():
225  if trigger.discovery_id == discovery_id:
226  self.trigger_idtrigger_id = trigger_id
227  break
228  if self.trigger_idtrigger_id not in self._mqtt_data_mqtt_data.device_triggers:
229  self._mqtt_data_mqtt_data.device_triggers[self.trigger_idtrigger_id] = Trigger(
230  hass=self.hasshasshass,
231  device_id=self.device_iddevice_id,
232  discovery_data=self.discovery_datadiscovery_data,
233  discovery_id=discovery_id,
234  type=self._config_config[CONF_TYPE],
235  subtype=self._config_config[CONF_SUBTYPE],
236  topic=self._config_config[CONF_TOPIC],
237  payload=self._config_config[CONF_PAYLOAD],
238  qos=self._config_config[CONF_QOS],
239  value_template=self._config_config[CONF_VALUE_TEMPLATE],
240  )
241  else:
242  await self._mqtt_data_mqtt_data.device_triggers[self.trigger_idtrigger_id].update_trigger(
243  self._config_config
244  )
245  debug_info.add_trigger_discovery_data(
246  self.hasshasshass, discovery_hash, self.discovery_datadiscovery_data, self.device_iddevice_id
247  )
248 
249  async def async_update(self, discovery_data: MQTTDiscoveryPayload) -> None:
250  """Handle MQTT device trigger discovery updates."""
251  discovery_hash = self.discovery_datadiscovery_data[ATTR_DISCOVERY_HASH]
252  debug_info.update_trigger_discovery_data(
253  self.hasshasshass, discovery_hash, discovery_data
254  )
255  config = TRIGGER_DISCOVERY_SCHEMA(discovery_data)
256  new_trigger_id = f"{self.device_id}_{config[CONF_TYPE]}_{config[CONF_SUBTYPE]}"
257  if new_trigger_id != self.trigger_idtrigger_id:
258  mqtt_data = self.hasshasshass.data[DATA_MQTT]
259  if new_trigger_id in mqtt_data.device_triggers:
260  _LOGGER.error(
261  "Cannot update device trigger %s due to an existing duplicate "
262  "device trigger with the same device_id, "
263  "type and subtype. Got: %s",
264  discovery_hash,
265  config,
266  )
267  return
268  # Update trigger_id based index after update of type or subtype
269  mqtt_data.device_triggers[new_trigger_id] = mqtt_data.device_triggers.pop(
270  self.trigger_idtrigger_id
271  )
272  self.trigger_idtrigger_id = new_trigger_id
273 
274  update_device(self.hasshasshass, self._config_entry_config_entry_config_entry, config)
275  device_trigger: Trigger = self._mqtt_data_mqtt_data.device_triggers[self.trigger_idtrigger_id]
276  await device_trigger.update_trigger(config)
277 
278  async def async_tear_down(self) -> None:
279  """Cleanup device trigger."""
280  discovery_hash = self.discovery_datadiscovery_data[ATTR_DISCOVERY_HASH]
281  if self.trigger_idtrigger_id in self._mqtt_data_mqtt_data.device_triggers:
282  _LOGGER.info("Removing trigger: %s", discovery_hash)
283  trigger: Trigger = self._mqtt_data_mqtt_data.device_triggers[self.trigger_idtrigger_id]
284  trigger.discovery_data = None
285  trigger.detach_trigger()
286  debug_info.remove_trigger_discovery_data(self.hasshasshass, discovery_hash)
287 
288 
290  hass: HomeAssistant,
291  config: ConfigType,
292  config_entry: ConfigEntry,
293  discovery_data: DiscoveryInfoType,
294 ) -> None:
295  """Set up the MQTT device trigger."""
296  config = TRIGGER_DISCOVERY_SCHEMA(config)
297 
298  # We update the device based on the trigger config to obtain the device_id.
299  # In all cases the setup will lead to device entry to be created or updated.
300  # If the trigger is a duplicate, trigger creation will be cancelled but we allow
301  # the device data to be updated to not add additional complexity to the code.
302  device_id = update_device(hass, config_entry, config)
303  discovery_id = discovery_data[ATTR_DISCOVERY_HASH][1]
304  trigger_type = config[CONF_TYPE]
305  trigger_subtype = config[CONF_SUBTYPE]
306  trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}"
307  mqtt_data = hass.data[DATA_MQTT]
308  if (
309  trigger_id in mqtt_data.device_triggers
310  and mqtt_data.device_triggers[trigger_id].discovery_data is not None
311  ):
312  _LOGGER.error(
313  "Config for device trigger %s conflicts with existing "
314  "device trigger, cannot set up trigger, got: %s",
315  discovery_id,
316  config,
317  )
318  send_discovery_done(hass, discovery_data)
319  clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
320  return
321 
322  if TYPE_CHECKING:
323  assert isinstance(device_id, str)
324  mqtt_device_trigger = MqttDeviceTrigger(
325  hass, config, device_id, discovery_data, config_entry
326  )
327  await mqtt_device_trigger.async_setup()
328  send_discovery_done(hass, discovery_data)
329 
330 
331 async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None:
332  """Handle Mqtt removed from a device."""
333  mqtt_data = hass.data[DATA_MQTT]
334  triggers = await async_get_triggers(hass, device_id)
335  for trig in triggers:
336  trigger_id = f"{device_id}_{trig[CONF_TYPE]}_{trig[CONF_SUBTYPE]}"
337  if trigger_id in mqtt_data.device_triggers:
338  device_trigger = mqtt_data.device_triggers.pop(trigger_id)
339  device_trigger.detach_trigger()
340  discovery_data = device_trigger.discovery_data
341  if TYPE_CHECKING:
342  assert discovery_data is not None
343  discovery_hash = discovery_data[ATTR_DISCOVERY_HASH]
344  debug_info.remove_trigger_discovery_data(hass, discovery_hash)
345 
346 
348  hass: HomeAssistant, device_id: str
349 ) -> list[dict[str, str]]:
350  """List device triggers for MQTT devices."""
351  mqtt_data = hass.data[DATA_MQTT]
352 
353  if not mqtt_data.device_triggers:
354  return []
355 
356  return [
357  {
358  **MQTT_TRIGGER_BASE,
359  "device_id": device_id,
360  "type": trig.type,
361  "subtype": trig.subtype,
362  }
363  for trig in mqtt_data.device_triggers.values()
364  if trig.device_id == device_id and trig.topic is not None
365  ]
366 
367 
369  hass: HomeAssistant,
370  config: ConfigType,
371  action: TriggerActionType,
372  trigger_info: TriggerInfo,
373 ) -> CALLBACK_TYPE:
374  """Attach a trigger."""
375  trigger_id: str | None = None
376  mqtt_data = hass.data[DATA_MQTT]
377  device_id = config[CONF_DEVICE_ID]
378 
379  # The use of CONF_DISCOVERY_ID was deprecated in HA Core 2024.2.
380  # In case CONF_DISCOVERY_ID is still used in an automation,
381  # we reference the device trigger by discovery_id instead of
382  # referencing it by device_id, type and subtype, which is the default.
383  discovery_id: str | None = config.get(CONF_DISCOVERY_ID)
384  if discovery_id is not None:
385  for trig_id, trig in mqtt_data.device_triggers.items():
386  if trig.discovery_id == discovery_id:
387  trigger_id = trig_id
388  break
389 
390  # Reference the device trigger by device_id, type and subtype.
391  if trigger_id is None:
392  trigger_type = config[CONF_TYPE]
393  trigger_subtype = config[CONF_SUBTYPE]
394  trigger_id = f"{device_id}_{trigger_type}_{trigger_subtype}"
395 
396  if trigger_id not in mqtt_data.device_triggers:
397  mqtt_data.device_triggers[trigger_id] = Trigger(
398  hass=hass,
399  device_id=device_id,
400  discovery_data=None,
401  discovery_id=discovery_id,
402  type=config[CONF_TYPE],
403  subtype=config[CONF_SUBTYPE],
404  topic=None,
405  payload=None,
406  qos=None,
407  value_template=None,
408  )
409 
410  return await mqtt_data.device_triggers[trigger_id].add_trigger(action, trigger_info)
None __init__(self, HomeAssistant hass, ConfigType config, str device_id, DiscoveryInfoType discovery_data, ConfigEntry config_entry)
None async_update(self, MQTTDiscoveryPayload discovery_data)
Callable[[], None] add_trigger(self, TriggerActionType action, TriggerInfo trigger_info)
bool remove(self, _T matcher)
Definition: match.py:214
None async_removed_from_device(HomeAssistant hass, str device_id)
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info)
list[dict[str, str]] async_get_triggers(HomeAssistant hass, str device_id)
None async_setup_trigger(HomeAssistant hass, ConfigType config, ConfigEntry config_entry, DiscoveryInfoType discovery_data)
None clear_discovery_hash(HomeAssistant hass, tuple[str, str] discovery_hash)
Definition: discovery.py:117
str|None update_device(HomeAssistant hass, ConfigEntry config_entry, ConfigType config)
Definition: entity.py:1512
None send_discovery_done(HomeAssistant hass, DiscoveryInfoType discovery_data)
Definition: entity.py:615
None async_remove(HomeAssistant hass, str intent_type)
Definition: intent.py:90