Skip to content

Commit bf17b53

Browse files
authored
Merge pull request #68 from graphras-com/feat/event-domain
feat(domains): add Event domain (#36)
2 parents 97f8ce1 + 8df859d commit bf17b53

3 files changed

Lines changed: 403 additions & 0 deletions

File tree

src/haclient/domains/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from haclient.domains.binary_sensor import BinarySensor
1010
from haclient.domains.climate import Climate
1111
from haclient.domains.cover import Cover
12+
from haclient.domains.event import Event
1213
from haclient.domains.humidifier import Humidifier
1314
from haclient.domains.light import Light
1415
from haclient.domains.lock import Lock
@@ -25,6 +26,7 @@
2526
"BinarySensor",
2627
"Climate",
2728
"Cover",
29+
"Event",
2830
"FavoriteItem",
2931
"Humidifier",
3032
"Light",

src/haclient/domains/event.py

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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

Comments
 (0)