@ajclarkson/homerun 0.0.1-edge.01eba24
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.
- package/LICENSE +21 -0
- package/README.md +276 -0
- package/dist/scripts/generate-ha-types.d.ts +8 -0
- package/dist/scripts/generate-ha-types.js +81 -0
- package/dist/src/framework/action-runtime.d.ts +27 -0
- package/dist/src/framework/action-runtime.js +58 -0
- package/dist/src/framework/api-server.d.ts +29 -0
- package/dist/src/framework/api-server.js +107 -0
- package/dist/src/framework/ha-client.d.ts +47 -0
- package/dist/src/framework/ha-client.js +125 -0
- package/dist/src/framework/hot-reload.d.ts +9 -0
- package/dist/src/framework/hot-reload.js +87 -0
- package/dist/src/framework/observability.d.ts +34 -0
- package/dist/src/framework/observability.js +47 -0
- package/dist/src/framework/pipeline.d.ts +12 -0
- package/dist/src/framework/pipeline.js +56 -0
- package/dist/src/framework/registry.d.ts +10 -0
- package/dist/src/framework/registry.js +21 -0
- package/dist/src/framework/scheduler.d.ts +11 -0
- package/dist/src/framework/scheduler.js +35 -0
- package/dist/src/framework/timer-manager.d.ts +8 -0
- package/dist/src/framework/timer-manager.js +22 -0
- package/dist/src/framework/trigger-engine.d.ts +22 -0
- package/dist/src/framework/trigger-engine.js +223 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +98 -0
- package/dist/src/lib.d.ts +4 -0
- package/dist/src/lib.js +1 -0
- package/dist/src/testing.d.ts +16 -0
- package/dist/src/testing.js +25 -0
- package/dist/src/types/actions.d.ts +21 -0
- package/dist/src/types/actions.js +1 -0
- package/dist/src/types/automation.d.ts +25 -0
- package/dist/src/types/automation.js +9 -0
- package/dist/src/types/triggers.d.ts +50 -0
- package/dist/src/types/triggers.js +1 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Clarkson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Homerun
|
|
2
|
+
|
|
3
|
+
**Typed, testable Home Assistant automations in TypeScript.**
|
|
4
|
+
|
|
5
|
+
> **Status: early development.** The framework is not yet published to npm and the API is not stable. This README documents the intended design; some parts are still being built.
|
|
6
|
+
|
|
7
|
+
Homerun is a framework that replaces HA automations and Node-RED flows with pure functions. Every automation is a context builder and a reducer — no side effects, no hidden state, no YAML. The pipeline handles observability, error isolation, and action execution.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The problem
|
|
12
|
+
|
|
13
|
+
Home Assistant automations are YAML. Node-RED flows are JSON blobs. Both are hard to test, hard to review, and impossible to type-check. A bad deploy silently breaks your lights at 2am.
|
|
14
|
+
|
|
15
|
+
Homerun brings the same discipline you'd apply to application code: pure functions, typed inputs, and a test suite that runs in milliseconds.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
Every automation follows the same pipeline:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Event → context() → reduce() → actions[]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**`context()`** reads from the HA state cache and returns a typed snapshot — or aborts early if the guard condition isn't met. It is the only place that touches external state.
|
|
28
|
+
|
|
29
|
+
**`reduce()`** is a pure function. It receives the context and returns a decision with a list of declarative actions. No async, no side effects, no HA calls.
|
|
30
|
+
|
|
31
|
+
**The pipeline** handles everything else: correlation IDs, observability snapshots, error isolation, and action execution.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Example
|
|
36
|
+
|
|
37
|
+
```typescript
|
|
38
|
+
import { defineAutomation, abort } from './src/types/automation.js';
|
|
39
|
+
|
|
40
|
+
export const kitchenLights = defineAutomation({
|
|
41
|
+
id: 'kitchen:lighting',
|
|
42
|
+
location: 'kitchen',
|
|
43
|
+
subsystem: 'lighting',
|
|
44
|
+
|
|
45
|
+
triggers: [
|
|
46
|
+
{ type: 'state_changed', entity: 'binary_sensor.kitchen_motion' },
|
|
47
|
+
{ type: 'state_changed', entity: 'binary_sensor.kitchen_door' },
|
|
48
|
+
{ type: 'on_start' },
|
|
49
|
+
],
|
|
50
|
+
|
|
51
|
+
context(state, ha) {
|
|
52
|
+
const enabled = state('input_boolean.kitchen_automation_lights_enabled');
|
|
53
|
+
if (enabled?.state !== 'on') return abort('automation_disabled');
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
motion: state('binary_sensor.kitchen_motion')?.state === 'on',
|
|
57
|
+
lux: Number(state('sensor.kitchen_sensor_lux')?.state ?? 0),
|
|
58
|
+
luxThreshold: Number(state('input_number.kitchen_automation_lux_threshold_dark')?.state ?? 40),
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
reduce(ctx) {
|
|
63
|
+
const shouldLight = ctx.motion && ctx.lux < ctx.luxThreshold;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
decision: shouldLight ? 'lights_on' : 'lights_off',
|
|
67
|
+
inputs: ctx,
|
|
68
|
+
actions: [
|
|
69
|
+
{
|
|
70
|
+
type: 'ha.call_service',
|
|
71
|
+
domain: 'light',
|
|
72
|
+
service: shouldLight ? 'turn_on' : 'turn_off',
|
|
73
|
+
target: { entity_id: 'light.kitchen_light_ceiling' },
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Testing
|
|
84
|
+
|
|
85
|
+
Because `reduce()` is a pure function, tests are just function calls:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { describe, it, expect } from 'vitest';
|
|
89
|
+
import { kitchenLights } from './kitchen-lights';
|
|
90
|
+
|
|
91
|
+
describe('kitchenLights reducer', () => {
|
|
92
|
+
it('turns lights on when motion is detected below lux threshold', () => {
|
|
93
|
+
const result = kitchenLights.reduce({ motion: true, lux: 20, luxThreshold: 40 });
|
|
94
|
+
expect(result.decision).toBe('lights_on');
|
|
95
|
+
expect(result.actions[0]).toMatchObject({ service: 'turn_on' });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('turns lights off when lux is already high', () => {
|
|
99
|
+
const result = kitchenLights.reduce({ motion: true, lux: 80, luxThreshold: 40 });
|
|
100
|
+
expect(result.decision).toBe('lights_off');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('turns lights off when motion clears', () => {
|
|
104
|
+
const result = kitchenLights.reduce({ motion: false, lux: 10, luxThreshold: 40 });
|
|
105
|
+
expect(result.decision).toBe('lights_off');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
No mocking, no HA connection, no async. The context builder can be tested separately with a plain function as the state accessor:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const mockState = (entity: string) => ({
|
|
114
|
+
'input_boolean.kitchen_automation_lights_enabled': { state: 'on' },
|
|
115
|
+
'binary_sensor.kitchen_motion': { state: 'on' },
|
|
116
|
+
'sensor.kitchen_sensor_lux': { state: '20' },
|
|
117
|
+
'input_number.kitchen_automation_lux_threshold_dark': { state: '40' },
|
|
118
|
+
})[entity] as any;
|
|
119
|
+
|
|
120
|
+
const ctx = kitchenLights.context(mockState, mockHAContext);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Trigger types
|
|
126
|
+
|
|
127
|
+
| Trigger | Fires when |
|
|
128
|
+
|---------|-----------|
|
|
129
|
+
| `state_changed` | An entity's state or attributes change. Accepts a string or `RegExp` for the entity ID. |
|
|
130
|
+
| `schedule` | A cron expression fires. |
|
|
131
|
+
| `on_start` | The system is ready and the state cache is fully populated. |
|
|
132
|
+
| `timer_expired` | A named timer set by a previous `timer.start` action expires. |
|
|
133
|
+
| `button` | A Zigbee button entity emits a `single_press`, `double_press`, or `hold` gesture. |
|
|
134
|
+
| `mqtt_in` | A message arrives on a subscribed MQTT topic. |
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
triggers: [
|
|
138
|
+
{ type: 'state_changed', entity: 'binary_sensor.parlour_motion' },
|
|
139
|
+
{ type: 'state_changed', entity: /^binary_sensor\..+_motion$/ },
|
|
140
|
+
{ type: 'schedule', cron: '0 22 * * *' },
|
|
141
|
+
{ type: 'on_start' },
|
|
142
|
+
{ type: 'timer_expired', timerKey: 'kitchen:lights:off-delay' },
|
|
143
|
+
{ type: 'button', entity: 'sensor.hallway_button', gesture: 'double_press' },
|
|
144
|
+
]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Action types
|
|
150
|
+
|
|
151
|
+
Actions are returned from `reduce()` as a plain array. The runtime executes them after the observability snapshot is published.
|
|
152
|
+
|
|
153
|
+
| Action | Effect |
|
|
154
|
+
|--------|--------|
|
|
155
|
+
| `ha.call_service` | Calls a Home Assistant service |
|
|
156
|
+
| `mqtt.publish` | Publishes to an MQTT topic |
|
|
157
|
+
| `timer.start` | Starts (or restarts) a named timer |
|
|
158
|
+
| `timer.cancel` | Cancels a named timer |
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
actions: [
|
|
162
|
+
{
|
|
163
|
+
type: 'ha.call_service',
|
|
164
|
+
domain: 'climate',
|
|
165
|
+
service: 'set_temperature',
|
|
166
|
+
target: { entity_id: 'climate.bedroom_trv' },
|
|
167
|
+
data: { temperature: 20 },
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
type: 'timer.start',
|
|
171
|
+
timerKey: 'kitchen:lights:off-delay',
|
|
172
|
+
delayMs: 120_000,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
type: 'mqtt.publish',
|
|
176
|
+
topic: 'homerun/decisions/kitchen',
|
|
177
|
+
payload: JSON.stringify({ decision: 'lights_on' }),
|
|
178
|
+
retain: false,
|
|
179
|
+
},
|
|
180
|
+
]
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Observability
|
|
186
|
+
|
|
187
|
+
Every pipeline run publishes a decision snapshot to MQTT — whether it completed, aborted, or threw. Each snapshot carries a `correlation_id` minted at the event source, so you can trace a HA state change through to the TRV setpoint that resulted from it.
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"schema": "home.events.v1",
|
|
192
|
+
"correlation_id": "b3d2f1a0-...",
|
|
193
|
+
"automation_id": "kitchen:lighting",
|
|
194
|
+
"location": "kitchen",
|
|
195
|
+
"subsystem": "lighting",
|
|
196
|
+
"event_type": "decision",
|
|
197
|
+
"decision": "lights_on",
|
|
198
|
+
"inputs": { "motion": true, "lux": 20, "luxThreshold": 40 },
|
|
199
|
+
"actions": [{ "type": "ha.call_service", "domain": "light", "service": "turn_on" }],
|
|
200
|
+
"timestamp": "2026-06-23T18:00:00.000Z"
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Environment variables
|
|
207
|
+
|
|
208
|
+
| Variable | Purpose |
|
|
209
|
+
|----------|---------|
|
|
210
|
+
| `HA_URL` | Home Assistant base URL (e.g. `http://homeassistant.local:8123`) |
|
|
211
|
+
| `HA_TOKEN` | Long-lived access token |
|
|
212
|
+
| `MQTT_URL` | MQTT broker URL (e.g. `mqtt://localhost:1883`) |
|
|
213
|
+
| `AUTOMATIONS_DIR` | Directory of automation files (hot-reloaded on change) |
|
|
214
|
+
| `DRY_RUN` | Set to `true` to log actions without executing them |
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Running
|
|
219
|
+
|
|
220
|
+
> Homerun is not yet published to npm. Clone the repository and run directly from source.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# Development — hot-reloads automations on file change
|
|
224
|
+
npm run dev
|
|
225
|
+
|
|
226
|
+
# Run tests
|
|
227
|
+
npm test
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Set `DRY_RUN=true` to run the full pipeline — context, reduce, observability — without making any HA service calls. This is the default for local development.
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## API
|
|
235
|
+
|
|
236
|
+
The server exposes an HTTP API on port `7070` by default.
|
|
237
|
+
|
|
238
|
+
| Method | Path | Description |
|
|
239
|
+
|--------|------|-------------|
|
|
240
|
+
| `GET` | `/health/live` | Always returns `200 { status: "live" }` — used for liveness probes. |
|
|
241
|
+
| `GET` | `/health/ready` | Returns `200` when the HA state cache is populated and automations are loaded, `503` while starting. Response includes `entities`, `automations` counts, and `dry_run: true` if running in dry-run mode. |
|
|
242
|
+
| `GET` | `/automations` | Lists all registered automations with their `id`, `location`, `subsystem`, and trigger types. |
|
|
243
|
+
| `POST` | `/automations/:id/trigger` | Manually fires an `on_start` event for the given automation. |
|
|
244
|
+
| `POST` | `/reload` | Rescans the automations directory and hot-reloads changed files. |
|
|
245
|
+
| `GET` | `/events` | Server-sent event stream of all pipeline decisions and action events in real time. |
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Architecture
|
|
250
|
+
|
|
251
|
+
```
|
|
252
|
+
HAClient ──────────────────────────────────────┐
|
|
253
|
+
└─ state_changed (per entity, with corr. ID) │
|
|
254
|
+
▼
|
|
255
|
+
Scheduler (cron / on_start) ──────► TriggerEngine.dispatch()
|
|
256
|
+
TimerManager (setTimeout) ──────► │
|
|
257
|
+
▼
|
|
258
|
+
matchAndFire()
|
|
259
|
+
│
|
|
260
|
+
┌───────────▼──────────┐
|
|
261
|
+
│ Pipeline Runner │
|
|
262
|
+
│ context → reduce │
|
|
263
|
+
│ → validate → fanout │
|
|
264
|
+
└──────────┬────────────┘
|
|
265
|
+
│
|
|
266
|
+
┌──────────────┴──────────────┐
|
|
267
|
+
▼ ▼
|
|
268
|
+
Observability ActionRuntime
|
|
269
|
+
(MQTT snapshot) (HA calls / timers / MQTT)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface HAStatePayload {
|
|
3
|
+
entity_id: string;
|
|
4
|
+
state: string;
|
|
5
|
+
attributes: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
export declare function inferStateType(entity: HAStatePayload, allObservedStates?: string[]): string;
|
|
8
|
+
export declare function generateFileContent(states: HAStatePayload[]): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
// ---------- Inference ----------
|
|
5
|
+
export function inferStateType(entity, allObservedStates) {
|
|
6
|
+
const domain = entity.entity_id.split('.')[0];
|
|
7
|
+
switch (domain) {
|
|
8
|
+
case 'binary_sensor':
|
|
9
|
+
case 'input_boolean':
|
|
10
|
+
case 'switch':
|
|
11
|
+
return "'on' | 'off'";
|
|
12
|
+
case 'input_select': {
|
|
13
|
+
const options = entity.attributes.options;
|
|
14
|
+
if (Array.isArray(options) && options.length > 0) {
|
|
15
|
+
return options.map((o) => `'${o}'`).join(' | ');
|
|
16
|
+
}
|
|
17
|
+
return 'string';
|
|
18
|
+
}
|
|
19
|
+
case 'person': {
|
|
20
|
+
const states = allObservedStates ?? [entity.state];
|
|
21
|
+
return [...new Set(states)].map((s) => `'${s}'`).join(' | ');
|
|
22
|
+
}
|
|
23
|
+
default:
|
|
24
|
+
return 'string';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// ---------- File generation ----------
|
|
28
|
+
export function generateFileContent(states) {
|
|
29
|
+
const personStates = new Map();
|
|
30
|
+
for (const s of states) {
|
|
31
|
+
if (s.entity_id.startsWith('person.')) {
|
|
32
|
+
const existing = personStates.get(s.entity_id) ?? [];
|
|
33
|
+
existing.push(s.state);
|
|
34
|
+
personStates.set(s.entity_id, existing);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const entries = states
|
|
38
|
+
.map((s) => {
|
|
39
|
+
const stateType = inferStateType(s, personStates.get(s.entity_id));
|
|
40
|
+
return ` '${s.entity_id}': { state: ${stateType} };`;
|
|
41
|
+
})
|
|
42
|
+
.join('\n');
|
|
43
|
+
return `// generated — do not edit — run: npm run generate:ha-types
|
|
44
|
+
export interface HAEntities {
|
|
45
|
+
${entries}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type HAState = {
|
|
49
|
+
<E extends keyof HAEntities>(entity: E): HAEntities[E]['state'];
|
|
50
|
+
<E extends string>(entity: E): string | undefined;
|
|
51
|
+
};
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
// ---------- CLI ----------
|
|
55
|
+
async function main() {
|
|
56
|
+
const url = process.env.HA_URL;
|
|
57
|
+
const token = process.env.HA_TOKEN;
|
|
58
|
+
if (!url || !token) {
|
|
59
|
+
console.error('Error: HA_URL and HA_TOKEN must be set');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const res = await fetch(`${url}/api/states`, {
|
|
63
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
console.error(`Error: HA API returned ${res.status} ${res.statusText}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const states = (await res.json());
|
|
70
|
+
const content = generateFileContent(states);
|
|
71
|
+
const outPath = path.join(process.cwd(), 'types', 'ha-entities.ts');
|
|
72
|
+
await mkdir(path.dirname(outPath), { recursive: true });
|
|
73
|
+
await writeFile(outPath, content, 'utf8');
|
|
74
|
+
console.log(`Written ${states.length} entities to ${outPath}`);
|
|
75
|
+
}
|
|
76
|
+
if (process.argv[1]?.endsWith('generate-ha-types.ts') || process.argv[1]?.endsWith('generate-ha-types.js') || process.argv[1]?.endsWith('homerun-generate-ha-types')) {
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error('Fatal:', err);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MqttClient } from 'mqtt';
|
|
2
|
+
import type { Action } from '../types/actions.js';
|
|
3
|
+
import type { HAClient } from './ha-client.js';
|
|
4
|
+
import type { TimerManager } from './timer-manager.js';
|
|
5
|
+
import type { Observability } from './observability.js';
|
|
6
|
+
export interface ExecutionContext {
|
|
7
|
+
correlationId: string;
|
|
8
|
+
automationId: string;
|
|
9
|
+
location: string;
|
|
10
|
+
subsystem: string;
|
|
11
|
+
}
|
|
12
|
+
interface Deps {
|
|
13
|
+
haClient: HAClient;
|
|
14
|
+
mqttClient: MqttClient;
|
|
15
|
+
timerManager: TimerManager;
|
|
16
|
+
observability: Observability;
|
|
17
|
+
dryRun: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare class ActionRuntime {
|
|
20
|
+
private readonly deps;
|
|
21
|
+
constructor(deps: Deps);
|
|
22
|
+
execute(actions: Action[], ctx: ExecutionContext): Promise<void>;
|
|
23
|
+
private runAction;
|
|
24
|
+
private dispatch;
|
|
25
|
+
private makeEvent;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export class ActionRuntime {
|
|
2
|
+
deps;
|
|
3
|
+
constructor(deps) {
|
|
4
|
+
this.deps = deps;
|
|
5
|
+
}
|
|
6
|
+
async execute(actions, ctx) {
|
|
7
|
+
for (const action of actions) {
|
|
8
|
+
await this.runAction(action, ctx);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
async runAction(action, ctx) {
|
|
12
|
+
this.deps.observability.publishActionEvent(this.makeEvent(ctx, 'action_started', action));
|
|
13
|
+
try {
|
|
14
|
+
if (!this.deps.dryRun) {
|
|
15
|
+
await this.dispatch(action);
|
|
16
|
+
}
|
|
17
|
+
this.deps.observability.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason: 'ok' }));
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
21
|
+
this.deps.observability.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async dispatch(action) {
|
|
25
|
+
switch (action.type) {
|
|
26
|
+
case 'ha.call_service':
|
|
27
|
+
await this.deps.haClient.callService(action.domain, action.service, action.target, action.data);
|
|
28
|
+
break;
|
|
29
|
+
case 'mqtt.publish':
|
|
30
|
+
await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
|
|
31
|
+
break;
|
|
32
|
+
case 'timer.start':
|
|
33
|
+
this.deps.timerManager.start(action.timerKey, action.delayMs);
|
|
34
|
+
break;
|
|
35
|
+
case 'timer.cancel':
|
|
36
|
+
this.deps.timerManager.cancel(action.timerKey);
|
|
37
|
+
break;
|
|
38
|
+
default: {
|
|
39
|
+
const unknown = action.type;
|
|
40
|
+
throw new Error(`unknown action type: ${unknown}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
makeEvent(ctx, event_type, action, extra = {}) {
|
|
45
|
+
return {
|
|
46
|
+
schema: 'home.events.v1',
|
|
47
|
+
correlation_id: ctx.correlationId,
|
|
48
|
+
automation_id: ctx.automationId,
|
|
49
|
+
location: ctx.location,
|
|
50
|
+
subsystem: ctx.subsystem,
|
|
51
|
+
event_type,
|
|
52
|
+
actions: [action],
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
55
|
+
...extra,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AutomationRegistry } from './registry.js';
|
|
2
|
+
import type { Observability } from './observability.js';
|
|
3
|
+
import type { Automation } from '../types/automation.js';
|
|
4
|
+
import type { TriggerEvent } from '../types/triggers.js';
|
|
5
|
+
export interface ApiServerDeps {
|
|
6
|
+
registry: AutomationRegistry;
|
|
7
|
+
onTrigger: (automation: Automation<unknown>, event: TriggerEvent) => void;
|
|
8
|
+
onReload: () => Promise<void>;
|
|
9
|
+
isReady: () => boolean;
|
|
10
|
+
entityCount: () => number;
|
|
11
|
+
observability: Observability;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare class ApiServer {
|
|
15
|
+
private readonly deps;
|
|
16
|
+
private server;
|
|
17
|
+
private _port;
|
|
18
|
+
constructor(deps: ApiServerDeps);
|
|
19
|
+
get port(): number | null;
|
|
20
|
+
start(port?: number): Promise<void>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
private handle;
|
|
23
|
+
private getAutomations;
|
|
24
|
+
private postTrigger;
|
|
25
|
+
private postReload;
|
|
26
|
+
private getHealthLive;
|
|
27
|
+
private getHealthReady;
|
|
28
|
+
private getEvents;
|
|
29
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
export class ApiServer {
|
|
3
|
+
deps;
|
|
4
|
+
server = null;
|
|
5
|
+
_port = null;
|
|
6
|
+
constructor(deps) {
|
|
7
|
+
this.deps = deps;
|
|
8
|
+
}
|
|
9
|
+
get port() {
|
|
10
|
+
return this._port;
|
|
11
|
+
}
|
|
12
|
+
start(port = 7070) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
this.server = createServer((req, res) => this.handle(req, res));
|
|
15
|
+
this.server.listen(port, '0.0.0.0', () => {
|
|
16
|
+
const addr = this.server.address();
|
|
17
|
+
this._port = typeof addr === 'object' && addr ? addr.port : port;
|
|
18
|
+
console.log(`[homerun] API server listening on port ${this._port}`);
|
|
19
|
+
resolve();
|
|
20
|
+
});
|
|
21
|
+
this.server.once('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
stop() {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
if (!this.server)
|
|
27
|
+
return resolve();
|
|
28
|
+
this.server.close((err) => (err ? reject(err) : resolve()));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
handle(req, res) {
|
|
32
|
+
const method = req.method ?? 'GET';
|
|
33
|
+
const url = req.url ?? '/';
|
|
34
|
+
if (method === 'GET' && url === '/automations')
|
|
35
|
+
return this.getAutomations(res);
|
|
36
|
+
if (method === 'POST' && url === '/reload')
|
|
37
|
+
return this.postReload(res);
|
|
38
|
+
if (method === 'GET' && url === '/health/live')
|
|
39
|
+
return this.getHealthLive(res);
|
|
40
|
+
if (method === 'GET' && url === '/health/ready')
|
|
41
|
+
return this.getHealthReady(res);
|
|
42
|
+
if (method === 'GET' && url === '/events')
|
|
43
|
+
return this.getEvents(req, res);
|
|
44
|
+
const triggerMatch = method === 'POST' && url.match(/^\/automations\/(.+)\/trigger$/);
|
|
45
|
+
if (triggerMatch)
|
|
46
|
+
return this.postTrigger(triggerMatch[1], res);
|
|
47
|
+
json(res, 404, { error: 'not found' });
|
|
48
|
+
}
|
|
49
|
+
getAutomations(res) {
|
|
50
|
+
const automations = this.deps.registry.getAll().map((a) => ({
|
|
51
|
+
id: a.id,
|
|
52
|
+
location: a.location,
|
|
53
|
+
subsystem: a.subsystem,
|
|
54
|
+
triggerTypes: a.triggers.map((t) => t.type),
|
|
55
|
+
}));
|
|
56
|
+
json(res, 200, automations);
|
|
57
|
+
}
|
|
58
|
+
postTrigger(id, res) {
|
|
59
|
+
const automation = this.deps.registry.getById(id);
|
|
60
|
+
if (!automation) {
|
|
61
|
+
json(res, 404, { error: `no automation with id "${id}"` });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.deps.onTrigger(automation, { type: 'on_start', correlation_id: crypto.randomUUID() });
|
|
65
|
+
json(res, 200, { ok: true });
|
|
66
|
+
}
|
|
67
|
+
postReload(res) {
|
|
68
|
+
this.deps.onReload()
|
|
69
|
+
.then(() => json(res, 200, { ok: true }))
|
|
70
|
+
.catch((err) => {
|
|
71
|
+
console.error('[ApiServer] reload failed:', err);
|
|
72
|
+
json(res, 500, { error: 'reload failed' });
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
getHealthLive(res) {
|
|
76
|
+
json(res, 200, { status: 'live' });
|
|
77
|
+
}
|
|
78
|
+
getHealthReady(res) {
|
|
79
|
+
if (!this.deps.isReady()) {
|
|
80
|
+
json(res, 503, { status: 'starting' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
json(res, 200, {
|
|
84
|
+
status: 'ready',
|
|
85
|
+
entities: this.deps.entityCount(),
|
|
86
|
+
automations: this.deps.registry.getAll().length,
|
|
87
|
+
...(this.deps.dryRun && { dry_run: true }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
getEvents(req, res) {
|
|
91
|
+
res.writeHead(200, {
|
|
92
|
+
'Content-Type': 'text/event-stream',
|
|
93
|
+
'Cache-Control': 'no-cache',
|
|
94
|
+
Connection: 'keep-alive',
|
|
95
|
+
});
|
|
96
|
+
res.flushHeaders();
|
|
97
|
+
const unsubscribe = this.deps.observability.subscribe((event) => {
|
|
98
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
99
|
+
});
|
|
100
|
+
req.on('close', unsubscribe);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function json(res, status, body) {
|
|
104
|
+
const payload = JSON.stringify(body);
|
|
105
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
106
|
+
res.end(payload);
|
|
107
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type HassServiceTarget } from 'home-assistant-js-websocket';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
export interface EntityState {
|
|
4
|
+
entity_id: string;
|
|
5
|
+
state: string;
|
|
6
|
+
attributes: Record<string, unknown>;
|
|
7
|
+
last_changed: string;
|
|
8
|
+
last_updated: string;
|
|
9
|
+
}
|
|
10
|
+
export type HAState = (entity: string) => EntityState | undefined;
|
|
11
|
+
export interface HAContext {
|
|
12
|
+
entitiesByLabel: (label: string) => string[];
|
|
13
|
+
labelsFor: (entity: string) => string[];
|
|
14
|
+
entitiesByArea: (area: string) => string[];
|
|
15
|
+
}
|
|
16
|
+
export interface StateChangedEvent {
|
|
17
|
+
entity_id: string;
|
|
18
|
+
old_state: EntityState | undefined;
|
|
19
|
+
new_state: EntityState;
|
|
20
|
+
correlation_id: string;
|
|
21
|
+
}
|
|
22
|
+
export declare interface HAClient {
|
|
23
|
+
on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
|
|
24
|
+
on(event: 'ready', listener: () => void): this;
|
|
25
|
+
on(event: 'reconnected', listener: () => void): this;
|
|
26
|
+
emit(event: 'state_changed', e: StateChangedEvent): boolean;
|
|
27
|
+
emit(event: 'ready'): boolean;
|
|
28
|
+
emit(event: 'reconnected'): boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare class HAClient extends EventEmitter {
|
|
31
|
+
private readonly stateCache;
|
|
32
|
+
private readonly labelToEntities;
|
|
33
|
+
private readonly entityToLabels;
|
|
34
|
+
private readonly areaToEntities;
|
|
35
|
+
private connection;
|
|
36
|
+
private reconnecting;
|
|
37
|
+
private _readyResolve;
|
|
38
|
+
readonly ready: Promise<void>;
|
|
39
|
+
readonly state: HAState;
|
|
40
|
+
readonly context: HAContext;
|
|
41
|
+
get entityCount(): number;
|
|
42
|
+
callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>): Promise<void>;
|
|
43
|
+
connect(url: string, token: string): Promise<void>;
|
|
44
|
+
private repopulateCache;
|
|
45
|
+
private diffAndUpdate;
|
|
46
|
+
private loadEntityRegistry;
|
|
47
|
+
}
|