@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.
Files changed (37) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/dist/scripts/generate-ha-types.d.ts +8 -0
  4. package/dist/scripts/generate-ha-types.js +81 -0
  5. package/dist/src/framework/action-runtime.d.ts +27 -0
  6. package/dist/src/framework/action-runtime.js +58 -0
  7. package/dist/src/framework/api-server.d.ts +29 -0
  8. package/dist/src/framework/api-server.js +107 -0
  9. package/dist/src/framework/ha-client.d.ts +47 -0
  10. package/dist/src/framework/ha-client.js +125 -0
  11. package/dist/src/framework/hot-reload.d.ts +9 -0
  12. package/dist/src/framework/hot-reload.js +87 -0
  13. package/dist/src/framework/observability.d.ts +34 -0
  14. package/dist/src/framework/observability.js +47 -0
  15. package/dist/src/framework/pipeline.d.ts +12 -0
  16. package/dist/src/framework/pipeline.js +56 -0
  17. package/dist/src/framework/registry.d.ts +10 -0
  18. package/dist/src/framework/registry.js +21 -0
  19. package/dist/src/framework/scheduler.d.ts +11 -0
  20. package/dist/src/framework/scheduler.js +35 -0
  21. package/dist/src/framework/timer-manager.d.ts +8 -0
  22. package/dist/src/framework/timer-manager.js +22 -0
  23. package/dist/src/framework/trigger-engine.d.ts +22 -0
  24. package/dist/src/framework/trigger-engine.js +223 -0
  25. package/dist/src/index.d.ts +1 -0
  26. package/dist/src/index.js +98 -0
  27. package/dist/src/lib.d.ts +4 -0
  28. package/dist/src/lib.js +1 -0
  29. package/dist/src/testing.d.ts +16 -0
  30. package/dist/src/testing.js +25 -0
  31. package/dist/src/types/actions.d.ts +21 -0
  32. package/dist/src/types/actions.js +1 -0
  33. package/dist/src/types/automation.d.ts +25 -0
  34. package/dist/src/types/automation.js +9 -0
  35. package/dist/src/types/triggers.d.ts +50 -0
  36. package/dist/src/types/triggers.js +1 -0
  37. package/package.json +55 -0
@@ -0,0 +1,125 @@
1
+ import { callService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
2
+ import { EventEmitter } from 'node:events';
3
+ export class HAClient extends EventEmitter {
4
+ stateCache = new Map();
5
+ labelToEntities = new Map();
6
+ entityToLabels = new Map();
7
+ areaToEntities = new Map();
8
+ connection = null;
9
+ // True between a disconnect event and the next subscribeEntities callback.
10
+ // During this window we repopulate silently — no state_changed events emitted.
11
+ reconnecting = false;
12
+ _readyResolve;
13
+ ready = new Promise((resolve) => {
14
+ this._readyResolve = resolve;
15
+ });
16
+ // Synchronous accessors passed into context builders.
17
+ state = (entity) => this.stateCache.get(entity);
18
+ context = {
19
+ entitiesByLabel: (label) => Array.from(this.labelToEntities.get(label) ?? []),
20
+ labelsFor: (entity) => this.entityToLabels.get(entity) ?? [],
21
+ entitiesByArea: (area) => Array.from(this.areaToEntities.get(area) ?? []),
22
+ };
23
+ get entityCount() {
24
+ return this.stateCache.size;
25
+ }
26
+ async callService(domain, service, target, data) {
27
+ if (!this.connection)
28
+ throw new Error('HAClient not connected');
29
+ await callService(this.connection, domain, service, data, target);
30
+ }
31
+ async connect(url, token) {
32
+ const auth = createLongLivedTokenAuth(url, token);
33
+ this.connection = await createConnection({ auth });
34
+ // Reload registry and repopulate cache silently on reconnect.
35
+ this.connection.addEventListener('disconnected', () => {
36
+ this.reconnecting = true;
37
+ });
38
+ await this.loadEntityRegistry();
39
+ let firstSnapshot = true;
40
+ subscribeEntities(this.connection, (entities) => {
41
+ if (firstSnapshot) {
42
+ firstSnapshot = false;
43
+ this.repopulateCache(entities);
44
+ this._readyResolve();
45
+ this.emit('ready');
46
+ }
47
+ else if (this.reconnecting) {
48
+ this.reconnecting = false;
49
+ // Reload registry in the background — don't block the cache repopulate.
50
+ this.loadEntityRegistry().catch((err) => {
51
+ console.error('[ha-client] registry reload failed after reconnect:', err);
52
+ });
53
+ this.repopulateCache(entities);
54
+ this.emit('reconnected');
55
+ }
56
+ else {
57
+ this.diffAndUpdate(entities);
58
+ }
59
+ });
60
+ }
61
+ // ---------- Private ----------
62
+ repopulateCache(entities) {
63
+ this.stateCache.clear();
64
+ for (const [id, entity] of Object.entries(entities)) {
65
+ this.stateCache.set(id, toEntityState(id, entity));
66
+ }
67
+ }
68
+ diffAndUpdate(entities) {
69
+ for (const [id, rawEntity] of Object.entries(entities)) {
70
+ const old_state = this.stateCache.get(id);
71
+ const new_state = toEntityState(id, rawEntity);
72
+ // last_updated changes whenever state or attributes change in HA.
73
+ if (!old_state || old_state.last_updated !== new_state.last_updated) {
74
+ this.stateCache.set(id, new_state);
75
+ this.emit('state_changed', { entity_id: id, old_state, new_state, correlation_id: crypto.randomUUID() });
76
+ }
77
+ }
78
+ // Prune entities that disappeared from the snapshot.
79
+ for (const id of this.stateCache.keys()) {
80
+ if (!(id in entities)) {
81
+ this.stateCache.delete(id);
82
+ }
83
+ }
84
+ }
85
+ async loadEntityRegistry() {
86
+ if (!this.connection)
87
+ return;
88
+ const entries = (await this.connection.sendMessagePromise({
89
+ type: 'config/entity_registry/list',
90
+ }));
91
+ this.labelToEntities.clear();
92
+ this.entityToLabels.clear();
93
+ this.areaToEntities.clear();
94
+ for (const entry of entries) {
95
+ const labels = entry.labels ?? [];
96
+ this.entityToLabels.set(entry.entity_id, labels);
97
+ for (const label of labels) {
98
+ let set = this.labelToEntities.get(label);
99
+ if (!set) {
100
+ set = new Set();
101
+ this.labelToEntities.set(label, set);
102
+ }
103
+ set.add(entry.entity_id);
104
+ }
105
+ if (entry.area_id) {
106
+ let set = this.areaToEntities.get(entry.area_id);
107
+ if (!set) {
108
+ set = new Set();
109
+ this.areaToEntities.set(entry.area_id, set);
110
+ }
111
+ set.add(entry.entity_id);
112
+ }
113
+ }
114
+ }
115
+ }
116
+ // ---------- Helpers ----------
117
+ function toEntityState(entity_id, entity) {
118
+ return {
119
+ entity_id,
120
+ state: entity.state,
121
+ attributes: entity.attributes,
122
+ last_changed: entity.last_changed,
123
+ last_updated: entity.last_updated,
124
+ };
125
+ }
@@ -0,0 +1,9 @@
1
+ import type { AutomationRegistry } from './registry.js';
2
+ type Importer = (dataUri: string) => Promise<{
3
+ default: unknown;
4
+ }>;
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
+ export declare function startHotReload(automationsDir: string, registry: AutomationRegistry): void;
9
+ export {};
@@ -0,0 +1,87 @@
1
+ import path from 'node:path';
2
+ import { watch } from 'chokidar';
3
+ import { build } from 'esbuild';
4
+ const defaultImporter = (dataUri) => import(dataUri);
5
+ // Module-level file→IDs map used in production. Tests pass their own instance.
6
+ const moduleFileToIds = new Map();
7
+ export async function _reloadFile(filePath, registry, importer = defaultImporter, fileToIds = moduleFileToIds) {
8
+ try {
9
+ const result = await build({
10
+ entryPoints: [filePath],
11
+ bundle: true,
12
+ platform: 'node',
13
+ format: 'esm',
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
+ },
19
+ });
20
+ const code = result.outputFiles[0].text;
21
+ const dataUri = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
22
+ const mod = await importer(dataUri);
23
+ if (!mod.default) {
24
+ throw new Error(`${filePath} has no default export`);
25
+ }
26
+ const automations = Array.isArray(mod.default)
27
+ ? mod.default
28
+ : [mod.default];
29
+ // Deregister previous automations from this file only after successful load.
30
+ for (const id of fileToIds.get(filePath) ?? []) {
31
+ registry.unregister(id);
32
+ }
33
+ for (const auto of automations) {
34
+ registry.register(auto);
35
+ }
36
+ fileToIds.set(filePath, automations.map((a) => a.id));
37
+ }
38
+ catch (err) {
39
+ console.error(`[hot-reload] failed to reload ${filePath}:`, err);
40
+ }
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
+ }
72
+ export function startHotReload(automationsDir, registry) {
73
+ const target = process.env.AUTOMATION
74
+ ? path.join(automationsDir, `${process.env.AUTOMATION}.ts`)
75
+ : `${automationsDir}/**/*.ts`;
76
+ const watcher = watch(target, { ignoreInitial: true, ignored: [/node_modules/, /\.test\.ts$/] });
77
+ const reload = (filePath) => {
78
+ _reloadFile(filePath, registry).catch((err) => {
79
+ console.error('[hot-reload] unexpected error:', err);
80
+ });
81
+ };
82
+ watcher.on('add', reload);
83
+ watcher.on('change', reload);
84
+ watcher.on('unlink', (filePath) => {
85
+ _deleteFile(filePath, registry);
86
+ });
87
+ }
@@ -0,0 +1,34 @@
1
+ import type { MqttClient } from 'mqtt';
2
+ import type { Action } from '../types/actions.js';
3
+ export interface ObsEvent {
4
+ schema: 'home.events.v1';
5
+ correlation_id: string;
6
+ automation_id: string;
7
+ location: string;
8
+ subsystem: string;
9
+ event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
10
+ decision?: string;
11
+ reason?: string;
12
+ inputs?: Record<string, unknown>;
13
+ actions?: Action[];
14
+ dry_run?: boolean;
15
+ timestamp: string;
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
+ export declare class Observability {
26
+ private readonly mqtt;
27
+ private readonly listeners;
28
+ constructor(mqtt: MqttClient);
29
+ subscribe(listener: (event: ObsEvent) => void): () => void;
30
+ publishDecision(event: ObsEvent): void;
31
+ publishActionEvent(event: ObsEvent): void;
32
+ publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
33
+ private publish;
34
+ }
@@ -0,0 +1,47 @@
1
+ export class Observability {
2
+ mqtt;
3
+ listeners = [];
4
+ constructor(mqtt) {
5
+ this.mqtt = mqtt;
6
+ }
7
+ subscribe(listener) {
8
+ this.listeners.push(listener);
9
+ return () => {
10
+ const idx = this.listeners.indexOf(listener);
11
+ if (idx !== -1)
12
+ this.listeners.splice(idx, 1);
13
+ };
14
+ }
15
+ publishDecision(event) {
16
+ const payload = JSON.stringify(event);
17
+ const ns = event.dry_run ? 'homerun/dev' : 'homerun';
18
+ this.publish(`${ns}/events`, payload, false);
19
+ this.publish(`${ns}/${event.location}/${event.subsystem}/decision`, payload, true);
20
+ for (const l of this.listeners)
21
+ l(event);
22
+ }
23
+ publishActionEvent(event) {
24
+ const ns = event.dry_run ? 'homerun/dev' : 'homerun';
25
+ this.publish(`${ns}/events`, JSON.stringify(event), false);
26
+ for (const l of this.listeners)
27
+ l(event);
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
+ publish(topic, payload, retain) {
43
+ this.mqtt.publishAsync(topic, payload, { retain }).catch((err) => {
44
+ console.error(`[Observability] MQTT publish failed on ${topic}:`, err);
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,12 @@
1
+ import type { Automation } from '../types/automation.js';
2
+ import type { TriggerEvent } from '../types/triggers.js';
3
+ import type { HAClient } from './ha-client.js';
4
+ import type { Observability } from './observability.js';
5
+ import type { ActionRuntime } from './action-runtime.js';
6
+ interface Deps {
7
+ observability: Observability;
8
+ actionRuntime: ActionRuntime;
9
+ dryRun?: boolean;
10
+ }
11
+ export declare function runPipeline(automation: Automation<unknown>, event: TriggerEvent, haClient: HAClient, deps: Deps): Promise<void>;
12
+ export {};
@@ -0,0 +1,56 @@
1
+ import { isAbort } from '../types/automation.js';
2
+ export async function runPipeline(automation, event, haClient, deps) {
3
+ const correlationId = event.correlation_id;
4
+ const timestamp = new Date().toISOString();
5
+ const base = {
6
+ schema: 'home.events.v1',
7
+ correlation_id: correlationId,
8
+ automation_id: automation.id,
9
+ location: automation.location,
10
+ subsystem: automation.subsystem,
11
+ timestamp,
12
+ ...(deps.dryRun ? { dry_run: true } : {}),
13
+ };
14
+ // Step 2: Context
15
+ let ctx;
16
+ try {
17
+ ctx = automation.context(haClient.state, haClient.context, event);
18
+ }
19
+ catch {
20
+ deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
21
+ return;
22
+ }
23
+ if (isAbort(ctx)) {
24
+ deps.observability.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
25
+ return;
26
+ }
27
+ // Step 3: Reduce
28
+ let result;
29
+ try {
30
+ result = automation.reduce(ctx);
31
+ }
32
+ catch {
33
+ deps.observability.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
34
+ return;
35
+ }
36
+ // Step 4: Validate — safe defaults
37
+ const actions = result.actions ?? [];
38
+ const decision = {
39
+ ...base,
40
+ event_type: 'decision',
41
+ decision: result.decision,
42
+ reason: result.reason,
43
+ inputs: result.inputs,
44
+ actions,
45
+ };
46
+ // Step 5: Fanout
47
+ await Promise.all([
48
+ Promise.resolve(deps.observability.publishDecision(decision)),
49
+ deps.actionRuntime.execute(actions, {
50
+ correlationId,
51
+ automationId: automation.id,
52
+ location: automation.location,
53
+ subsystem: automation.subsystem,
54
+ }),
55
+ ]);
56
+ }
@@ -0,0 +1,10 @@
1
+ import type { Automation } from '../types/automation.js';
2
+ export declare class AutomationRegistry {
3
+ private readonly automations;
4
+ private readonly changeCallbacks;
5
+ onChange(cb: () => void): void;
6
+ register(automation: Automation<unknown>): void;
7
+ unregister(id: string): void;
8
+ getAll(): Automation<unknown>[];
9
+ getById(id: string): Automation<unknown> | undefined;
10
+ }
@@ -0,0 +1,21 @@
1
+ export class AutomationRegistry {
2
+ automations = new Map();
3
+ changeCallbacks = [];
4
+ onChange(cb) {
5
+ this.changeCallbacks.push(cb);
6
+ }
7
+ register(automation) {
8
+ this.automations.set(automation.id, automation);
9
+ for (const cb of this.changeCallbacks)
10
+ cb();
11
+ }
12
+ unregister(id) {
13
+ this.automations.delete(id);
14
+ }
15
+ getAll() {
16
+ return Array.from(this.automations.values());
17
+ }
18
+ getById(id) {
19
+ return this.automations.get(id);
20
+ }
21
+ }
@@ -0,0 +1,11 @@
1
+ import type { Automation } from '../types/automation.js';
2
+ import type { TriggerEvent } from '../types/triggers.js';
3
+ export declare class Scheduler {
4
+ private readonly automations;
5
+ private readonly dispatch;
6
+ private readonly ready;
7
+ private readonly cleanups;
8
+ constructor(automations: Automation<unknown>[], dispatch: (event: TriggerEvent) => void, ready: Promise<void>);
9
+ start(): void;
10
+ stop(): void;
11
+ }
@@ -0,0 +1,35 @@
1
+ import cron from 'node-cron';
2
+ export class Scheduler {
3
+ automations;
4
+ dispatch;
5
+ ready;
6
+ cleanups = [];
7
+ constructor(automations, dispatch, ready) {
8
+ this.automations = automations;
9
+ this.dispatch = dispatch;
10
+ this.ready = ready;
11
+ }
12
+ start() {
13
+ for (const automation of this.automations) {
14
+ for (const trigger of automation.triggers) {
15
+ if (trigger.type === 'schedule') {
16
+ const { cron: expression } = trigger;
17
+ const task = cron.schedule(expression, () => {
18
+ this.dispatch({ type: 'schedule', cron: expression, correlation_id: crypto.randomUUID() });
19
+ });
20
+ this.cleanups.push(() => task.stop());
21
+ }
22
+ }
23
+ }
24
+ this.ready.then(() => {
25
+ this.dispatch({ type: 'on_start', correlation_id: crypto.randomUUID() });
26
+ }).catch((err) => {
27
+ console.error('[scheduler] ready promise rejected:', err);
28
+ });
29
+ }
30
+ stop() {
31
+ for (const cleanup of this.cleanups)
32
+ cleanup();
33
+ this.cleanups.length = 0;
34
+ }
35
+ }
@@ -0,0 +1,8 @@
1
+ import type { TriggerEvent } from '../types/triggers.js';
2
+ export declare class TimerManager {
3
+ private readonly dispatch;
4
+ private readonly timers;
5
+ constructor(dispatch: (event: TriggerEvent) => void);
6
+ start(timerKey: string, delayMs: number): void;
7
+ cancel(timerKey: string): void;
8
+ }
@@ -0,0 +1,22 @@
1
+ export class TimerManager {
2
+ dispatch;
3
+ timers = new Map();
4
+ constructor(dispatch) {
5
+ this.dispatch = dispatch;
6
+ }
7
+ start(timerKey, delayMs) {
8
+ this.cancel(timerKey);
9
+ const handle = setTimeout(() => {
10
+ this.timers.delete(timerKey);
11
+ this.dispatch({ type: 'timer_expired', timerKey, correlation_id: crypto.randomUUID() });
12
+ }, delayMs);
13
+ this.timers.set(timerKey, handle);
14
+ }
15
+ cancel(timerKey) {
16
+ const handle = this.timers.get(timerKey);
17
+ if (handle !== undefined) {
18
+ clearTimeout(handle);
19
+ this.timers.delete(timerKey);
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,22 @@
1
+ import type { MqttClient } from 'mqtt';
2
+ import type { Automation } from '../types/automation.js';
3
+ import type { TriggerEvent } from '../types/triggers.js';
4
+ import type { HAClient } from './ha-client.js';
5
+ import type { AutomationRegistry } from './registry.js';
6
+ export declare function parseButtonAction(state: string): {
7
+ button?: string;
8
+ pressType: 'short' | 'hold';
9
+ } | null;
10
+ export declare class TriggerEngine {
11
+ private readonly registry;
12
+ private readonly haClient;
13
+ private readonly onMatch;
14
+ private readonly mqttClient?;
15
+ private readonly buttonHandlers;
16
+ private readonly durationTimers;
17
+ constructor(registry: AutomationRegistry, haClient: HAClient, onMatch: (automation: Automation<unknown>, event: TriggerEvent) => void, mqttClient?: MqttClient | undefined);
18
+ private rebuildButtonHandlers;
19
+ start(): void;
20
+ dispatch(event: TriggerEvent): void;
21
+ private matchAndFire;
22
+ }