|
| 1 | +"""``event`` domain implementation (read-only, stateless triggers). |
| 2 | +
|
| 3 | +Home Assistant's ``event`` domain (added in 2023.8) represents stateless |
| 4 | +trigger entities such as button presses, doorbell rings, and remote |
| 5 | +control actions. The entity's ``state`` is an ISO-8601 timestamp of the |
| 6 | +most recent event, while ``event_type`` (the kind of event that just |
| 7 | +fired) and ``event_types`` (all possible event types) live in the |
| 8 | +attributes. |
| 9 | +
|
| 10 | +This module exposes those values as typed properties and provides a |
| 11 | +single intent-driven listener decorator, `Event.on_event`, that can be |
| 12 | +used either bare (``@button.on_event``) or with a filter |
| 13 | +(``@button.on_event(event_type="double_press")``). |
| 14 | +""" |
| 15 | + |
| 16 | +from __future__ import annotations |
| 17 | + |
| 18 | +from collections.abc import Callable |
| 19 | +from typing import Any, overload |
| 20 | + |
| 21 | +from haclient.core.plugins import DomainSpec, register_domain |
| 22 | +from haclient.entity.base import Entity, ValueChangeHandler |
| 23 | + |
| 24 | + |
| 25 | +class Event(Entity): |
| 26 | + """A read-only Home Assistant event entity. |
| 27 | +
|
| 28 | + Event entities represent discrete, stateless triggers (e.g. a |
| 29 | + button press). Each fire updates the entity's ``state`` to the new |
| 30 | + event timestamp and populates the ``event_type`` attribute with the |
| 31 | + kind of event that occurred. |
| 32 | +
|
| 33 | + Listener callbacks registered via `on_event` receive a single |
| 34 | + positional argument: the ``event_type`` string of the event that |
| 35 | + just fired (e.g. ``"single_press"``). |
| 36 | + """ |
| 37 | + |
| 38 | + domain = "event" |
| 39 | + |
| 40 | + def __init__(self, *args: Any, **kwargs: Any) -> None: |
| 41 | + super().__init__(*args, **kwargs) |
| 42 | + # Listeners keyed by event_type filter. ``None`` is the |
| 43 | + # catch-all bucket that receives every event. |
| 44 | + self._event_listeners: dict[str | None, list[Callable[[str], Any]]] = {} |
| 45 | + |
| 46 | + # -- State properties --------------------------------------------- |
| 47 | + |
| 48 | + @property |
| 49 | + def event_type(self) -> str | None: |
| 50 | + """Type of the most recent event (e.g. ``"single_press"``). |
| 51 | +
|
| 52 | + Returns |
| 53 | + ------- |
| 54 | + str or None |
| 55 | + The event type from the entity's attributes, or ``None`` if |
| 56 | + the entity has not fired yet (or the attribute is missing). |
| 57 | + """ |
| 58 | + value = self.attributes.get("event_type") |
| 59 | + return str(value) if value is not None else None |
| 60 | + |
| 61 | + @property |
| 62 | + def event_types(self) -> list[str] | None: |
| 63 | + """All event types this entity is capable of firing. |
| 64 | +
|
| 65 | + Returns |
| 66 | + ------- |
| 67 | + list of str or None |
| 68 | + The declared event-type catalogue, or ``None`` if the |
| 69 | + attribute is absent. |
| 70 | + """ |
| 71 | + value = self.attributes.get("event_types") |
| 72 | + if value is None: |
| 73 | + return None |
| 74 | + if isinstance(value, list): |
| 75 | + return [str(item) for item in value] |
| 76 | + return None |
| 77 | + |
| 78 | + @property |
| 79 | + def device_class(self) -> str | None: |
| 80 | + """Device class (e.g. ``"button"``, ``"doorbell"``).""" |
| 81 | + value = self.attributes.get("device_class") |
| 82 | + return str(value) if value is not None else None |
| 83 | + |
| 84 | + # -- Listener decorator ------------------------------------------- |
| 85 | + |
| 86 | + @overload |
| 87 | + def on_event(self, func: Callable[[str], Any]) -> Callable[[str], Any]: ... |
| 88 | + |
| 89 | + @overload |
| 90 | + def on_event( |
| 91 | + self, *, event_type: str |
| 92 | + ) -> Callable[[Callable[[str], Any]], Callable[[str], Any]]: ... |
| 93 | + |
| 94 | + def on_event( |
| 95 | + self, |
| 96 | + func: Callable[[str], Any] | None = None, |
| 97 | + *, |
| 98 | + event_type: str | None = None, |
| 99 | + ) -> Any: |
| 100 | + """Register a listener for events fired by this entity. |
| 101 | +
|
| 102 | + May be used either as a bare decorator to receive every event, |
| 103 | + or as a parameterised decorator to filter by ``event_type``. |
| 104 | +
|
| 105 | + Parameters |
| 106 | + ---------- |
| 107 | + func : callable, optional |
| 108 | + Sync or async callable receiving a single positional |
| 109 | + argument: the event-type string that just fired. Supplied |
| 110 | + implicitly when used as ``@entity.on_event``. |
| 111 | + event_type : str or None, keyword-only |
| 112 | + Restrict the listener to a specific event-type. When |
| 113 | + ``None`` (the default), the listener receives every event. |
| 114 | +
|
| 115 | + Returns |
| 116 | + ------- |
| 117 | + callable |
| 118 | + When called as a bare decorator, returns *func*. When called |
| 119 | + with ``event_type=...``, returns a decorator that registers |
| 120 | + and then returns the wrapped function. |
| 121 | +
|
| 122 | + Examples |
| 123 | + -------- |
| 124 | + Listen to every event:: |
| 125 | +
|
| 126 | + @button.on_event |
| 127 | + async def any_press(event_type): |
| 128 | + ... |
| 129 | +
|
| 130 | + Filter to a specific event type:: |
| 131 | +
|
| 132 | + @button.on_event(event_type="double_press") |
| 133 | + async def double(event_type): |
| 134 | + ... |
| 135 | + """ |
| 136 | + if func is not None and event_type is not None: |
| 137 | + raise TypeError("on_event accepts either a function or event_type, not both") |
| 138 | + |
| 139 | + if func is not None: |
| 140 | + # Bare-decorator form: @entity.on_event |
| 141 | + self._event_listeners.setdefault(None, []).append(func) |
| 142 | + return func |
| 143 | + |
| 144 | + # Parameterised form: @entity.on_event(event_type="...") |
| 145 | + def decorator(inner: Callable[[str], Any]) -> Callable[[str], Any]: |
| 146 | + self._event_listeners.setdefault(event_type, []).append(inner) |
| 147 | + return inner |
| 148 | + |
| 149 | + return decorator |
| 150 | + |
| 151 | + def remove_event_listener(self, func: Callable[[str], Any]) -> None: |
| 152 | + """Remove a previously registered event listener. |
| 153 | +
|
| 154 | + Searches all registered buckets (catch-all and per-event-type) |
| 155 | + and removes the first match. Unknown callables are silently |
| 156 | + ignored. |
| 157 | +
|
| 158 | + Parameters |
| 159 | + ---------- |
| 160 | + func : callable |
| 161 | + The exact callable previously registered via `on_event`. |
| 162 | + """ |
| 163 | + for listeners in self._event_listeners.values(): |
| 164 | + try: |
| 165 | + listeners.remove(func) |
| 166 | + return |
| 167 | + except ValueError: |
| 168 | + continue |
| 169 | + |
| 170 | + # -- Dispatch ----------------------------------------------------- |
| 171 | + |
| 172 | + def _handle_state_changed( |
| 173 | + self, |
| 174 | + old_state: dict[str, Any] | None, |
| 175 | + new_state: dict[str, Any] | None, |
| 176 | + ) -> None: |
| 177 | + """Apply state, then dispatch typed event listeners. |
| 178 | +
|
| 179 | + An event "fires" whenever the state timestamp changes. The |
| 180 | + ``event_type`` attribute on the *new* state describes what kind |
| 181 | + of event occurred and is the payload delivered to listeners. |
| 182 | + """ |
| 183 | + old_ts = (old_state or {}).get("state") |
| 184 | + new_ts = (new_state or {}).get("state") |
| 185 | + super()._handle_state_changed(old_state, new_state) |
| 186 | + |
| 187 | + if new_state is None or new_ts == old_ts: |
| 188 | + return |
| 189 | + if new_ts in (None, "unknown", "unavailable"): |
| 190 | + return |
| 191 | + |
| 192 | + new_attrs = new_state.get("attributes") or {} |
| 193 | + event_type_raw = new_attrs.get("event_type") |
| 194 | + if event_type_raw is None: |
| 195 | + return |
| 196 | + event_type = str(event_type_raw) |
| 197 | + |
| 198 | + # Catch-all listeners always fire. |
| 199 | + for listener in list(self._event_listeners.get(None, [])): |
| 200 | + self._dispatch_event(listener, event_type) |
| 201 | + # Typed listeners only fire when the event_type matches. |
| 202 | + for listener in list(self._event_listeners.get(event_type, [])): |
| 203 | + self._dispatch_event(listener, event_type) |
| 204 | + |
| 205 | + def _dispatch_event(self, handler: Callable[[str], Any], event_type: str) -> None: |
| 206 | + """Invoke an event handler with the event_type payload. |
| 207 | +
|
| 208 | + Reuses the base class's value-dispatch path so that async |
| 209 | + handlers are scheduled via the registered `Clock` while sync |
| 210 | + ones run immediately. |
| 211 | + """ |
| 212 | + # The base ``_schedule_value`` helper passes ``(old, new)`` to |
| 213 | + # the callback; here we only have a single payload, so we adapt |
| 214 | + # via a thin lambda. Errors are caught inside ``_schedule_value``. |
| 215 | + adapter: ValueChangeHandler = lambda _old, new: handler(new) # noqa: E731 |
| 216 | + self._schedule_value(adapter, None, event_type) |
| 217 | + |
| 218 | + |
| 219 | +SPEC: DomainSpec[Event] = register_domain(DomainSpec(name="event", entity_cls=Event)) |
| 220 | +"""The `DomainSpec` registered with the shared `DomainRegistry`.""" |
0 commit comments