@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 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
- "event_type": "decision",
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, event_type, action, extra = {}) {
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
- event_type,
51
+ type,
52
52
  actions: [action],
53
53
  timestamp: new Date().toISOString(),
54
54
  ...(this.deps.dryRun ? { dry_run: true } : {}),
@@ -9,7 +9,6 @@ export interface ApiServerDeps {
9
9
  isReady: () => boolean;
10
10
  entityCount: () => number;
11
11
  observability: Observability;
12
- dryRun?: boolean;
13
12
  }
14
13
  export declare class ApiServer {
15
14
  private readonly deps;
@@ -51,7 +51,7 @@ export class ApiServer {
51
51
  id: a.id,
52
52
  location: a.location,
53
53
  subsystem: a.subsystem,
54
- triggers: a.triggers.map(serializeTrigger),
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
- const watcher = watch(target, { ignoreInitial: true, ignored: [/node_modules/, /\.test\.ts$/] });
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
- event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
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, event_type: 'abort', reason: 'unhandled_error' });
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, event_type: 'abort', reason: ctx.reason });
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, event_type: 'abort', reason: 'unhandled_error' });
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
- event_type: 'decision',
39
+ type: 'decision',
41
40
  decision: result.decision,
42
41
  reason: result.reason,
43
42
  inputs: result.inputs,
@@ -1,4 +1,4 @@
1
- const DOUBLE_PRESS_WINDOW_MS = 250;
1
+ const DOUBLE_PRESS_WINDOW_MS = 400;
2
2
  class ButtonGestureHandler {
3
3
  entityId;
4
4
  dispatch;
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 { rescanAutomations, startHotReload } from './framework/hot-reload.js';
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
- await rescanAutomations(automationsDir, registry);
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, dryRun }).catch((err) => {
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 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);
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, dryRun }).catch((err) => {
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);
@@ -4,8 +4,6 @@ import type { HAContext } from './framework/ha-client.js';
4
4
  type TestStateEntry = {
5
5
  state: string;
6
6
  attributes?: Record<string, unknown>;
7
- last_changed?: string;
8
- last_updated?: string;
9
7
  };
10
8
  interface TestOptions {
11
9
  event: TriggerEvent;
@@ -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: entry.last_changed ?? '',
13
- last_updated: entry.last_updated ?? '',
12
+ last_changed: '',
13
+ last_updated: '',
14
14
  };
15
15
  };
16
16
  const haContext = {
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.f1d8985",
4
+ "version": "0.0.1",
5
5
  "description": "TypeScript automation framework for Home Assistant",
6
6
  "files": [
7
7
  "dist"