@ajclarkson/homerun 0.0.1-edge.f1d8985 → 0.0.1
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/README.md +1 -17
- package/dist/src/framework/action-runtime.js +2 -2
- package/dist/src/framework/api-server.d.ts +0 -1
- package/dist/src/framework/api-server.js +1 -8
- package/dist/src/framework/hot-reload.d.ts +0 -2
- package/dist/src/framework/hot-reload.js +2 -41
- package/dist/src/framework/observability.d.ts +1 -10
- package/dist/src/framework/observability.js +0 -13
- package/dist/src/framework/pipeline.d.ts +0 -1
- package/dist/src/framework/pipeline.js +4 -5
- package/dist/src/framework/trigger-engine.js +1 -1
- package/dist/src/index.js +25 -18
- package/dist/src/testing.d.ts +0 -2
- package/dist/src/testing.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,7 +131,6 @@ const ctx = kitchenLights.context(mockState, mockHAContext);
|
|
|
131
131
|
| `on_start` | The system is ready and the state cache is fully populated. |
|
|
132
132
|
| `timer_expired` | A named timer set by a previous `timer.start` action expires. |
|
|
133
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
134
|
|
|
136
135
|
```typescript
|
|
137
136
|
triggers: [
|
|
@@ -193,7 +192,7 @@ Every pipeline run publishes a decision snapshot to MQTT — whether it complete
|
|
|
193
192
|
"automation_id": "kitchen:lighting",
|
|
194
193
|
"location": "kitchen",
|
|
195
194
|
"subsystem": "lighting",
|
|
196
|
-
"
|
|
195
|
+
"type": "decision",
|
|
197
196
|
"decision": "lights_on",
|
|
198
197
|
"inputs": { "motion": true, "lux": 20, "luxThreshold": 40 },
|
|
199
198
|
"actions": [{ "type": "ha.call_service", "domain": "light", "service": "turn_on" }],
|
|
@@ -231,21 +230,6 @@ Set `DRY_RUN=true` to run the full pipeline — context, reduce, observability
|
|
|
231
230
|
|
|
232
231
|
---
|
|
233
232
|
|
|
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
233
|
## Architecture
|
|
250
234
|
|
|
251
235
|
```
|
|
@@ -41,14 +41,14 @@ export class ActionRuntime {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
-
makeEvent(ctx,
|
|
44
|
+
makeEvent(ctx, type, action, extra = {}) {
|
|
45
45
|
return {
|
|
46
46
|
schema: 'home.events.v1',
|
|
47
47
|
correlation_id: ctx.correlationId,
|
|
48
48
|
automation_id: ctx.automationId,
|
|
49
49
|
location: ctx.location,
|
|
50
50
|
subsystem: ctx.subsystem,
|
|
51
|
-
|
|
51
|
+
type,
|
|
52
52
|
actions: [action],
|
|
53
53
|
timestamp: new Date().toISOString(),
|
|
54
54
|
...(this.deps.dryRun ? { dry_run: true } : {}),
|
|
@@ -51,7 +51,7 @@ export class ApiServer {
|
|
|
51
51
|
id: a.id,
|
|
52
52
|
location: a.location,
|
|
53
53
|
subsystem: a.subsystem,
|
|
54
|
-
|
|
54
|
+
triggerTypes: a.triggers.map((t) => t.type),
|
|
55
55
|
}));
|
|
56
56
|
json(res, 200, automations);
|
|
57
57
|
}
|
|
@@ -84,7 +84,6 @@ export class ApiServer {
|
|
|
84
84
|
status: 'ready',
|
|
85
85
|
entities: this.deps.entityCount(),
|
|
86
86
|
automations: this.deps.registry.getAll().length,
|
|
87
|
-
...(this.deps.dryRun && { dry_run: true }),
|
|
88
87
|
});
|
|
89
88
|
}
|
|
90
89
|
getEvents(req, res) {
|
|
@@ -100,12 +99,6 @@ export class ApiServer {
|
|
|
100
99
|
req.on('close', unsubscribe);
|
|
101
100
|
}
|
|
102
101
|
}
|
|
103
|
-
function serializeTrigger(trigger) {
|
|
104
|
-
if (trigger.type === 'state_changed' && trigger.entity instanceof RegExp) {
|
|
105
|
-
return { ...trigger, entity: trigger.entity.toString() };
|
|
106
|
-
}
|
|
107
|
-
return trigger;
|
|
108
|
-
}
|
|
109
102
|
function json(res, status, body) {
|
|
110
103
|
const payload = JSON.stringify(body);
|
|
111
104
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
@@ -3,7 +3,5 @@ type Importer = (dataUri: string) => Promise<{
|
|
|
3
3
|
default: unknown;
|
|
4
4
|
}>;
|
|
5
5
|
export declare function _reloadFile(filePath: string, registry: AutomationRegistry, importer?: Importer, fileToIds?: Map<string, string[]>): Promise<void>;
|
|
6
|
-
export declare function _deleteFile(filePath: string, registry: AutomationRegistry, fileToIds?: Map<string, string[]>): void;
|
|
7
|
-
export declare function rescanAutomations(automationsDir: string, registry: AutomationRegistry, fileToIds?: Map<string, string[]>, importer?: Importer): Promise<void>;
|
|
8
6
|
export declare function startHotReload(automationsDir: string, registry: AutomationRegistry): void;
|
|
9
7
|
export {};
|
|
@@ -12,10 +12,7 @@ export async function _reloadFile(filePath, registry, importer = defaultImporter
|
|
|
12
12
|
platform: 'node',
|
|
13
13
|
format: 'esm',
|
|
14
14
|
write: false,
|
|
15
|
-
alias: {
|
|
16
|
-
'@ajclarkson/homerun/testing': path.resolve(import.meta.dirname, '../testing.js'),
|
|
17
|
-
'@ajclarkson/homerun': path.resolve(import.meta.dirname, '../lib.js'),
|
|
18
|
-
},
|
|
15
|
+
alias: { homerun: path.resolve(import.meta.dirname, '../lib.js') },
|
|
19
16
|
});
|
|
20
17
|
const code = result.outputFiles[0].text;
|
|
21
18
|
const dataUri = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
|
|
@@ -39,49 +36,13 @@ export async function _reloadFile(filePath, registry, importer = defaultImporter
|
|
|
39
36
|
console.error(`[hot-reload] failed to reload ${filePath}:`, err);
|
|
40
37
|
}
|
|
41
38
|
}
|
|
42
|
-
export function _deleteFile(filePath, registry, fileToIds = moduleFileToIds) {
|
|
43
|
-
for (const id of fileToIds.get(filePath) ?? []) {
|
|
44
|
-
registry.unregister(id);
|
|
45
|
-
}
|
|
46
|
-
fileToIds.delete(filePath);
|
|
47
|
-
}
|
|
48
|
-
const isAutomationFile = (f) => f.endsWith('.ts') &&
|
|
49
|
-
!f.endsWith('.test.ts') &&
|
|
50
|
-
!f.includes('node_modules') &&
|
|
51
|
-
!f.includes('.d.ts') &&
|
|
52
|
-
!f.split(path.sep).includes('types');
|
|
53
|
-
export async function rescanAutomations(automationsDir, registry, fileToIds = moduleFileToIds, importer = defaultImporter) {
|
|
54
|
-
let files = [];
|
|
55
|
-
try {
|
|
56
|
-
const { readdir } = await import('node:fs/promises');
|
|
57
|
-
files = (await readdir(automationsDir, { recursive: true }));
|
|
58
|
-
}
|
|
59
|
-
catch {
|
|
60
|
-
console.warn(`[homerun] AUTOMATIONS_DIR not found: ${automationsDir} — starting with no automations`);
|
|
61
|
-
}
|
|
62
|
-
const currentPaths = new Set(files.filter(isAutomationFile).map((f) => path.join(automationsDir, f)));
|
|
63
|
-
for (const trackedPath of [...fileToIds.keys()]) {
|
|
64
|
-
if (!currentPaths.has(trackedPath)) {
|
|
65
|
-
_deleteFile(trackedPath, registry, fileToIds);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
for (const filePath of currentPaths) {
|
|
69
|
-
await _reloadFile(filePath, registry, importer, fileToIds);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
39
|
export function startHotReload(automationsDir, registry) {
|
|
73
40
|
const target = process.env.AUTOMATION
|
|
74
41
|
? path.join(automationsDir, `${process.env.AUTOMATION}.ts`)
|
|
75
42
|
: `${automationsDir}/**/*.ts`;
|
|
76
|
-
|
|
77
|
-
const reload = (filePath) => {
|
|
43
|
+
watch(target, { ignoreInitial: true, ignored: /node_modules/ }).on('change', (filePath) => {
|
|
78
44
|
_reloadFile(filePath, registry).catch((err) => {
|
|
79
45
|
console.error('[hot-reload] unexpected error:', err);
|
|
80
46
|
});
|
|
81
|
-
};
|
|
82
|
-
watcher.on('add', reload);
|
|
83
|
-
watcher.on('change', reload);
|
|
84
|
-
watcher.on('unlink', (filePath) => {
|
|
85
|
-
_deleteFile(filePath, registry);
|
|
86
47
|
});
|
|
87
48
|
}
|
|
@@ -6,7 +6,7 @@ export interface ObsEvent {
|
|
|
6
6
|
automation_id: string;
|
|
7
7
|
location: string;
|
|
8
8
|
subsystem: string;
|
|
9
|
-
|
|
9
|
+
type: 'decision' | 'abort' | 'action_started' | 'action_result';
|
|
10
10
|
decision?: string;
|
|
11
11
|
reason?: string;
|
|
12
12
|
inputs?: Record<string, unknown>;
|
|
@@ -14,14 +14,6 @@ export interface ObsEvent {
|
|
|
14
14
|
dry_run?: boolean;
|
|
15
15
|
timestamp: string;
|
|
16
16
|
}
|
|
17
|
-
export type LifecycleEventType = 'server_started' | 'rescan_complete' | 'ha_reconnected';
|
|
18
|
-
export interface LifecycleEvent {
|
|
19
|
-
schema: 'home.lifecycle.v1';
|
|
20
|
-
type: LifecycleEventType;
|
|
21
|
-
automation_count: number;
|
|
22
|
-
timestamp: string;
|
|
23
|
-
dry_run?: boolean;
|
|
24
|
-
}
|
|
25
17
|
export declare class Observability {
|
|
26
18
|
private readonly mqtt;
|
|
27
19
|
private readonly listeners;
|
|
@@ -29,6 +21,5 @@ export declare class Observability {
|
|
|
29
21
|
subscribe(listener: (event: ObsEvent) => void): () => void;
|
|
30
22
|
publishDecision(event: ObsEvent): void;
|
|
31
23
|
publishActionEvent(event: ObsEvent): void;
|
|
32
|
-
publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
|
|
33
24
|
private publish;
|
|
34
25
|
}
|
|
@@ -26,19 +26,6 @@ export class Observability {
|
|
|
26
26
|
for (const l of this.listeners)
|
|
27
27
|
l(event);
|
|
28
28
|
}
|
|
29
|
-
publishLifecycle(type, automationCount, dryRun = false) {
|
|
30
|
-
const event = {
|
|
31
|
-
schema: 'home.lifecycle.v1',
|
|
32
|
-
type,
|
|
33
|
-
automation_count: automationCount,
|
|
34
|
-
timestamp: new Date().toISOString(),
|
|
35
|
-
...(dryRun && { dry_run: true }),
|
|
36
|
-
};
|
|
37
|
-
const ns = dryRun ? 'homerun/dev' : 'homerun';
|
|
38
|
-
const payload = JSON.stringify(event);
|
|
39
|
-
this.publish(`${ns}/lifecycle`, payload, false);
|
|
40
|
-
this.publish(`${ns}/status`, JSON.stringify({ status: 'online', automation_count: automationCount, timestamp: event.timestamp }), true);
|
|
41
|
-
}
|
|
42
29
|
publish(topic, payload, retain) {
|
|
43
30
|
this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
|
|
44
31
|
console.error(`[Observability] MQTT publish failed on ${topic}:`, err);
|
|
@@ -6,7 +6,6 @@ import type { ActionRuntime } from './action-runtime.js';
|
|
|
6
6
|
interface Deps {
|
|
7
7
|
observability: Observability;
|
|
8
8
|
actionRuntime: ActionRuntime;
|
|
9
|
-
dryRun?: boolean;
|
|
10
9
|
}
|
|
11
10
|
export declare function runPipeline(automation: Automation<unknown>, event: TriggerEvent, haClient: HAClient, deps: Deps): Promise<void>;
|
|
12
11
|
export {};
|
|
@@ -9,7 +9,6 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
9
9
|
location: automation.location,
|
|
10
10
|
subsystem: automation.subsystem,
|
|
11
11
|
timestamp,
|
|
12
|
-
...(deps.dryRun ? { dry_run: true } : {}),
|
|
13
12
|
};
|
|
14
13
|
// Step 2: Context
|
|
15
14
|
let ctx;
|
|
@@ -17,11 +16,11 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
17
16
|
ctx = automation.context(haClient.state, haClient.context, event);
|
|
18
17
|
}
|
|
19
18
|
catch {
|
|
20
|
-
deps.observability.publishDecision({ ...base,
|
|
19
|
+
deps.observability.publishDecision({ ...base, type: 'abort', reason: 'unhandled_error' });
|
|
21
20
|
return;
|
|
22
21
|
}
|
|
23
22
|
if (isAbort(ctx)) {
|
|
24
|
-
deps.observability.publishDecision({ ...base,
|
|
23
|
+
deps.observability.publishDecision({ ...base, type: 'abort', reason: ctx.reason });
|
|
25
24
|
return;
|
|
26
25
|
}
|
|
27
26
|
// Step 3: Reduce
|
|
@@ -30,14 +29,14 @@ export async function runPipeline(automation, event, haClient, deps) {
|
|
|
30
29
|
result = automation.reduce(ctx);
|
|
31
30
|
}
|
|
32
31
|
catch {
|
|
33
|
-
deps.observability.publishDecision({ ...base,
|
|
32
|
+
deps.observability.publishDecision({ ...base, type: 'abort', reason: 'unhandled_error' });
|
|
34
33
|
return;
|
|
35
34
|
}
|
|
36
35
|
// Step 4: Validate — safe defaults
|
|
37
36
|
const actions = result.actions ?? [];
|
|
38
37
|
const decision = {
|
|
39
38
|
...base,
|
|
40
|
-
|
|
39
|
+
type: 'decision',
|
|
41
40
|
decision: result.decision,
|
|
42
41
|
reason: result.reason,
|
|
43
42
|
inputs: result.inputs,
|
package/dist/src/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { connect } from 'mqtt';
|
|
4
5
|
import { HAClient } from './framework/ha-client.js';
|
|
@@ -8,7 +9,7 @@ import { TimerManager } from './framework/timer-manager.js';
|
|
|
8
9
|
import { ActionRuntime } from './framework/action-runtime.js';
|
|
9
10
|
import { TriggerEngine } from './framework/trigger-engine.js';
|
|
10
11
|
import { Scheduler } from './framework/scheduler.js';
|
|
11
|
-
import {
|
|
12
|
+
import { _reloadFile, startHotReload } from './framework/hot-reload.js';
|
|
12
13
|
import { runPipeline } from './framework/pipeline.js';
|
|
13
14
|
import { ApiServer } from './framework/api-server.js';
|
|
14
15
|
process.on('uncaughtException', (err) => {
|
|
@@ -17,13 +18,8 @@ process.on('uncaughtException', (err) => {
|
|
|
17
18
|
process.on('unhandledRejection', (reason) => {
|
|
18
19
|
console.error('[homerun] unhandledRejection:', reason);
|
|
19
20
|
});
|
|
20
|
-
const dryRun = process.env.DRY_RUN === 'true';
|
|
21
|
-
const lwtTopic = dryRun ? 'homerun/dev/status' : 'homerun/status';
|
|
22
|
-
const lwtPayload = JSON.stringify({ status: 'offline', timestamp: new Date().toISOString() });
|
|
23
21
|
// 1. Connect MQTT before anything else (Observability and ActionRuntime need it).
|
|
24
|
-
const mqtt = connect(process.env.MQTT_URL
|
|
25
|
-
will: { topic: lwtTopic, payload: lwtPayload, qos: 1, retain: true },
|
|
26
|
-
});
|
|
22
|
+
const mqtt = connect(process.env.MQTT_URL);
|
|
27
23
|
await new Promise((resolve, reject) => {
|
|
28
24
|
mqtt.once('connect', () => resolve());
|
|
29
25
|
mqtt.once('error', reject);
|
|
@@ -41,15 +37,31 @@ const actionRuntime = new ActionRuntime({
|
|
|
41
37
|
mqttClient: mqtt,
|
|
42
38
|
timerManager,
|
|
43
39
|
observability,
|
|
44
|
-
dryRun,
|
|
40
|
+
dryRun: process.env.DRY_RUN === 'true',
|
|
45
41
|
});
|
|
46
42
|
// 3. Initial automation load — must complete before the engine and scheduler start.
|
|
47
43
|
const automationsDir = path.resolve(process.env.AUTOMATIONS_DIR);
|
|
48
|
-
|
|
44
|
+
const isAutomation = (f) => f.endsWith('.ts') &&
|
|
45
|
+
!f.includes('node_modules') &&
|
|
46
|
+
!f.includes('.d.ts') &&
|
|
47
|
+
!f.split(path.sep).includes('types');
|
|
48
|
+
async function loadAutomations() {
|
|
49
|
+
let files = [];
|
|
50
|
+
try {
|
|
51
|
+
files = (await readdir(automationsDir, { recursive: true }));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
console.warn(`[homerun] AUTOMATIONS_DIR not found: ${automationsDir} — starting with no automations`);
|
|
55
|
+
}
|
|
56
|
+
for (const file of files.filter(isAutomation)) {
|
|
57
|
+
await _reloadFile(path.join(automationsDir, file), registry);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await loadAutomations();
|
|
49
61
|
console.log(`[homerun] loaded ${registry.getAll().length} automation(s)`);
|
|
50
62
|
// 4. Wire up the engine and scheduler.
|
|
51
63
|
engine = new TriggerEngine(registry, haClient, (automation, event) => {
|
|
52
|
-
runPipeline(automation, event, haClient, { observability, actionRuntime
|
|
64
|
+
runPipeline(automation, event, haClient, { observability, actionRuntime }).catch((err) => {
|
|
53
65
|
console.error('[homerun] pipeline error:', err);
|
|
54
66
|
});
|
|
55
67
|
}, mqtt);
|
|
@@ -59,10 +71,8 @@ scheduler.start();
|
|
|
59
71
|
// 5. Start hot-reload watcher (dev) and SIGUSR1 rescan (git-sync sidecar in K8s).
|
|
60
72
|
startHotReload(automationsDir, registry);
|
|
61
73
|
async function reload() {
|
|
62
|
-
await
|
|
63
|
-
|
|
64
|
-
console.log(`[homerun] rescan complete — ${count} automation(s) registered`);
|
|
65
|
-
observability.publishLifecycle('rescan_complete', count, dryRun);
|
|
74
|
+
await loadAutomations();
|
|
75
|
+
console.log(`[homerun] rescan complete — ${registry.getAll().length} automation(s) registered`);
|
|
66
76
|
}
|
|
67
77
|
process.on('SIGUSR1', () => {
|
|
68
78
|
console.log('[homerun] SIGUSR1 received — rescanning automations');
|
|
@@ -75,7 +85,7 @@ let haReady = false;
|
|
|
75
85
|
const apiServer = new ApiServer({
|
|
76
86
|
registry,
|
|
77
87
|
onTrigger: (automation, event) => {
|
|
78
|
-
runPipeline(automation, event, haClient, { observability, actionRuntime
|
|
88
|
+
runPipeline(automation, event, haClient, { observability, actionRuntime }).catch((err) => {
|
|
79
89
|
console.error('[homerun] pipeline error (http trigger):', err);
|
|
80
90
|
});
|
|
81
91
|
},
|
|
@@ -83,16 +93,13 @@ const apiServer = new ApiServer({
|
|
|
83
93
|
isReady: () => haReady,
|
|
84
94
|
entityCount: () => haClient.entityCount,
|
|
85
95
|
observability,
|
|
86
|
-
dryRun,
|
|
87
96
|
});
|
|
88
97
|
await apiServer.start(Number(process.env.API_PORT ?? 7070));
|
|
89
98
|
// 7. Connect to HA last — state_changed events start flowing once ready resolves.
|
|
90
99
|
haClient.on('reconnected', () => {
|
|
91
100
|
console.log(`[homerun] reconnected — ${haClient.entityCount} entities refreshed`);
|
|
92
|
-
observability.publishLifecycle('ha_reconnected', registry.getAll().length, dryRun);
|
|
93
101
|
});
|
|
94
102
|
await haClient.connect(process.env.HA_URL, process.env.HA_TOKEN);
|
|
95
103
|
await haClient.ready;
|
|
96
104
|
haReady = true;
|
|
97
105
|
console.log(`[homerun] ready — ${haClient.entityCount} entities cached`);
|
|
98
|
-
observability.publishLifecycle('server_started', registry.getAll().length, dryRun);
|
package/dist/src/testing.d.ts
CHANGED
package/dist/src/testing.js
CHANGED
|
@@ -9,8 +9,8 @@ export function testAutomation(automation, options) {
|
|
|
9
9
|
entity_id: entityId,
|
|
10
10
|
state: entry.state,
|
|
11
11
|
attributes: entry.attributes ?? {},
|
|
12
|
-
last_changed:
|
|
13
|
-
last_updated:
|
|
12
|
+
last_changed: '',
|
|
13
|
+
last_updated: '',
|
|
14
14
|
};
|
|
15
15
|
};
|
|
16
16
|
const haContext = {
|