@ajclarkson/homerun 0.0.1-edge.d1f2721 → 0.0.1-edge.db0717b

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 CHANGED
@@ -131,6 +131,7 @@ 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. |
134
135
 
135
136
  ```typescript
136
137
  triggers: [
@@ -192,7 +193,7 @@ Every pipeline run publishes a decision snapshot to MQTT — whether it complete
192
193
  "automation_id": "kitchen:lighting",
193
194
  "location": "kitchen",
194
195
  "subsystem": "lighting",
195
- "type": "decision",
196
+ "event_type": "decision",
196
197
  "decision": "lights_on",
197
198
  "inputs": { "motion": true, "lux": 20, "luxThreshold": 40 },
198
199
  "actions": [{ "type": "ha.call_service", "domain": "light", "service": "turn_on" }],
@@ -230,6 +231,21 @@ Set `DRY_RUN=true` to run the full pipeline — context, reduce, observability
230
231
 
231
232
  ---
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
+
233
249
  ## Architecture
234
250
 
235
251
  ```
@@ -41,14 +41,14 @@ export class ActionRuntime {
41
41
  }
42
42
  }
43
43
  }
44
- makeEvent(ctx, type, action, extra = {}) {
44
+ makeEvent(ctx, event_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
- type,
51
+ event_type,
52
52
  actions: [action],
53
53
  timestamp: new Date().toISOString(),
54
54
  ...(this.deps.dryRun ? { dry_run: true } : {}),
@@ -9,6 +9,7 @@ export interface ApiServerDeps {
9
9
  isReady: () => boolean;
10
10
  entityCount: () => number;
11
11
  observability: Observability;
12
+ dryRun?: boolean;
12
13
  }
13
14
  export declare class ApiServer {
14
15
  private readonly deps;
@@ -84,6 +84,7 @@ 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 }),
87
88
  });
88
89
  }
89
90
  getEvents(req, res) {
@@ -3,5 +3,7 @@ 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>;
6
8
  export declare function startHotReload(automationsDir: string, registry: AutomationRegistry): void;
7
9
  export {};
@@ -12,7 +12,10 @@ export async function _reloadFile(filePath, registry, importer = defaultImporter
12
12
  platform: 'node',
13
13
  format: 'esm',
14
14
  write: false,
15
- alias: { '@ajclarkson/homerun': path.resolve(import.meta.dirname, '../lib.js') },
15
+ alias: {
16
+ '@ajclarkson/homerun/testing': path.resolve(import.meta.dirname, '../testing.js'),
17
+ '@ajclarkson/homerun': path.resolve(import.meta.dirname, '../lib.js'),
18
+ },
16
19
  });
17
20
  const code = result.outputFiles[0].text;
18
21
  const dataUri = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
@@ -36,13 +39,49 @@ export async function _reloadFile(filePath, registry, importer = defaultImporter
36
39
  console.error(`[hot-reload] failed to reload ${filePath}:`, err);
37
40
  }
38
41
  }
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
+ }
39
72
  export function startHotReload(automationsDir, registry) {
40
73
  const target = process.env.AUTOMATION
41
74
  ? path.join(automationsDir, `${process.env.AUTOMATION}.ts`)
42
75
  : `${automationsDir}/**/*.ts`;
43
- watch(target, { ignoreInitial: true, ignored: /node_modules/ }).on('change', (filePath) => {
76
+ const watcher = watch(target, { ignoreInitial: true, ignored: [/node_modules/, /\.test\.ts$/] });
77
+ const reload = (filePath) => {
44
78
  _reloadFile(filePath, registry).catch((err) => {
45
79
  console.error('[hot-reload] unexpected error:', err);
46
80
  });
81
+ };
82
+ watcher.on('add', reload);
83
+ watcher.on('change', reload);
84
+ watcher.on('unlink', (filePath) => {
85
+ _deleteFile(filePath, registry);
47
86
  });
48
87
  }
@@ -6,7 +6,7 @@ export interface ObsEvent {
6
6
  automation_id: string;
7
7
  location: string;
8
8
  subsystem: string;
9
- type: 'decision' | 'abort' | 'action_started' | 'action_result';
9
+ event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
10
10
  decision?: string;
11
11
  reason?: string;
12
12
  inputs?: Record<string, unknown>;
@@ -14,6 +14,14 @@ 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
+ }
17
25
  export declare class Observability {
18
26
  private readonly mqtt;
19
27
  private readonly listeners;
@@ -21,5 +29,6 @@ export declare class Observability {
21
29
  subscribe(listener: (event: ObsEvent) => void): () => void;
22
30
  publishDecision(event: ObsEvent): void;
23
31
  publishActionEvent(event: ObsEvent): void;
32
+ publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
24
33
  private publish;
25
34
  }
@@ -26,6 +26,19 @@ 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
+ }
29
42
  publish(topic, payload, retain) {
30
43
  this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
31
44
  console.error(`[Observability] MQTT publish failed on ${topic}:`, err);
@@ -6,6 +6,7 @@ import type { ActionRuntime } from './action-runtime.js';
6
6
  interface Deps {
7
7
  observability: Observability;
8
8
  actionRuntime: ActionRuntime;
9
+ dryRun?: boolean;
9
10
  }
10
11
  export declare function runPipeline(automation: Automation<unknown>, event: TriggerEvent, haClient: HAClient, deps: Deps): Promise<void>;
11
12
  export {};
@@ -9,6 +9,7 @@ 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 } : {}),
12
13
  };
13
14
  // Step 2: Context
14
15
  let ctx;
@@ -16,11 +17,11 @@ export async function runPipeline(automation, event, haClient, deps) {
16
17
  ctx = automation.context(haClient.state, haClient.context, event);
17
18
  }
18
19
  catch {
19
- deps.observability.publishDecision({ ...base, type: 'abort', reason: 'unhandled_error' });
20
+ deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
20
21
  return;
21
22
  }
22
23
  if (isAbort(ctx)) {
23
- deps.observability.publishDecision({ ...base, type: 'abort', reason: ctx.reason });
24
+ deps.observability.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
24
25
  return;
25
26
  }
26
27
  // Step 3: Reduce
@@ -29,14 +30,14 @@ export async function runPipeline(automation, event, haClient, deps) {
29
30
  result = automation.reduce(ctx);
30
31
  }
31
32
  catch {
32
- deps.observability.publishDecision({ ...base, type: 'abort', reason: 'unhandled_error' });
33
+ deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
33
34
  return;
34
35
  }
35
36
  // Step 4: Validate — safe defaults
36
37
  const actions = result.actions ?? [];
37
38
  const decision = {
38
39
  ...base,
39
- type: 'decision',
40
+ event_type: 'decision',
40
41
  decision: result.decision,
41
42
  reason: result.reason,
42
43
  inputs: result.inputs,
package/dist/src/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import 'dotenv/config';
2
- import { readdir } from 'node:fs/promises';
3
2
  import path from 'node:path';
4
3
  import { connect } from 'mqtt';
5
4
  import { HAClient } from './framework/ha-client.js';
@@ -9,7 +8,7 @@ import { TimerManager } from './framework/timer-manager.js';
9
8
  import { ActionRuntime } from './framework/action-runtime.js';
10
9
  import { TriggerEngine } from './framework/trigger-engine.js';
11
10
  import { Scheduler } from './framework/scheduler.js';
12
- import { _reloadFile, startHotReload } from './framework/hot-reload.js';
11
+ import { rescanAutomations, startHotReload } from './framework/hot-reload.js';
13
12
  import { runPipeline } from './framework/pipeline.js';
14
13
  import { ApiServer } from './framework/api-server.js';
15
14
  process.on('uncaughtException', (err) => {
@@ -18,8 +17,13 @@ process.on('uncaughtException', (err) => {
18
17
  process.on('unhandledRejection', (reason) => {
19
18
  console.error('[homerun] unhandledRejection:', reason);
20
19
  });
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() });
21
23
  // 1. Connect MQTT before anything else (Observability and ActionRuntime need it).
22
- const mqtt = connect(process.env.MQTT_URL);
24
+ const mqtt = connect(process.env.MQTT_URL, {
25
+ will: { topic: lwtTopic, payload: lwtPayload, qos: 1, retain: true },
26
+ });
23
27
  await new Promise((resolve, reject) => {
24
28
  mqtt.once('connect', () => resolve());
25
29
  mqtt.once('error', reject);
@@ -37,31 +41,15 @@ const actionRuntime = new ActionRuntime({
37
41
  mqttClient: mqtt,
38
42
  timerManager,
39
43
  observability,
40
- dryRun: process.env.DRY_RUN === 'true',
44
+ dryRun,
41
45
  });
42
46
  // 3. Initial automation load — must complete before the engine and scheduler start.
43
47
  const automationsDir = path.resolve(process.env.AUTOMATIONS_DIR);
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();
48
+ await rescanAutomations(automationsDir, registry);
61
49
  console.log(`[homerun] loaded ${registry.getAll().length} automation(s)`);
62
50
  // 4. Wire up the engine and scheduler.
63
51
  engine = new TriggerEngine(registry, haClient, (automation, event) => {
64
- runPipeline(automation, event, haClient, { observability, actionRuntime }).catch((err) => {
52
+ runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun }).catch((err) => {
65
53
  console.error('[homerun] pipeline error:', err);
66
54
  });
67
55
  }, mqtt);
@@ -71,8 +59,10 @@ scheduler.start();
71
59
  // 5. Start hot-reload watcher (dev) and SIGUSR1 rescan (git-sync sidecar in K8s).
72
60
  startHotReload(automationsDir, registry);
73
61
  async function reload() {
74
- await loadAutomations();
75
- console.log(`[homerun] rescan complete — ${registry.getAll().length} automation(s) registered`);
62
+ await rescanAutomations(automationsDir, registry);
63
+ const count = registry.getAll().length;
64
+ console.log(`[homerun] rescan complete — ${count} automation(s) registered`);
65
+ observability.publishLifecycle('rescan_complete', count, dryRun);
76
66
  }
77
67
  process.on('SIGUSR1', () => {
78
68
  console.log('[homerun] SIGUSR1 received — rescanning automations');
@@ -85,7 +75,7 @@ let haReady = false;
85
75
  const apiServer = new ApiServer({
86
76
  registry,
87
77
  onTrigger: (automation, event) => {
88
- runPipeline(automation, event, haClient, { observability, actionRuntime }).catch((err) => {
78
+ runPipeline(automation, event, haClient, { observability, actionRuntime, dryRun }).catch((err) => {
89
79
  console.error('[homerun] pipeline error (http trigger):', err);
90
80
  });
91
81
  },
@@ -93,13 +83,16 @@ const apiServer = new ApiServer({
93
83
  isReady: () => haReady,
94
84
  entityCount: () => haClient.entityCount,
95
85
  observability,
86
+ dryRun,
96
87
  });
97
88
  await apiServer.start(Number(process.env.API_PORT ?? 7070));
98
89
  // 7. Connect to HA last — state_changed events start flowing once ready resolves.
99
90
  haClient.on('reconnected', () => {
100
91
  console.log(`[homerun] reconnected — ${haClient.entityCount} entities refreshed`);
92
+ observability.publishLifecycle('ha_reconnected', registry.getAll().length, dryRun);
101
93
  });
102
94
  await haClient.connect(process.env.HA_URL, process.env.HA_TOKEN);
103
95
  await haClient.ready;
104
96
  haReady = true;
105
97
  console.log(`[homerun] ready — ${haClient.entityCount} entities cached`);
98
+ observability.publishLifecycle('server_started', registry.getAll().length, dryRun);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ajclarkson/homerun",
3
3
  "type": "module",
4
- "version": "0.0.1-edge.d1f2721",
4
+ "version": "0.0.1-edge.db0717b",
5
5
  "description": "TypeScript automation framework for Home Assistant",
6
6
  "files": [
7
7
  "dist"