@ajclarkson/homerun 0.0.1-edge.0131fc6

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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +276 -0
  3. package/dist/scripts/generate-ha-services.d.ts +16 -0
  4. package/dist/scripts/generate-ha-services.js +111 -0
  5. package/dist/scripts/generate-ha-types.d.ts +8 -0
  6. package/dist/scripts/generate-ha-types.js +75 -0
  7. package/dist/src/framework/action-runtime.d.ts +39 -0
  8. package/dist/src/framework/action-runtime.js +193 -0
  9. package/dist/src/framework/api-server.d.ts +35 -0
  10. package/dist/src/framework/api-server.js +132 -0
  11. package/dist/src/framework/config.d.ts +34 -0
  12. package/dist/src/framework/config.js +93 -0
  13. package/dist/src/framework/event-publisher.d.ts +65 -0
  14. package/dist/src/framework/event-publisher.js +85 -0
  15. package/dist/src/framework/ha-client.d.ts +85 -0
  16. package/dist/src/framework/ha-client.js +201 -0
  17. package/dist/src/framework/hot-reload.d.ts +9 -0
  18. package/dist/src/framework/hot-reload.js +88 -0
  19. package/dist/src/framework/metrics-prom.d.ts +18 -0
  20. package/dist/src/framework/metrics-prom.js +93 -0
  21. package/dist/src/framework/metrics.d.ts +10 -0
  22. package/dist/src/framework/metrics.js +5 -0
  23. package/dist/src/framework/pipeline.d.ts +14 -0
  24. package/dist/src/framework/pipeline.js +85 -0
  25. package/dist/src/framework/registry.d.ts +10 -0
  26. package/dist/src/framework/registry.js +24 -0
  27. package/dist/src/framework/scheduler.d.ts +13 -0
  28. package/dist/src/framework/scheduler.js +55 -0
  29. package/dist/src/framework/timer-manager.d.ts +9 -0
  30. package/dist/src/framework/timer-manager.js +28 -0
  31. package/dist/src/framework/trigger-engine.d.ts +28 -0
  32. package/dist/src/framework/trigger-engine.js +321 -0
  33. package/dist/src/index.d.ts +1 -0
  34. package/dist/src/index.js +157 -0
  35. package/dist/src/lib.d.ts +6 -0
  36. package/dist/src/lib.js +2 -0
  37. package/dist/src/services.d.ts +178 -0
  38. package/dist/src/services.js +106 -0
  39. package/dist/src/testing.d.ts +18 -0
  40. package/dist/src/testing.js +60 -0
  41. package/dist/src/types/actions.d.ts +23 -0
  42. package/dist/src/types/actions.js +1 -0
  43. package/dist/src/types/automation.d.ts +32 -0
  44. package/dist/src/types/automation.js +44 -0
  45. package/dist/src/types/triggers.d.ts +77 -0
  46. package/dist/src/types/triggers.js +26 -0
  47. package/package.json +61 -0
@@ -0,0 +1,85 @@
1
+ import { type HassServiceTarget } from 'home-assistant-js-websocket';
2
+ import { EventEmitter } from 'node:events';
3
+ import type { Action } from '../types/actions.js';
4
+ export interface EntityState {
5
+ entity_id: string;
6
+ state: string;
7
+ attributes: Record<string, unknown>;
8
+ last_changed: string;
9
+ last_updated: string;
10
+ }
11
+ declare global {
12
+ interface HAEntities {
13
+ }
14
+ }
15
+ type _HAStateKey = keyof HAEntities extends never ? string : keyof HAEntities;
16
+ export type HAState = <E extends _HAStateKey>(entity: E) => (E extends keyof HAEntities ? EntityState & HAEntities[E] : EntityState) | undefined;
17
+ export interface HAContext {
18
+ entitiesByLabel: (label: string) => string[];
19
+ labelsFor: (entity: string) => string[];
20
+ entitiesByArea: (area: string) => string[];
21
+ }
22
+ export interface StateChangedEvent {
23
+ entity_id: string;
24
+ old_state: EntityState | undefined;
25
+ new_state: EntityState;
26
+ correlation_id: string;
27
+ parent_correlation_id?: string;
28
+ root_correlation_id?: string;
29
+ parent_automation_id?: string;
30
+ }
31
+ export interface WriteOrigin {
32
+ correlationId: string;
33
+ rootCorrelationId?: string;
34
+ automationId: string;
35
+ }
36
+ export interface AckOrigin {
37
+ correlationId: string;
38
+ rootCorrelationId?: string;
39
+ automationId: string;
40
+ location: string;
41
+ subsystem: string;
42
+ action: Action;
43
+ expected: Record<string, unknown>;
44
+ }
45
+ export interface AckTimeoutEvent extends AckOrigin {
46
+ entity_id: string;
47
+ }
48
+ export declare interface HAClient {
49
+ on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
50
+ on(event: 'ready', listener: () => void): this;
51
+ on(event: 'reconnected', listener: () => void): this;
52
+ on(event: 'action_ack_timeout', listener: (e: AckTimeoutEvent) => void): this;
53
+ emit(event: 'state_changed', e: StateChangedEvent): boolean;
54
+ emit(event: 'ready'): boolean;
55
+ emit(event: 'reconnected'): boolean;
56
+ emit(event: 'action_ack_timeout', e: AckTimeoutEvent): boolean;
57
+ }
58
+ export declare class HAClient extends EventEmitter {
59
+ private readonly stateCache;
60
+ private readonly labelToEntities;
61
+ private readonly entityToLabels;
62
+ private readonly areaToEntities;
63
+ private readonly pendingWrites;
64
+ private readonly pendingAcks;
65
+ private connection;
66
+ private reconnecting;
67
+ private _readyResolve;
68
+ readonly ready: Promise<void>;
69
+ readonly state: HAState;
70
+ readonly context: HAContext;
71
+ get entityCount(): number;
72
+ get registryStats(): {
73
+ labels: number;
74
+ areas: number;
75
+ };
76
+ callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>, origin?: WriteOrigin): Promise<void>;
77
+ registerPendingWrite(entityId: string, origin: WriteOrigin): void;
78
+ registerPendingAck(entityId: string, origin: AckOrigin, timeoutMs: number): void;
79
+ disconnect(): void;
80
+ connect(url: string, token: string): Promise<void>;
81
+ private repopulateCache;
82
+ private diffAndUpdate;
83
+ private loadEntityRegistry;
84
+ }
85
+ export {};
@@ -0,0 +1,201 @@
1
+ import { callService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
2
+ import { EventEmitter } from 'node:events';
3
+ const PENDING_WRITE_TTL_MS = 2000;
4
+ function ackSatisfied(expected, state) {
5
+ // HA represents every entity's state as a string, even numeric domains (number,
6
+ // input_number, ...) — a dispatched call's data.value is typically a plain JS number, so
7
+ // comparing `state` with strict equality never matches regardless of what HA does. See #158.
8
+ return Object.entries(expected).every(([key, value]) => key === 'state' ? String(state.state) === String(value) : state.attributes[key] === value);
9
+ }
10
+ export class HAClient extends EventEmitter {
11
+ stateCache = new Map();
12
+ labelToEntities = new Map();
13
+ entityToLabels = new Map();
14
+ areaToEntities = new Map();
15
+ // entity_id -> origin of the write that's expected to produce a state_changed for it.
16
+ // Self-evicts after PENDING_WRITE_TTL_MS; consumed (deleted) the moment it's matched.
17
+ pendingWrites = new Map();
18
+ // entity_id -> ack tracking for a dispatched call awaiting its expected state/attribute
19
+ // change. Consumed (timer cleared, deleted) the moment diffAndUpdate sees a satisfying
20
+ // state_changed; otherwise self-fires action_ack_timeout after its own timeout.
21
+ pendingAcks = new Map();
22
+ connection = null;
23
+ // True between a disconnect event and the next subscribeEntities callback.
24
+ // During this window we repopulate silently — no state_changed events emitted.
25
+ reconnecting = false;
26
+ _readyResolve;
27
+ ready = new Promise((resolve) => {
28
+ this._readyResolve = resolve;
29
+ });
30
+ // Synchronous accessors passed into context builders.
31
+ state = ((entity) => this.stateCache.get(entity));
32
+ context = {
33
+ entitiesByLabel: (label) => Array.from(this.labelToEntities.get(label) ?? []),
34
+ labelsFor: (entity) => this.entityToLabels.get(entity) ?? [],
35
+ entitiesByArea: (area) => Array.from(this.areaToEntities.get(area) ?? []),
36
+ };
37
+ get entityCount() {
38
+ return this.stateCache.size;
39
+ }
40
+ get registryStats() {
41
+ return { labels: this.labelToEntities.size, areas: this.areaToEntities.size };
42
+ }
43
+ async callService(domain, service, target, data, origin) {
44
+ if (!this.connection)
45
+ throw new Error('HAClient not connected');
46
+ const entityId = target?.entity_id;
47
+ if (origin && entityId)
48
+ this.registerPendingWrite(entityId, origin);
49
+ await callService(this.connection, domain, service, data, target);
50
+ }
51
+ // Registers that `origin` is expected to produce a state_changed for `entityId` — consumed
52
+ // (and stamped as parent_correlation_id/parent_automation_id) by diffAndUpdate on match, or
53
+ // self-evicted after PENDING_WRITE_TTL_MS if nothing arrives. Used by ha.call_service writes
54
+ // (automatically, via target.entity_id) and by mqtt.publish writes that declare `impliesEntity`
55
+ // (explicitly, since a topic alone carries no entity information — see #28, #138).
56
+ registerPendingWrite(entityId, origin) {
57
+ this.pendingWrites.set(entityId, origin);
58
+ setTimeout(() => this.pendingWrites.delete(entityId), PENDING_WRITE_TTL_MS);
59
+ }
60
+ // Registers that `entityId` is expected to reach `origin.expected` (a field/attribute ->
61
+ // value map) within `timeoutMs`. Consumed (timer cleared, deleted) by diffAndUpdate the
62
+ // moment a satisfying state_changed arrives; otherwise emits action_ack_timeout on expiry
63
+ // with the same origin, so a listener has everything needed to publish a self-sufficient
64
+ // ObsEvent without HAClient itself knowing about observability. See #55.
65
+ registerPendingAck(entityId, origin, timeoutMs) {
66
+ const timer = setTimeout(() => {
67
+ this.pendingAcks.delete(entityId);
68
+ this.emit('action_ack_timeout', { ...origin, entity_id: entityId });
69
+ }, timeoutMs);
70
+ this.pendingAcks.set(entityId, { ...origin, timer });
71
+ }
72
+ disconnect() {
73
+ this.connection?.close();
74
+ this.connection = null;
75
+ }
76
+ async connect(url, token) {
77
+ const auth = createLongLivedTokenAuth(url, token);
78
+ this.connection = await createConnection({ auth });
79
+ // Reload registry and repopulate cache silently on reconnect.
80
+ this.connection.addEventListener('disconnected', () => {
81
+ this.reconnecting = true;
82
+ });
83
+ await this.loadEntityRegistry();
84
+ this.connection.subscribeEvents(() => {
85
+ console.log('[ha-client] entity_registry_updated received — reloading registry');
86
+ this.loadEntityRegistry()
87
+ .then(() => {
88
+ console.log(`[ha-client] registry reloaded (${this.labelToEntities.size} labels, ${this.areaToEntities.size} areas)`);
89
+ })
90
+ .catch((err) => {
91
+ console.error('[ha-client] registry reload failed after entity_registry_updated:', err);
92
+ });
93
+ }, 'entity_registry_updated').catch((err) => {
94
+ console.error('[ha-client] failed to subscribe to entity_registry_updated:', err);
95
+ });
96
+ let firstSnapshot = true;
97
+ subscribeEntities(this.connection, (entities) => {
98
+ if (firstSnapshot) {
99
+ firstSnapshot = false;
100
+ this.repopulateCache(entities);
101
+ this._readyResolve();
102
+ this.emit('ready');
103
+ }
104
+ else if (this.reconnecting) {
105
+ this.reconnecting = false;
106
+ // Reload registry in the background — don't block the cache repopulate.
107
+ this.loadEntityRegistry().catch((err) => {
108
+ console.error('[ha-client] registry reload failed after reconnect:', err);
109
+ });
110
+ this.repopulateCache(entities);
111
+ this.emit('reconnected');
112
+ }
113
+ else {
114
+ this.diffAndUpdate(entities);
115
+ }
116
+ });
117
+ }
118
+ // ---------- Private ----------
119
+ repopulateCache(entities) {
120
+ this.stateCache.clear();
121
+ for (const [id, entity] of Object.entries(entities)) {
122
+ this.stateCache.set(id, toEntityState(id, entity));
123
+ }
124
+ }
125
+ diffAndUpdate(entities) {
126
+ for (const [id, rawEntity] of Object.entries(entities)) {
127
+ const old_state = this.stateCache.get(id);
128
+ const new_state = toEntityState(id, rawEntity);
129
+ // last_updated changes whenever state or attributes change in HA. It's also compared as
130
+ // a millisecond-precision string (see toEntityState), so two distinct state changes to
131
+ // the same entity within the same millisecond collide on last_updated alone — compare
132
+ // state directly too so a genuine transition is never silently dropped. See #170.
133
+ if (!old_state || old_state.state !== new_state.state || old_state.last_updated !== new_state.last_updated) {
134
+ this.stateCache.set(id, new_state);
135
+ const ack = this.pendingAcks.get(id);
136
+ if (ack && ackSatisfied(ack.expected, new_state)) {
137
+ clearTimeout(ack.timer);
138
+ this.pendingAcks.delete(id);
139
+ }
140
+ const correlation_id = crypto.randomUUID();
141
+ const origin = this.pendingWrites.get(id);
142
+ if (origin)
143
+ this.pendingWrites.delete(id);
144
+ this.emit('state_changed', {
145
+ entity_id: id,
146
+ old_state,
147
+ new_state,
148
+ correlation_id,
149
+ root_correlation_id: origin ? (origin.rootCorrelationId ?? origin.correlationId) : correlation_id,
150
+ ...(origin && { parent_correlation_id: origin.correlationId, parent_automation_id: origin.automationId }),
151
+ });
152
+ }
153
+ }
154
+ // Prune entities that disappeared from the snapshot.
155
+ for (const id of this.stateCache.keys()) {
156
+ if (!(id in entities)) {
157
+ this.stateCache.delete(id);
158
+ }
159
+ }
160
+ }
161
+ async loadEntityRegistry() {
162
+ if (!this.connection)
163
+ return;
164
+ const entries = (await this.connection.sendMessagePromise({
165
+ type: 'config/entity_registry/list',
166
+ }));
167
+ this.labelToEntities.clear();
168
+ this.entityToLabels.clear();
169
+ this.areaToEntities.clear();
170
+ for (const entry of entries) {
171
+ const labels = entry.labels ?? [];
172
+ this.entityToLabels.set(entry.entity_id, labels);
173
+ for (const label of labels) {
174
+ let set = this.labelToEntities.get(label);
175
+ if (!set) {
176
+ set = new Set();
177
+ this.labelToEntities.set(label, set);
178
+ }
179
+ set.add(entry.entity_id);
180
+ }
181
+ if (entry.area_id) {
182
+ let set = this.areaToEntities.get(entry.area_id);
183
+ if (!set) {
184
+ set = new Set();
185
+ this.areaToEntities.set(entry.area_id, set);
186
+ }
187
+ set.add(entry.entity_id);
188
+ }
189
+ }
190
+ }
191
+ }
192
+ // ---------- Helpers ----------
193
+ function toEntityState(entity_id, entity) {
194
+ return {
195
+ entity_id,
196
+ state: entity.state,
197
+ attributes: entity.attributes,
198
+ last_changed: entity.last_changed,
199
+ last_updated: entity.last_updated,
200
+ };
201
+ }
@@ -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,88 @@
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 EXCLUDED_DIRS = new Set(['types', 'lib']);
49
+ const isAutomationFile = (f) => f.endsWith('.ts') &&
50
+ !f.endsWith('.test.ts') &&
51
+ !f.includes('node_modules') &&
52
+ !f.includes('.d.ts') &&
53
+ !f.split(path.sep).some(seg => EXCLUDED_DIRS.has(seg));
54
+ export async function rescanAutomations(automationsDir, registry, fileToIds = moduleFileToIds, importer = defaultImporter) {
55
+ let files = [];
56
+ try {
57
+ const { readdir } = await import('node:fs/promises');
58
+ files = (await readdir(automationsDir, { recursive: true }));
59
+ }
60
+ catch {
61
+ console.warn(`[homerun] AUTOMATIONS_DIR not found: ${automationsDir} — starting with no automations`);
62
+ }
63
+ const currentPaths = new Set(files.filter(isAutomationFile).map((f) => path.join(automationsDir, f)));
64
+ for (const trackedPath of [...fileToIds.keys()]) {
65
+ if (!currentPaths.has(trackedPath)) {
66
+ _deleteFile(trackedPath, registry, fileToIds);
67
+ }
68
+ }
69
+ for (const filePath of currentPaths) {
70
+ await _reloadFile(filePath, registry, importer, fileToIds);
71
+ }
72
+ }
73
+ export function startHotReload(automationsDir, registry) {
74
+ const target = process.env.AUTOMATION
75
+ ? path.join(automationsDir, `${process.env.AUTOMATION}.ts`)
76
+ : `${automationsDir}/**/*.ts`;
77
+ const watcher = watch(target, { ignoreInitial: true, ignored: [/node_modules/, /\.test\.ts$/] });
78
+ const reload = (filePath) => {
79
+ _reloadFile(filePath, registry).catch((err) => {
80
+ console.error('[hot-reload] unexpected error:', err);
81
+ });
82
+ };
83
+ watcher.on('add', reload);
84
+ watcher.on('change', reload);
85
+ watcher.on('unlink', (filePath) => {
86
+ _deleteFile(filePath, registry);
87
+ });
88
+ }
@@ -0,0 +1,18 @@
1
+ import { Registry } from 'prom-client';
2
+ import type { MetricsBackend } from './metrics.js';
3
+ export declare class PromMetricsBackend implements MetricsBackend {
4
+ readonly registry: Registry<"text/plain; version=0.0.4; charset=utf-8">;
5
+ private readonly haEventsTotal;
6
+ private readonly pipelineRunsTotal;
7
+ private readonly actionsDispatchedTotal;
8
+ private readonly actionsSucceededTotal;
9
+ private readonly actionsFailedTotal;
10
+ private readonly actionDurationSeconds;
11
+ private readonly automationsLoaded;
12
+ constructor(collectDefaults?: boolean);
13
+ incrementCounter(name: string, labels?: Record<string, string>): void;
14
+ observeHistogram(name: string, value: number, labels?: Record<string, string>): void;
15
+ setGauge(name: string, value: number): void;
16
+ getMetrics(): Promise<string>;
17
+ get contentType(): string;
18
+ }
@@ -0,0 +1,93 @@
1
+ import { Registry, Counter, Gauge, Histogram, collectDefaultMetrics } from 'prom-client';
2
+ export class PromMetricsBackend {
3
+ registry = new Registry();
4
+ haEventsTotal;
5
+ pipelineRunsTotal;
6
+ actionsDispatchedTotal;
7
+ actionsSucceededTotal;
8
+ actionsFailedTotal;
9
+ actionDurationSeconds;
10
+ automationsLoaded;
11
+ constructor(collectDefaults = false) {
12
+ if (collectDefaults) {
13
+ collectDefaultMetrics({ register: this.registry });
14
+ }
15
+ this.haEventsTotal = new Counter({
16
+ name: 'homerun_ha_events_received_total',
17
+ help: 'Total HA and MQTT events received by homerun',
18
+ labelNames: ['event_type'],
19
+ registers: [this.registry],
20
+ });
21
+ this.pipelineRunsTotal = new Counter({
22
+ name: 'homerun_pipeline_runs_total',
23
+ help: 'Total automation pipeline runs',
24
+ labelNames: ['location', 'trigger_type'],
25
+ registers: [this.registry],
26
+ });
27
+ this.actionsDispatchedTotal = new Counter({
28
+ name: 'homerun_actions_dispatched_total',
29
+ help: 'Total actions dispatched by the action runtime',
30
+ labelNames: ['location', 'action_type'],
31
+ registers: [this.registry],
32
+ });
33
+ this.actionsSucceededTotal = new Counter({
34
+ name: 'homerun_actions_succeeded_total',
35
+ help: 'Total actions that completed successfully',
36
+ labelNames: ['location', 'action_type'],
37
+ registers: [this.registry],
38
+ });
39
+ this.actionsFailedTotal = new Counter({
40
+ name: 'homerun_actions_failed_total',
41
+ help: 'Total actions that failed with an error',
42
+ labelNames: ['location', 'action_type'],
43
+ registers: [this.registry],
44
+ });
45
+ this.actionDurationSeconds = new Histogram({
46
+ name: 'homerun_action_duration_seconds',
47
+ help: 'Duration of action execution in seconds',
48
+ labelNames: ['location', 'action_type'],
49
+ buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
50
+ registers: [this.registry],
51
+ });
52
+ this.automationsLoaded = new Gauge({
53
+ name: 'homerun_automations_loaded',
54
+ help: 'Number of automations currently loaded',
55
+ registers: [this.registry],
56
+ });
57
+ }
58
+ incrementCounter(name, labels = {}) {
59
+ switch (name) {
60
+ case 'homerun_ha_events_received_total':
61
+ this.haEventsTotal.inc(labels);
62
+ break;
63
+ case 'homerun_pipeline_runs_total':
64
+ this.pipelineRunsTotal.inc(labels);
65
+ break;
66
+ case 'homerun_actions_dispatched_total':
67
+ this.actionsDispatchedTotal.inc(labels);
68
+ break;
69
+ case 'homerun_actions_succeeded_total':
70
+ this.actionsSucceededTotal.inc(labels);
71
+ break;
72
+ case 'homerun_actions_failed_total':
73
+ this.actionsFailedTotal.inc(labels);
74
+ break;
75
+ }
76
+ }
77
+ observeHistogram(name, value, labels = {}) {
78
+ if (name === 'homerun_action_duration_seconds') {
79
+ this.actionDurationSeconds.observe(labels, value);
80
+ }
81
+ }
82
+ setGauge(name, value) {
83
+ if (name === 'homerun_automations_loaded') {
84
+ this.automationsLoaded.set(value);
85
+ }
86
+ }
87
+ async getMetrics() {
88
+ return this.registry.metrics();
89
+ }
90
+ get contentType() {
91
+ return this.registry.contentType;
92
+ }
93
+ }
@@ -0,0 +1,10 @@
1
+ export interface MetricsBackend {
2
+ incrementCounter(name: string, labels?: Record<string, string>): void;
3
+ observeHistogram(name: string, value: number, labels?: Record<string, string>): void;
4
+ setGauge(name: string, value: number, labels?: Record<string, string>): void;
5
+ }
6
+ export declare class NoopMetricsBackend implements MetricsBackend {
7
+ incrementCounter(): void;
8
+ observeHistogram(): void;
9
+ setGauge(): void;
10
+ }
@@ -0,0 +1,5 @@
1
+ export class NoopMetricsBackend {
2
+ incrementCounter() { }
3
+ observeHistogram() { }
4
+ setGauge() { }
5
+ }
@@ -0,0 +1,14 @@
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 { EventPublisher } from './event-publisher.js';
5
+ import type { ActionRuntime } from './action-runtime.js';
6
+ import type { MetricsBackend } from './metrics.js';
7
+ interface Deps {
8
+ eventPublisher: EventPublisher;
9
+ actionRuntime: ActionRuntime;
10
+ dryRun?: boolean;
11
+ metrics?: MetricsBackend;
12
+ }
13
+ export declare function runPipeline(automation: Automation<unknown>, event: TriggerEvent, haClient: HAClient, deps: Deps): Promise<void>;
14
+ export {};
@@ -0,0 +1,85 @@
1
+ import { summarizeTrigger } from '../types/triggers.js';
2
+ import { isAbort, UnavailableInputError } from '../types/automation.js';
3
+ export async function runPipeline(automation, event, haClient, deps) {
4
+ deps.metrics?.incrementCounter('homerun_pipeline_runs_total', {
5
+ location: automation.location,
6
+ trigger_type: event.type,
7
+ });
8
+ const correlationId = event.correlation_id;
9
+ const rootCorrelationId = event.root_correlation_id ?? correlationId;
10
+ const timestamp = new Date().toISOString();
11
+ const trigger = summarizeTrigger(event);
12
+ const base = {
13
+ schema: 'home.events.v2',
14
+ correlation_id: correlationId,
15
+ root_correlation_id: rootCorrelationId,
16
+ automation_id: automation.id,
17
+ location: automation.location,
18
+ subsystem: automation.subsystem,
19
+ timestamp,
20
+ ...(deps.dryRun ? { dry_run: true } : {}),
21
+ ...(event.parent_correlation_id && { parent_correlation_id: event.parent_correlation_id }),
22
+ ...(event.parent_automation_id && { parent_automation_id: event.parent_automation_id }),
23
+ };
24
+ // Step 1: Enabled check
25
+ if (automation.enabled === false) {
26
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'disabled', trigger });
27
+ return;
28
+ }
29
+ // Step 2: Context
30
+ let ctx;
31
+ try {
32
+ ctx = automation.context(haClient.state, haClient.context, event);
33
+ }
34
+ catch (err) {
35
+ if (err instanceof UnavailableInputError) {
36
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unavailable_input', entity: err.entityId, trigger });
37
+ }
38
+ else {
39
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unhandled_error', trigger });
40
+ }
41
+ return;
42
+ }
43
+ if (isAbort(ctx)) {
44
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'guard', reason: ctx.reason, trigger });
45
+ return;
46
+ }
47
+ // Step 3: Reduce
48
+ let result;
49
+ try {
50
+ result = automation.reduce(ctx);
51
+ }
52
+ catch {
53
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unhandled_error', trigger });
54
+ return;
55
+ }
56
+ // Step 4: Validate — safe defaults
57
+ const actions = result.actions ?? [];
58
+ // Defaults to the full context object so authors get observability "for free" without
59
+ // hand-duplicating context fields into conditions — reduce() can still override with its
60
+ // own (e.g. trimmed) conditions when the full context isn't what should be published.
61
+ const conditions = result.conditions ?? ctx;
62
+ const decision = {
63
+ ...base,
64
+ event_type: 'decision',
65
+ trigger,
66
+ decision: result.decision,
67
+ reason: result.reason,
68
+ conditions,
69
+ actions,
70
+ hasAction: actions.length > 0,
71
+ };
72
+ // Step 5: Fanout
73
+ await Promise.all([
74
+ Promise.resolve(deps.eventPublisher.publishDecision(decision)),
75
+ deps.actionRuntime.execute(actions, {
76
+ correlationId,
77
+ automationId: automation.id,
78
+ location: automation.location,
79
+ subsystem: automation.subsystem,
80
+ rootCorrelationId,
81
+ ...(event.parent_correlation_id && { parentCorrelationId: event.parent_correlation_id }),
82
+ ...(event.parent_automation_id && { parentAutomationId: event.parent_automation_id }),
83
+ }),
84
+ ]);
85
+ }
@@ -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,24 @@
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
+ if (!this.automations.delete(id))
14
+ return;
15
+ for (const cb of this.changeCallbacks)
16
+ cb();
17
+ }
18
+ getAll() {
19
+ return Array.from(this.automations.values());
20
+ }
21
+ getById(id) {
22
+ return this.automations.get(id);
23
+ }
24
+ }
@@ -0,0 +1,13 @@
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
+ sync(automations: Automation<unknown>[]): void;
11
+ stop(): void;
12
+ private registerCronTriggers;
13
+ }