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.
- hassreactor-0.2.0/PKG-INFO +118 -0
- hassreactor-0.2.0/README.md +88 -0
- hassreactor-0.2.0/hassreactor/__init__.py +6 -0
- hassreactor-0.2.0/hassreactor/domain_proxy.py +67 -0
- hassreactor-0.2.0/hassreactor/engine.py +188 -0
- hassreactor-0.2.0/hassreactor/reactor.py +267 -0
- hassreactor-0.2.0/hassreactor/scheduler.py +165 -0
- hassreactor-0.2.0/hassreactor.egg-info/PKG-INFO +118 -0
- hassreactor-0.2.0/hassreactor.egg-info/SOURCES.txt +12 -0
- hassreactor-0.2.0/hassreactor.egg-info/dependency_links.txt +1 -0
- hassreactor-0.2.0/hassreactor.egg-info/requires.txt +1 -0
- hassreactor-0.2.0/hassreactor.egg-info/top_level.txt +1 -0
- hassreactor-0.2.0/setup.cfg +4 -0
- hassreactor-0.2.0/setup.py +37 -0
|
@@ -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,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
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aiohttp>=3.8
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hassreactor
|
|
@@ -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
|
+
)
|