Home Assistant Unofficial Reference 2024.12.1
device_trigger.py
Go to the documentation of this file.
1 """Provides device automations for Tasmota."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import logging
7 
8 import attr
9 from hatasmota.models import DiscoveryHashType
10 from hatasmota.trigger import TasmotaTrigger, TasmotaTriggerConfig
11 import voluptuous as vol
12 
13 from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
14 from homeassistant.components.homeassistant.triggers import event as event_trigger
15 from homeassistant.config_entries import ConfigEntry
16 from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
17 from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
18 from homeassistant.exceptions import HomeAssistantError
19 from homeassistant.helpers import config_validation as cv, device_registry as dr
20 from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
21 from homeassistant.helpers.dispatcher import async_dispatcher_connect
22 from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
23 from homeassistant.helpers.typing import ConfigType
24 
25 from .const import DOMAIN, TASMOTA_EVENT
26 from .discovery import TASMOTA_DISCOVERY_ENTITY_UPDATED, clear_discovery_hash
27 
28 _LOGGER = logging.getLogger(__name__)
29 
30 CONF_DISCOVERY_ID = "discovery_id"
31 CONF_SUBTYPE = "subtype"
32 DEVICE = "device"
33 
34 TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
35  {
36  vol.Required(CONF_PLATFORM): DEVICE,
37  vol.Required(CONF_DOMAIN): DOMAIN,
38  vol.Required(CONF_DEVICE_ID): str,
39  vol.Required(CONF_DISCOVERY_ID): str,
40  vol.Required(CONF_TYPE): cv.string,
41  vol.Required(CONF_SUBTYPE): cv.string,
42  }
43 )
44 
45 DEVICE_TRIGGERS = "tasmota_device_triggers"
46 
47 
48 @attr.s(slots=True)
50  """Attached trigger settings."""
51 
52  action: TriggerActionType = attr.ib()
53  trigger_info: TriggerInfo = attr.ib()
54  trigger: Trigger = attr.ib()
55  remove: CALLBACK_TYPE | None = attr.ib(default=None)
56 
57  async def async_attach_trigger(self) -> None:
58  """Attach event trigger."""
59  assert self.trigger.tasmota_trigger is not None
60  event_config = {
61  event_trigger.CONF_PLATFORM: "event",
62  event_trigger.CONF_EVENT_TYPE: TASMOTA_EVENT,
63  event_trigger.CONF_EVENT_DATA: {
64  "mac": self.trigger.tasmota_trigger.cfg.mac,
65  "source": self.trigger.tasmota_trigger.cfg.subtype,
66  "event": self.trigger.tasmota_trigger.cfg.event,
67  },
68  }
69 
70  event_config = event_trigger.TRIGGER_SCHEMA(event_config)
71  if self.removeremove:
72  self.removeremove()
73  # Note: No lock needed, event_trigger.async_attach_trigger
74  # is an synchronous function
75  self.removeremove = await event_trigger.async_attach_trigger(
76  self.trigger.hass,
77  event_config,
78  self.action,
79  self.trigger_info,
80  platform_type="device",
81  )
82 
83 
84 @attr.s(slots=True)
85 class Trigger:
86  """Device trigger settings."""
87 
88  device_id: str = attr.ib()
89  discovery_hash: DiscoveryHashType | None = attr.ib()
90  hass: HomeAssistant = attr.ib()
91  remove_update_signal: Callable[[], None] | None = attr.ib()
92  subtype: str = attr.ib()
93  tasmota_trigger: TasmotaTrigger | None = attr.ib()
94  type: str = attr.ib()
95  trigger_instances: list[TriggerInstance] = attr.ib(factory=list)
96 
97  async def add_trigger(
98  self, action: TriggerActionType, trigger_info: TriggerInfo
99  ) -> Callable[[], None]:
100  """Add Tasmota trigger."""
101  instance = TriggerInstance(action, trigger_info, self)
102  self.trigger_instances.append(instance)
103 
104  if self.tasmota_triggertasmota_trigger is not None:
105  # If we know about the trigger, set it up
106  await instance.async_attach_trigger()
107 
108  @callback
109  def async_remove() -> None:
110  """Remove trigger."""
111  if instance not in self.trigger_instances:
112  raise HomeAssistantError("Can't remove trigger twice")
113 
114  if instance.remove:
115  instance.remove()
116  self.trigger_instances.remove(instance)
117 
118  return async_remove
119 
120  def detach_trigger(self) -> None:
121  """Remove Tasmota device trigger."""
122  # Mark trigger as unknown
123  self.tasmota_triggertasmota_trigger = None
124 
125  # Unsubscribe if this trigger is in use
126  for trig in self.trigger_instances:
127  if trig.remove:
128  trig.remove()
129  trig.remove = None
130 
131  async def arm_tasmota_trigger(self) -> None:
132  """Arm Tasmota trigger: subscribe to MQTT topics and fire events."""
133 
134  @callback
135  def _on_trigger() -> None:
136  assert self.tasmota_triggertasmota_trigger is not None
137  data = {
138  "mac": self.tasmota_triggertasmota_trigger.cfg.mac,
139  "source": self.tasmota_triggertasmota_trigger.cfg.subtype,
140  "event": self.tasmota_triggertasmota_trigger.cfg.event,
141  }
142  self.hass.bus.async_fire(
143  TASMOTA_EVENT,
144  data,
145  )
146 
147  assert self.tasmota_triggertasmota_trigger is not None
148  self.tasmota_triggertasmota_trigger.set_on_trigger_callback(_on_trigger)
149  await self.tasmota_triggertasmota_trigger.subscribe_topics()
150 
151  async def set_tasmota_trigger(
152  self, tasmota_trigger: TasmotaTrigger, remove_update_signal: Callable[[], None]
153  ) -> None:
154  """Set Tasmota trigger."""
155  await self.update_tasmota_trigger(tasmota_trigger.cfg, remove_update_signal)
156  self.tasmota_triggertasmota_trigger = tasmota_trigger
157 
158  for trig in self.trigger_instances:
159  await trig.async_attach_trigger()
160 
161  async def update_tasmota_trigger(
162  self,
163  tasmota_trigger_cfg: TasmotaTriggerConfig,
164  remove_update_signal: Callable[[], None],
165  ) -> None:
166  """Update Tasmota trigger."""
167  self.remove_update_signalremove_update_signal = remove_update_signal
168  self.typetype = tasmota_trigger_cfg.type
169  self.subtypesubtype = tasmota_trigger_cfg.subtype
170 
171 
173  hass: HomeAssistant,
174  tasmota_trigger: TasmotaTrigger,
175  config_entry: ConfigEntry,
176  discovery_hash: DiscoveryHashType,
177 ) -> None:
178  """Set up a discovered Tasmota device trigger."""
179  discovery_id = tasmota_trigger.cfg.trigger_id
180  remove_update_signal: Callable[[], None] | None = None
181  _LOGGER.debug(
182  "Discovered trigger with ID: %s '%s'", discovery_id, tasmota_trigger.cfg
183  )
184 
185  async def discovery_update(trigger_config: TasmotaTriggerConfig) -> None:
186  """Handle discovery update."""
187  _LOGGER.debug(
188  "Got update for trigger with hash: %s '%s'", discovery_hash, trigger_config
189  )
190  device_triggers: dict[str, Trigger] = hass.data[DEVICE_TRIGGERS]
191  if not trigger_config.is_active:
192  # Empty trigger_config: Remove trigger
193  _LOGGER.debug("Removing trigger: %s", discovery_hash)
194  if discovery_id in device_triggers:
195  device_trigger = device_triggers[discovery_id]
196  assert device_trigger.tasmota_trigger
197  await device_trigger.tasmota_trigger.unsubscribe_topics()
198  device_trigger.detach_trigger()
199  clear_discovery_hash(hass, discovery_hash)
200  if remove_update_signal is not None:
201  remove_update_signal()
202  return
203 
204  device_trigger = device_triggers[discovery_id]
205  assert device_trigger.tasmota_trigger
206  if device_trigger.tasmota_trigger.config_same(trigger_config):
207  # Unchanged payload: Ignore to avoid unnecessary unsubscribe / subscribe
208  _LOGGER.debug("Ignoring unchanged update for: %s", discovery_hash)
209  return
210 
211  # Non-empty, changed trigger_config: Update trigger
212  _LOGGER.debug("Updating trigger: %s", discovery_hash)
213  device_trigger.tasmota_trigger.config_update(trigger_config)
214  assert remove_update_signal
215  await device_trigger.update_tasmota_trigger(
216  trigger_config, remove_update_signal
217  )
218  await device_trigger.arm_tasmota_trigger()
219  return
220 
221  remove_update_signal = async_dispatcher_connect(
222  hass, TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*discovery_hash), discovery_update
223  )
224 
225  device_registry = dr.async_get(hass)
226  device = device_registry.async_get_device(
227  connections={(CONNECTION_NETWORK_MAC, tasmota_trigger.cfg.mac)},
228  )
229 
230  if device is None:
231  return
232 
233  if DEVICE_TRIGGERS not in hass.data:
234  hass.data[DEVICE_TRIGGERS] = {}
235  device_triggers: dict[str, Trigger] = hass.data[DEVICE_TRIGGERS]
236  if discovery_id not in device_triggers:
237  device_trigger = Trigger(
238  hass=hass,
239  device_id=device.id,
240  discovery_hash=discovery_hash,
241  subtype=tasmota_trigger.cfg.subtype,
242  tasmota_trigger=tasmota_trigger,
243  type=tasmota_trigger.cfg.type,
244  remove_update_signal=remove_update_signal,
245  )
246  device_triggers[discovery_id] = device_trigger
247  else:
248  # This Tasmota trigger is wanted by device trigger(s), set them up
249  device_trigger = device_triggers[discovery_id]
250  await device_trigger.set_tasmota_trigger(tasmota_trigger, remove_update_signal)
251  await device_trigger.arm_tasmota_trigger()
252 
253 
254 async def async_remove_triggers(hass: HomeAssistant, device_id: str) -> None:
255  """Cleanup any device triggers for a Tasmota device."""
256  triggers = await async_get_triggers(hass, device_id)
257 
258  if not triggers:
259  return
260  device_triggers: dict[str, Trigger] = hass.data[DEVICE_TRIGGERS]
261  for trig in triggers:
262  device_trigger = device_triggers.pop(trig[CONF_DISCOVERY_ID])
263  if device_trigger:
264  discovery_hash = device_trigger.discovery_hash
265 
266  assert device_trigger.tasmota_trigger
267  await device_trigger.tasmota_trigger.unsubscribe_topics()
268  device_trigger.detach_trigger()
269  clear_discovery_hash(hass, discovery_hash)
270  assert device_trigger.remove_update_signal
271  device_trigger.remove_update_signal()
272 
273 
275  hass: HomeAssistant, device_id: str
276 ) -> list[dict[str, str]]:
277  """List device triggers for a Tasmota device."""
278  triggers: list[dict[str, str]] = []
279 
280  if DEVICE_TRIGGERS not in hass.data:
281  return triggers
282 
283  device_triggers: dict[str, Trigger] = hass.data[DEVICE_TRIGGERS]
284  for discovery_id, trig in device_triggers.items():
285  if trig.device_id != device_id or trig.tasmota_trigger is None:
286  continue
287 
288  trigger = {
289  "platform": "device",
290  "domain": "tasmota",
291  "device_id": device_id,
292  "type": trig.type,
293  "subtype": trig.subtype,
294  "discovery_id": discovery_id,
295  }
296  triggers.append(trigger)
297 
298  return triggers
299 
300 
302  hass: HomeAssistant,
303  config: ConfigType,
304  action: TriggerActionType,
305  trigger_info: TriggerInfo,
306 ) -> CALLBACK_TYPE:
307  """Attach a device trigger."""
308  if DEVICE_TRIGGERS not in hass.data:
309  hass.data[DEVICE_TRIGGERS] = {}
310  device_triggers: dict[str, Trigger] = hass.data[DEVICE_TRIGGERS]
311  device_id = config[CONF_DEVICE_ID]
312  discovery_id = config[CONF_DISCOVERY_ID]
313 
314  if discovery_id not in device_triggers:
315  # The trigger has not (yet) been discovered, prepare it for later
316  device_triggers[discovery_id] = Trigger(
317  hass=hass,
318  device_id=device_id,
319  discovery_hash=None,
320  remove_update_signal=None,
321  type=config[CONF_TYPE],
322  subtype=config[CONF_SUBTYPE],
323  tasmota_trigger=None,
324  )
325  trigger: Trigger = device_triggers[discovery_id]
326  return await trigger.add_trigger(action, trigger_info)
Callable[[], None] add_trigger(self, TriggerActionType action, TriggerInfo trigger_info)
bool remove(self, _T matcher)
Definition: match.py:214
None clear_discovery_hash(HomeAssistant hass, tuple[str, str] discovery_hash)
Definition: discovery.py:117
list[dict[str, str]] async_get_triggers(HomeAssistant hass, str device_id)
None async_remove_triggers(HomeAssistant hass, str device_id)
None async_setup_trigger(HomeAssistant hass, TasmotaTrigger tasmota_trigger, ConfigEntry config_entry, DiscoveryHashType discovery_hash)
CALLBACK_TYPE async_attach_trigger(HomeAssistant hass, ConfigType config, TriggerActionType action, TriggerInfo trigger_info)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_remove(HomeAssistant hass, str intent_type)
Definition: intent.py:90