hassreactor 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: hassreactor
3
+ Version: 0.2.0
4
+ Summary: Event-driven Home Assistant automations in Python
5
+ Home-page: https://github.com/N4S4/hassreactor
6
+ Author: Renato Visaggio
7
+ Author-email: synology.python.api@gmail.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Home Automation
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: aiohttp>=3.8
21
+ Dynamic: author
22
+ Dynamic: author-email
23
+ Dynamic: classifier
24
+ Dynamic: description
25
+ Dynamic: description-content-type
26
+ Dynamic: home-page
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
30
+
31
+ # hassreactor
32
+
33
+ Event-driven Home Assistant automations in Python. No YAML, no Node-RED, no AppDaemon — just Python.
34
+
35
+ ## Why
36
+
37
+ Home Assistant has a powerful automation engine, but it lives in YAML or a UI. Sometimes you just want to write a Python script:
38
+
39
+ - "If living room temp > 28°C, turn on fan"
40
+ - "If front door opens, send me a Telegram message"
41
+ - "Every hour, log the temperature"
42
+
43
+ hassreactor lets you write these as plain Python files using WebSocket events — no polling, no complex setup.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install hassreactor
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ Create a file `automations.py`:
54
+
55
+ ```python
56
+ from hassreactor import Reactor
57
+
58
+ app = Reactor("http://homeassistant:8123", "your-long-lived-token")
59
+
60
+ @app.when("sensor.temperatura_salotto", above=28)
61
+ async def accendi_ventilatore(event):
62
+ """When temp goes above 28°C, turn on the fan."""
63
+ await app.fan.turn_on(entity_id="fan.ventilatore")
64
+
65
+ @app.when("binary_sensor.porta_ingresso", to="on")
66
+ async def porta_aperta(event):
67
+ """When front door opens, notify."""
68
+ await app.notify.telegram(message="Porta d'ingresso aperta!")
69
+
70
+ @app.schedule("every 1h")
71
+ async def report():
72
+ temp = await app.get_state("sensor.temperatura_salotto")
73
+ print(f"Current temperature: {temp}°C")
74
+
75
+ if __name__ == "__main__":
76
+ app.run()
77
+ ```
78
+
79
+ Run it:
80
+
81
+ ```bash
82
+ python automations.py
83
+ ```
84
+
85
+ ## Trigger Types
86
+
87
+ | Trigger | Description |
88
+ |---|---|
89
+ | `@app.when(entity, above=N)` | Numeric value crosses ABOVE threshold |
90
+ | `@app.when(entity, below=N)` | Numeric value crosses BELOW threshold |
91
+ | `@app.when(entity, to="on")` | State changes TO an exact value |
92
+ | `@app.when(entity, changes=True)` | ANY state change |
93
+ | `@app.schedule("every 30m")` | Run every 30 minutes |
94
+ | `@app.schedule("every 2h")` | Run every 2 hours |
95
+ | `@app.schedule("0 9 * * *")` | Cron expression (every day at 9am) |
96
+
97
+ ## Calling Services
98
+
99
+ Any HA service is available as a method on the domain:
100
+
101
+ ```python
102
+ await app.light.turn_on(entity_id="light.kitchen", brightness=128)
103
+ await app.climate.set_temperature(entity_id="climate.home", temperature=22)
104
+ await app.switch.toggle(entity_id="switch.pump")
105
+ await app.notify.telegram(message="Hello!")
106
+ ```
107
+
108
+ ## How It Works
109
+
110
+ hassreactor connects to Home Assistant via **WebSocket** and subscribes to `state_changed` events. When an entity you're watching changes state, your function runs instantly — no polling, no sleep loops.
111
+
112
+ Service calls use the REST API.
113
+
114
+ Only dependency: `aiohttp`.
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,88 @@
1
+ # hassreactor
2
+
3
+ Event-driven Home Assistant automations in Python. No YAML, no Node-RED, no AppDaemon — just Python.
4
+
5
+ ## Why
6
+
7
+ Home Assistant has a powerful automation engine, but it lives in YAML or a UI. Sometimes you just want to write a Python script:
8
+
9
+ - "If living room temp > 28°C, turn on fan"
10
+ - "If front door opens, send me a Telegram message"
11
+ - "Every hour, log the temperature"
12
+
13
+ hassreactor lets you write these as plain Python files using WebSocket events — no polling, no complex setup.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install hassreactor
19
+ ```
20
+
21
+ ## Quick Start
22
+
23
+ Create a file `automations.py`:
24
+
25
+ ```python
26
+ from hassreactor import Reactor
27
+
28
+ app = Reactor("http://homeassistant:8123", "your-long-lived-token")
29
+
30
+ @app.when("sensor.temperatura_salotto", above=28)
31
+ async def accendi_ventilatore(event):
32
+ """When temp goes above 28°C, turn on the fan."""
33
+ await app.fan.turn_on(entity_id="fan.ventilatore")
34
+
35
+ @app.when("binary_sensor.porta_ingresso", to="on")
36
+ async def porta_aperta(event):
37
+ """When front door opens, notify."""
38
+ await app.notify.telegram(message="Porta d'ingresso aperta!")
39
+
40
+ @app.schedule("every 1h")
41
+ async def report():
42
+ temp = await app.get_state("sensor.temperatura_salotto")
43
+ print(f"Current temperature: {temp}°C")
44
+
45
+ if __name__ == "__main__":
46
+ app.run()
47
+ ```
48
+
49
+ Run it:
50
+
51
+ ```bash
52
+ python automations.py
53
+ ```
54
+
55
+ ## Trigger Types
56
+
57
+ | Trigger | Description |
58
+ |---|---|
59
+ | `@app.when(entity, above=N)` | Numeric value crosses ABOVE threshold |
60
+ | `@app.when(entity, below=N)` | Numeric value crosses BELOW threshold |
61
+ | `@app.when(entity, to="on")` | State changes TO an exact value |
62
+ | `@app.when(entity, changes=True)` | ANY state change |
63
+ | `@app.schedule("every 30m")` | Run every 30 minutes |
64
+ | `@app.schedule("every 2h")` | Run every 2 hours |
65
+ | `@app.schedule("0 9 * * *")` | Cron expression (every day at 9am) |
66
+
67
+ ## Calling Services
68
+
69
+ Any HA service is available as a method on the domain:
70
+
71
+ ```python
72
+ await app.light.turn_on(entity_id="light.kitchen", brightness=128)
73
+ await app.climate.set_temperature(entity_id="climate.home", temperature=22)
74
+ await app.switch.toggle(entity_id="switch.pump")
75
+ await app.notify.telegram(message="Hello!")
76
+ ```
77
+
78
+ ## How It Works
79
+
80
+ hassreactor connects to Home Assistant via **WebSocket** and subscribes to `state_changed` events. When an entity you're watching changes state, your function runs instantly — no polling, no sleep loops.
81
+
82
+ Service calls use the REST API.
83
+
84
+ Only dependency: `aiohttp`.
85
+
86
+ ## License
87
+
88
+ MIT
@@ -0,0 +1,6 @@
1
+ """hassreactor — Event-driven Home Assistant automations in Python."""
2
+
3
+ from .reactor import Reactor, TriggerEvent
4
+
5
+ __version__ = "0.1.1"
6
+ __all__ = ["Reactor", "TriggerEvent"]
@@ -0,0 +1,67 @@
1
+ """
2
+ Domain proxy for fluent service calls.
3
+
4
+ Provides `app.light.turn_on(...)` style API by dynamically
5
+ generating proxy objects for each Home Assistant domain.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from .engine import EventEngine
13
+
14
+
15
+ class _DomainProxy:
16
+ """Proxy for a single HA domain (light, switch, climate, etc.).
17
+
18
+ Service calls become method calls:
19
+ app.light.turn_on(entity_id='light.kitchen')
20
+ """
21
+
22
+ def __init__(self, engine: "EventEngine", domain: str):
23
+ self._engine = engine
24
+ self._domain = domain
25
+
26
+ def __getattr__(self, service: str):
27
+ """Return a callable that invokes the service."""
28
+ if service.startswith("_"):
29
+ raise AttributeError(service)
30
+
31
+ async def _call(**kwargs):
32
+ data = {k: v for k, v in kwargs.items() if k not in ("entity_id",)}
33
+ target = None
34
+ if "entity_id" in kwargs:
35
+ target = {"entity_id": kwargs["entity_id"]}
36
+ return await self._engine.call_service(
37
+ self._domain, service, data=data, target=target
38
+ )
39
+
40
+ return _call
41
+
42
+ def __dir__(self):
43
+ return [] # Dynamic — auto-complete not supported
44
+
45
+
46
+ class DomainProxy:
47
+ """Top-level proxy that returns _DomainProxy instances.
48
+
49
+ Usage:
50
+ app = DomainProxy(engine)
51
+ await app.light.turn_on(entity_id='light.kitchen')
52
+ await app.climate.set_temperature(entity_id='climate.home', temperature=22)
53
+ """
54
+
55
+ def __init__(self, engine: "EventEngine"):
56
+ self._engine = engine
57
+ self._cache: dict[str, _DomainProxy] = {}
58
+
59
+ def __getattr__(self, domain: str) -> _DomainProxy:
60
+ if domain.startswith("_"):
61
+ raise AttributeError(domain)
62
+ if domain not in self._cache:
63
+ self._cache[domain] = _DomainProxy(self._engine, domain)
64
+ return self._cache[domain]
65
+
66
+ def __getitem__(self, domain: str) -> _DomainProxy:
67
+ return self.__getattr__(domain)
@@ -0,0 +1,188 @@
1
+ """
2
+ WebSocket event engine for Home Assistant.
3
+
4
+ Handles authentication, subscription to state_changed events,
5
+ and dispatches to registered triggers.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ from typing import Any, Callable, Awaitable
13
+
14
+ import aiohttp
15
+
16
+ logger = logging.getLogger("hassreactor.engine")
17
+
18
+
19
+ class EventEngine:
20
+ """Low-level WebSocket client for Home Assistant."""
21
+
22
+ def __init__(self, url: str, token: str, verify_ssl: bool = True):
23
+ if not url.startswith(("http://", "https://")):
24
+ url = f"http://{url}"
25
+ url = url.rstrip("/")
26
+ self._http_url = url
27
+ self._ws_url = url.replace("http://", "ws://").replace("https://", "wss://")
28
+ self._ws_url = f"{self._ws_url}/api/websocket"
29
+ self._token = token
30
+ self._verify_ssl = verify_ssl
31
+ self._msg_id = 0
32
+ self._session: aiohttp.ClientSession | None = None
33
+ self._ws: aiohttp.ClientWebSocketResponse | None = None
34
+ self._listeners: dict[str, list[Callable[[dict], Awaitable[None]]]] = {}
35
+ self._result_futures: dict[int, asyncio.Future] = {}
36
+ self._running = False
37
+ self._connected = False
38
+
39
+ # -- public API -----------------------------------------------------------
40
+
41
+ @property
42
+ def connected(self) -> bool:
43
+ return self._connected
44
+
45
+ async def connect(self) -> None:
46
+ """Authenticate and start listening."""
47
+ self._session = aiohttp.ClientSession()
48
+ self._ws = await self._session.ws_connect(
49
+ self._ws_url, verify_ssl=self._verify_ssl
50
+ )
51
+
52
+ # Read auth required message
53
+ auth_msg = await self._ws.receive_json()
54
+ if auth_msg.get("type") != "auth_required":
55
+ raise RuntimeError(f"Unexpected message: {auth_msg}")
56
+
57
+ # Authenticate
58
+ await self._ws.send_json({
59
+ "type": "auth",
60
+ "access_token": self._token,
61
+ })
62
+ auth_result = await self._ws.receive_json()
63
+ if auth_result.get("type") != "auth_ok":
64
+ raise RuntimeError(f"Auth failed: {auth_result}")
65
+
66
+ # Subscribe to state_changed events
67
+ sub_id = self._next_id()
68
+ await self._ws.send_json({
69
+ "id": sub_id,
70
+ "type": "subscribe_events",
71
+ "event_type": "state_changed",
72
+ })
73
+ sub_result = await self._ws.receive_json()
74
+ if not sub_result.get("success"):
75
+ raise RuntimeError(f"Subscribe failed: {sub_result}")
76
+
77
+ self._connected = True
78
+ self._running = True
79
+ logger.info("Connected to Home Assistant WebSocket")
80
+ asyncio.create_task(self._listen_loop())
81
+
82
+ async def disconnect(self) -> None:
83
+ """Close the WebSocket connection."""
84
+ self._running = False
85
+ self._connected = False
86
+ if self._ws and not self._ws.closed:
87
+ await self._ws.close()
88
+ if self._session:
89
+ await self._session.close()
90
+ logger.info("Disconnected from Home Assistant")
91
+
92
+ async def call_service(
93
+ self, domain: str, service: str, data: dict | None = None,
94
+ target: dict | None = None,
95
+ ) -> dict:
96
+ """Call a Home Assistant service via REST API."""
97
+ url = f"{self._http_url}/api/services/{domain}/{service}"
98
+ body: dict[str, Any] = {}
99
+ if data:
100
+ body = dict(data)
101
+ if target:
102
+ body["target"] = target
103
+ async with self._session.post(
104
+ url, json=body,
105
+ headers={"Authorization": f"Bearer {self._token}"},
106
+ verify_ssl=self._verify_ssl,
107
+ ) as resp:
108
+ return await resp.json()
109
+
110
+ async def get_states(self) -> list[dict]:
111
+ """Get all entity states via REST API."""
112
+ url = f"{self._http_url}/api/states"
113
+ async with self._session.get(
114
+ url,
115
+ headers={"Authorization": f"Bearer {self._token}"},
116
+ verify_ssl=self._verify_ssl,
117
+ ) as resp:
118
+ return await resp.json()
119
+
120
+ async def get_state(self, entity_id: str) -> dict | None:
121
+ """Get single entity state."""
122
+ url = f"{self._http_url}/api/states/{entity_id}"
123
+ async with self._session.get(
124
+ url,
125
+ headers={"Authorization": f"Bearer {self._token}"},
126
+ verify_ssl=self._verify_ssl,
127
+ ) as resp:
128
+ if resp.status == 404:
129
+ return None
130
+ return await resp.json()
131
+
132
+ def on_state_change(
133
+ self, entity_id: str, callback: Callable[[dict], Awaitable[None]],
134
+ ) -> None:
135
+ """Register a callback for state_changed events on a specific entity."""
136
+ if entity_id not in self._listeners:
137
+ self._listeners[entity_id] = []
138
+ self._listeners[entity_id].append(callback)
139
+
140
+ # -- internal -------------------------------------------------------------
141
+
142
+ def _next_id(self) -> int:
143
+ self._msg_id += 1
144
+ return self._msg_id
145
+
146
+ async def _listen_loop(self) -> None:
147
+ """Long-running loop: read WebSocket messages and dispatch."""
148
+ while self._running:
149
+ try:
150
+ ws_msg = await asyncio.wait_for(self._ws.receive(), timeout=30)
151
+ except asyncio.TimeoutError:
152
+ continue
153
+ except Exception:
154
+ logger.exception("WebSocket read error")
155
+ break
156
+
157
+ # Home Assistant sends binary frames (pings). Skip them.
158
+ if ws_msg.type != aiohttp.WSMsgType.TEXT:
159
+ continue
160
+
161
+ try:
162
+ msg = json.loads(ws_msg.data)
163
+ except (json.JSONDecodeError, TypeError):
164
+ logger.warning("Invalid JSON in WebSocket message")
165
+ continue
166
+
167
+ msg_type = msg.get("type")
168
+ if msg_type == "event":
169
+ event = msg.get("event", {})
170
+ if event.get("event_type") == "state_changed":
171
+ data = event.get("data", {})
172
+ entity_id = data.get("entity_id", "")
173
+ # Dispatch to listeners
174
+ callbacks = self._listeners.get(entity_id, [])
175
+ for cb in callbacks:
176
+ try:
177
+ await cb(data)
178
+ except Exception:
179
+ logger.exception(
180
+ "Error in listener for %s", entity_id
181
+ )
182
+ elif msg_type == "result":
183
+ msg_id = msg.get("id")
184
+ future = self._result_futures.pop(msg_id, None)
185
+ if future:
186
+ future.set_result(msg)
187
+
188
+ self._connected = False
@@ -0,0 +1,267 @@
1
+ """
2
+ Reactor — the main entry point for hassreactor.
3
+
4
+ Ties together the WebSocket engine, service proxy, and scheduler
5
+ into a single, ergonomic interface for writing Home Assistant
6
+ automations in Python.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ import signal
13
+ from typing import Any, Callable, Awaitable
14
+
15
+ from .domain_proxy import DomainProxy
16
+ from .engine import EventEngine
17
+ from .scheduler import Scheduler
18
+
19
+
20
+ class TriggerEvent:
21
+ """Event passed to @app.when callbacks."""
22
+
23
+ def __init__(self, engine: EventEngine, event_data: dict):
24
+ self.entity_id: str = event_data.get("entity_id", "")
25
+ self.old_state: dict | None = event_data.get("old_state")
26
+ self.new_state: dict | None = event_data.get("new_state")
27
+
28
+ # Convenience: new state value as string
29
+ ns = self.new_state or {}
30
+ self.state: str = ns.get("state", "")
31
+ self.attributes: dict = ns.get("attributes", {})
32
+
33
+ def __repr__(self) -> str:
34
+ return (
35
+ f"TriggerEvent(entity_id={self.entity_id!r}, "
36
+ f"state={self.state!r})"
37
+ )
38
+
39
+
40
+ class Reactor:
41
+ """Main application class for hassreactor.
42
+
43
+ Usage::
44
+
45
+ app = Reactor("http://ha:8123", "token")
46
+
47
+ @app.when("sensor.temp", above=28)
48
+ async def hot(event):
49
+ await app.fan.turn_on(entity_id="fan.ventilatore")
50
+
51
+ if __name__ == "__main__":
52
+ app.run()
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ url: str,
58
+ token: str,
59
+ verify_ssl: bool = True,
60
+ ):
61
+ self._engine = EventEngine(url, token, verify_ssl=verify_ssl)
62
+ self._proxy = DomainProxy(self._engine)
63
+ self._scheduler = Scheduler()
64
+ self._triggers: list[tuple[str, dict, Callable]] = []
65
+ self._running = False
66
+ self.log = logging.getLogger("hassreactor")
67
+
68
+ # -- domain access ---------------------------------------------------------
69
+
70
+ def __getattr__(self, name: str):
71
+ """Proxy unknown attrs to the domain proxy.
72
+
73
+ Example: app.light → DomainProxy(engine).light
74
+ """
75
+ if name.startswith("_"):
76
+ raise AttributeError(name)
77
+ # Check if it's a known domain or delegate to proxy
78
+ return getattr(self._proxy, name)
79
+
80
+ # -- decorators -----------------------------------------------------------
81
+
82
+ def when(
83
+ self,
84
+ entity_id: str,
85
+ *,
86
+ above: float | None = None,
87
+ below: float | None = None,
88
+ to: str | None = None,
89
+ changes: bool = False,
90
+ ):
91
+ """Decorator: trigger on state changes.
92
+
93
+ Args:
94
+ entity_id: HA entity to watch (e.g. 'sensor.temp')
95
+ above: Fire when numeric state goes ABOVE this value
96
+ below: Fire when numeric state goes BELOW this value
97
+ to: Fire when state changes TO this exact value
98
+ changes: Fire on ANY state change
99
+
100
+ Example::
101
+
102
+ @app.when("sensor.temp", above=28)
103
+ async def hot(event):
104
+ await app.fan.turn_on(entity_id="fan.ventilatore")
105
+ """
106
+ conditions = {}
107
+ if above is not None:
108
+ conditions["above"] = above
109
+ if below is not None:
110
+ conditions["below"] = below
111
+ if to is not None:
112
+ conditions["to"] = to
113
+ if changes:
114
+ conditions["changes"] = True
115
+
116
+ def decorator(fn: Callable):
117
+ self._triggers.append((entity_id, conditions, fn))
118
+ return fn
119
+
120
+ return decorator
121
+
122
+ def schedule(self, expression: str):
123
+ """Decorator: run on a schedule.
124
+
125
+ Args:
126
+ expression: "every 30m", "every 2h", or "0 9 * * *"
127
+
128
+ Example::
129
+
130
+ @app.schedule("every 1h")
131
+ async def report():
132
+ temp = await app.get_state("sensor.temp")
133
+ app.log.info("Temp: %s", temp)
134
+ """
135
+
136
+ def decorator(fn: Callable):
137
+ self._scheduler.add(expression, fn)
138
+ return fn
139
+
140
+ return decorator
141
+
142
+ # -- state access ----------------------------------------------------------
143
+
144
+ async def get_state(self, entity_id: str) -> str | None:
145
+ """Get current state value of an entity."""
146
+ s = await self._engine.get_state(entity_id)
147
+ if s:
148
+ return s.get("state")
149
+ return None
150
+
151
+ async def get_states(self) -> list[dict]:
152
+ """Get all entity states."""
153
+ return await self._engine.get_states()
154
+
155
+ # -- lifecycle ------------------------------------------------------------
156
+
157
+ def run(self) -> None:
158
+ """Start the reactor (blocking)."""
159
+ try:
160
+ asyncio.run(self._run())
161
+ except KeyboardInterrupt:
162
+ self.log.info("Shutting down...")
163
+
164
+ async def start(self) -> None:
165
+ """Start the reactor (non-blocking). Call from async context."""
166
+ await self._connect()
167
+
168
+ async def stop(self) -> None:
169
+ """Stop the reactor."""
170
+ await self._shutdown()
171
+
172
+ # -- internal -------------------------------------------------------------
173
+
174
+ async def _run(self) -> None:
175
+ await self._connect()
176
+
177
+ # Keep alive until signal
178
+ loop = asyncio.get_running_loop()
179
+ stop_event = asyncio.Event()
180
+
181
+ for sig in (signal.SIGINT, signal.SIGTERM):
182
+ try:
183
+ loop.add_signal_handler(sig, stop_event.set)
184
+ except NotImplementedError:
185
+ pass # Windows
186
+
187
+ try:
188
+ await stop_event.wait()
189
+ finally:
190
+ await self._shutdown()
191
+
192
+ async def _connect(self) -> None:
193
+ await self._engine.connect()
194
+
195
+ # Register all triggers
196
+ for entity_id, conditions, fn in self._triggers:
197
+ self._engine.on_state_change(
198
+ entity_id,
199
+ self._make_listener(entity_id, conditions, fn),
200
+ )
201
+
202
+ self._running = True
203
+ self.log.info("Reactor running — %d triggers active", len(self._triggers))
204
+
205
+ def _make_listener(
206
+ self, entity_id: str, conditions: dict, fn: Callable,
207
+ ):
208
+ """Build a closure that filters events and calls the user function."""
209
+
210
+ async def listener(data: dict):
211
+ event = TriggerEvent(self._engine, data)
212
+
213
+ # Check conditions
214
+ if not conditions:
215
+ return # Should not happen
216
+
217
+ # "changes" — fire on any state change
218
+ if conditions.get("changes"):
219
+ await fn(event)
220
+ return
221
+
222
+ new_state = data.get("new_state")
223
+ if not new_state:
224
+ return
225
+
226
+ state_val = new_state.get("state", "")
227
+
228
+ # "to" — exact state match
229
+ if "to" in conditions:
230
+ if state_val == conditions["to"]:
231
+ await fn(event)
232
+ return
233
+
234
+ # "above" / "below" — numeric comparisons
235
+ try:
236
+ num_val = float(state_val)
237
+ except (TypeError, ValueError):
238
+ return
239
+
240
+ if "above" in conditions:
241
+ old_state = data.get("old_state") or {}
242
+ old_val = old_state.get("state", "")
243
+ try:
244
+ old_num = float(old_val)
245
+ except (TypeError, ValueError):
246
+ old_num = float("-inf")
247
+ # Fire only on crossing the threshold (not every poll while above)
248
+ if num_val > conditions["above"] and old_num <= conditions["above"]:
249
+ await fn(event)
250
+
251
+ if "below" in conditions:
252
+ old_state = data.get("old_state") or {}
253
+ old_val = old_state.get("state", "")
254
+ try:
255
+ old_num = float(old_val)
256
+ except (TypeError, ValueError):
257
+ old_num = float("inf")
258
+ if num_val < conditions["below"] and old_num >= conditions["below"]:
259
+ await fn(event)
260
+
261
+ return listener
262
+
263
+ async def _shutdown(self) -> None:
264
+ self._running = False
265
+ self._scheduler.cancel_all()
266
+ await self._engine.disconnect()
267
+ self.log.info("Reactor stopped")
@@ -0,0 +1,165 @@
1
+ """
2
+ Lightweight cron-like scheduler for recurring tasks.
3
+
4
+ Supports human-readable intervals:
5
+ @app.schedule("every 30m")
6
+ @app.schedule("every 2h")
7
+ @app.schedule("every 1h 30m")
8
+ @app.schedule("0 9 * * *") # cron expression
9
+ @app.schedule("*/5 * * * *") # every 5 minutes via cron
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ import re
16
+ import time
17
+ from typing import Callable, Awaitable
18
+
19
+ logger = logging.getLogger("hassreactor.scheduler")
20
+
21
+ # Pre-compiled patterns for human-readable intervals
22
+ _RE_EVERY = re.compile(
23
+ r"every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes|s|sec|secs|second|seconds)",
24
+ re.IGNORECASE,
25
+ )
26
+
27
+ # 5-field cron pattern
28
+ _RE_CRON = re.compile(
29
+ r"^(\*|[\d,\-*/]+)\s+(\*|[\d,\-*/]+)\s+(\*|[\d,\-*/]+)"
30
+ r"\s+(\*|[\d,\-*/]+)\s+(\*|[\d,\-*/]+)$"
31
+ )
32
+
33
+
34
+ def _parse_interval(expression: str) -> float:
35
+ """Parse human-readable interval into seconds.
36
+
37
+ Returns 0 if it looks like a cron expression.
38
+ """
39
+ total_seconds = 0
40
+ for match in _RE_EVERY.finditer(expression):
41
+ value = int(match.group(1))
42
+ unit = match.group(2).lower()[0]
43
+ if unit == "h":
44
+ total_seconds += value * 3600
45
+ elif unit == "m":
46
+ total_seconds += value * 60
47
+ elif unit == "s":
48
+ total_seconds += value
49
+ return total_seconds
50
+
51
+
52
+ def _cron_matches(cron_expr: str, now: None = None) -> bool:
53
+ """Check if a 5-field cron expression matches the current time."""
54
+ if now is None:
55
+ now = time.localtime()
56
+
57
+ m = _RE_CRON.match(cron_expr.strip())
58
+ if not m:
59
+ return False
60
+
61
+ fields = [m.group(i) for i in range(1, 6)]
62
+
63
+ now_fields = [
64
+ str(now.tm_min), # minute 0-59
65
+ str(now.tm_hour), # hour 0-23
66
+ str(now.tm_mday), # day 1-31
67
+ str(now.tm_mon), # month 1-12
68
+ str(now.tm_wday), # weekday 0-6 (Sun=0)
69
+ ]
70
+
71
+ for field_expr, now_val in zip(fields, now_fields):
72
+ if not _cron_field_matches(field_expr, now_val):
73
+ return False
74
+ return True
75
+
76
+
77
+ def _cron_field_matches(expr: str, value: str) -> bool:
78
+ if expr == "*":
79
+ return True
80
+ for part in expr.split(","):
81
+ if "/" in part:
82
+ base, step = part.split("/")
83
+ step = int(step)
84
+ base = 0 if base == "*" else int(base)
85
+ if (int(value) - base) % step == 0 and int(value) >= base:
86
+ return True
87
+ elif "-" in part:
88
+ lo, hi = part.split("-")
89
+ if int(lo) <= int(value) <= int(hi):
90
+ return True
91
+ elif part == value:
92
+ return True
93
+ return False
94
+
95
+
96
+ class Scheduler:
97
+ """Manages scheduled tasks."""
98
+
99
+ def __init__(self):
100
+ self._tasks: list[asyncio.Task] = []
101
+
102
+ def add(
103
+ self,
104
+ expression: str,
105
+ callback: Callable[[], Awaitable[None]],
106
+ ) -> None:
107
+ """Schedule a recurring task.
108
+
109
+ Args:
110
+ expression: "every 30m" or "0 9 * * *"
111
+ callback: async function to call
112
+ """
113
+ interval = _parse_interval(expression)
114
+ is_cron = _RE_CRON.match(expression.strip())
115
+
116
+ if interval > 0:
117
+ task = asyncio.create_task(_run_interval(callback, interval))
118
+ elif is_cron:
119
+ task = asyncio.create_task(_run_cron(callback, expression.strip()))
120
+ else:
121
+ raise ValueError(f"Cannot parse schedule: {expression!r}")
122
+
123
+ self._tasks.append(task)
124
+
125
+ def cancel_all(self) -> None:
126
+ for task in self._tasks:
127
+ task.cancel()
128
+ self._tasks.clear()
129
+
130
+
131
+ async def _run_interval(
132
+ callback: Callable[[], Awaitable[None]], interval: float,
133
+ ) -> None:
134
+ """Run callback every `interval` seconds."""
135
+ # Run once immediately
136
+ try:
137
+ await callback()
138
+ except Exception:
139
+ logger.exception("Error in scheduled task")
140
+ while True:
141
+ await asyncio.sleep(interval)
142
+ try:
143
+ await callback()
144
+ except asyncio.CancelledError:
145
+ break
146
+ except Exception:
147
+ logger.exception("Error in scheduled task")
148
+
149
+
150
+ async def _run_cron(
151
+ callback: Callable[[], Awaitable[None]], cron_expr: str,
152
+ ) -> None:
153
+ """Run callback when cron expression matches (check every 30s)."""
154
+ while True:
155
+ now = time.localtime()
156
+ if _cron_matches(cron_expr, now):
157
+ try:
158
+ await callback()
159
+ except Exception:
160
+ logger.exception("Error in scheduled task")
161
+ # Sleep past this minute to avoid double-firing
162
+ await asyncio.sleep(60 - now.tm_sec)
163
+ continue
164
+ # Re-check in 30s
165
+ await asyncio.sleep(30)
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.4
2
+ Name: hassreactor
3
+ Version: 0.2.0
4
+ Summary: Event-driven Home Assistant automations in Python
5
+ Home-page: https://github.com/N4S4/hassreactor
6
+ Author: Renato Visaggio
7
+ Author-email: synology.python.api@gmail.com
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Home Automation
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: aiohttp>=3.8
21
+ Dynamic: author
22
+ Dynamic: author-email
23
+ Dynamic: classifier
24
+ Dynamic: description
25
+ Dynamic: description-content-type
26
+ Dynamic: home-page
27
+ Dynamic: requires-dist
28
+ Dynamic: requires-python
29
+ Dynamic: summary
30
+
31
+ # hassreactor
32
+
33
+ Event-driven Home Assistant automations in Python. No YAML, no Node-RED, no AppDaemon — just Python.
34
+
35
+ ## Why
36
+
37
+ Home Assistant has a powerful automation engine, but it lives in YAML or a UI. Sometimes you just want to write a Python script:
38
+
39
+ - "If living room temp > 28°C, turn on fan"
40
+ - "If front door opens, send me a Telegram message"
41
+ - "Every hour, log the temperature"
42
+
43
+ hassreactor lets you write these as plain Python files using WebSocket events — no polling, no complex setup.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install hassreactor
49
+ ```
50
+
51
+ ## Quick Start
52
+
53
+ Create a file `automations.py`:
54
+
55
+ ```python
56
+ from hassreactor import Reactor
57
+
58
+ app = Reactor("http://homeassistant:8123", "your-long-lived-token")
59
+
60
+ @app.when("sensor.temperatura_salotto", above=28)
61
+ async def accendi_ventilatore(event):
62
+ """When temp goes above 28°C, turn on the fan."""
63
+ await app.fan.turn_on(entity_id="fan.ventilatore")
64
+
65
+ @app.when("binary_sensor.porta_ingresso", to="on")
66
+ async def porta_aperta(event):
67
+ """When front door opens, notify."""
68
+ await app.notify.telegram(message="Porta d'ingresso aperta!")
69
+
70
+ @app.schedule("every 1h")
71
+ async def report():
72
+ temp = await app.get_state("sensor.temperatura_salotto")
73
+ print(f"Current temperature: {temp}°C")
74
+
75
+ if __name__ == "__main__":
76
+ app.run()
77
+ ```
78
+
79
+ Run it:
80
+
81
+ ```bash
82
+ python automations.py
83
+ ```
84
+
85
+ ## Trigger Types
86
+
87
+ | Trigger | Description |
88
+ |---|---|
89
+ | `@app.when(entity, above=N)` | Numeric value crosses ABOVE threshold |
90
+ | `@app.when(entity, below=N)` | Numeric value crosses BELOW threshold |
91
+ | `@app.when(entity, to="on")` | State changes TO an exact value |
92
+ | `@app.when(entity, changes=True)` | ANY state change |
93
+ | `@app.schedule("every 30m")` | Run every 30 minutes |
94
+ | `@app.schedule("every 2h")` | Run every 2 hours |
95
+ | `@app.schedule("0 9 * * *")` | Cron expression (every day at 9am) |
96
+
97
+ ## Calling Services
98
+
99
+ Any HA service is available as a method on the domain:
100
+
101
+ ```python
102
+ await app.light.turn_on(entity_id="light.kitchen", brightness=128)
103
+ await app.climate.set_temperature(entity_id="climate.home", temperature=22)
104
+ await app.switch.toggle(entity_id="switch.pump")
105
+ await app.notify.telegram(message="Hello!")
106
+ ```
107
+
108
+ ## How It Works
109
+
110
+ hassreactor connects to Home Assistant via **WebSocket** and subscribes to `state_changed` events. When an entity you're watching changes state, your function runs instantly — no polling, no sleep loops.
111
+
112
+ Service calls use the REST API.
113
+
114
+ Only dependency: `aiohttp`.
115
+
116
+ ## License
117
+
118
+ MIT
@@ -0,0 +1,12 @@
1
+ README.md
2
+ setup.py
3
+ hassreactor/__init__.py
4
+ hassreactor/domain_proxy.py
5
+ hassreactor/engine.py
6
+ hassreactor/reactor.py
7
+ hassreactor/scheduler.py
8
+ hassreactor.egg-info/PKG-INFO
9
+ hassreactor.egg-info/SOURCES.txt
10
+ hassreactor.egg-info/dependency_links.txt
11
+ hassreactor.egg-info/requires.txt
12
+ hassreactor.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ aiohttp>=3.8
@@ -0,0 +1 @@
1
+ hassreactor
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """
2
+ hassreactor — Write Home Assistant automations in Python.
3
+
4
+ Event-driven, WebSocket-native, no YAML required.
5
+ """
6
+ from setuptools import setup, find_packages
7
+
8
+ with open("README.md", encoding="utf-8") as f:
9
+ long_description = f.read()
10
+
11
+ setup(
12
+ name="hassreactor",
13
+ version="0.2.0",
14
+ description="Event-driven Home Assistant automations in Python",
15
+ long_description=long_description,
16
+ long_description_content_type="text/markdown",
17
+ author="Renato Visaggio",
18
+ author_email="synology.python.api@gmail.com",
19
+ url="https://github.com/N4S4/hassreactor",
20
+ packages=find_packages(),
21
+ python_requires=">=3.9",
22
+ install_requires=[
23
+ "aiohttp>=3.8",
24
+ ],
25
+ classifiers=[
26
+ "Development Status :: 3 - Alpha",
27
+ "Intended Audience :: Developers",
28
+ "License :: OSI Approved :: MIT License",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Programming Language :: Python :: 3.13",
35
+ "Topic :: Home Automation",
36
+ ],
37
+ )