@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,55 @@
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
+ this.registerCronTriggers(this.automations);
14
+ this.ready.then(() => {
15
+ const correlation_id = crypto.randomUUID();
16
+ this.dispatch({ type: 'on_start', correlation_id, root_correlation_id: correlation_id });
17
+ }).catch((err) => {
18
+ console.error('[scheduler] ready promise rejected:', err);
19
+ });
20
+ }
21
+ sync(automations) {
22
+ this.stop();
23
+ this.registerCronTriggers(automations);
24
+ }
25
+ stop() {
26
+ for (const cleanup of this.cleanups)
27
+ cleanup();
28
+ this.cleanups.length = 0;
29
+ }
30
+ registerCronTriggers(automations) {
31
+ const automationIdsByExpression = new Map();
32
+ for (const automation of automations) {
33
+ for (const trigger of automation.triggers) {
34
+ if (trigger.type === 'schedule') {
35
+ const ids = automationIdsByExpression.get(trigger.cron) ?? [];
36
+ ids.push(automation.id);
37
+ automationIdsByExpression.set(trigger.cron, ids);
38
+ }
39
+ }
40
+ }
41
+ for (const [expression, automationIds] of automationIdsByExpression) {
42
+ try {
43
+ const task = cron.schedule(expression, () => {
44
+ const correlation_id = crypto.randomUUID();
45
+ this.dispatch({ type: 'schedule', cron: expression, correlation_id, root_correlation_id: correlation_id });
46
+ });
47
+ this.cleanups.push(() => task.stop());
48
+ console.log(`[scheduler] registered cron "${expression}" for ${automationIds.join(', ')}`);
49
+ }
50
+ catch (err) {
51
+ console.error(`[scheduler] failed to register cron "${expression}" for ${automationIds.join(', ')}:`, err);
52
+ }
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,9 @@
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
+ cancelAll(): void;
9
+ }
@@ -0,0 +1,28 @@
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
+ const correlation_id = crypto.randomUUID();
12
+ this.dispatch({ type: 'timer_expired', timerKey, correlation_id, root_correlation_id: correlation_id });
13
+ }, delayMs);
14
+ this.timers.set(timerKey, handle);
15
+ }
16
+ cancel(timerKey) {
17
+ const handle = this.timers.get(timerKey);
18
+ if (handle !== undefined) {
19
+ clearTimeout(handle);
20
+ this.timers.delete(timerKey);
21
+ }
22
+ }
23
+ cancelAll() {
24
+ for (const handle of this.timers.values())
25
+ clearTimeout(handle);
26
+ this.timers.clear();
27
+ }
28
+ }
@@ -0,0 +1,28 @@
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
+ import type { MetricsBackend } from './metrics.js';
7
+ export declare function parseButtonAction(state: string): {
8
+ button?: string;
9
+ pressType: 'short' | 'hold';
10
+ } | null;
11
+ export declare class TriggerEngine {
12
+ private readonly registry;
13
+ private readonly haClient;
14
+ private readonly onMatch;
15
+ private readonly mqttClient?;
16
+ private readonly metrics?;
17
+ private readonly buttonHandlers;
18
+ private regexButtonTriggers;
19
+ private readonly durationTimers;
20
+ private readonly subscribedTopics;
21
+ private started;
22
+ constructor(registry: AutomationRegistry, haClient: HAClient, onMatch: (automation: Automation<unknown>, event: TriggerEvent) => void, mqttClient?: MqttClient | undefined, metrics?: MetricsBackend | undefined);
23
+ private syncMqttSubscriptions;
24
+ private rebuildButtonHandlers;
25
+ start(): void;
26
+ dispatch(event: TriggerEvent): void;
27
+ private matchAndFire;
28
+ }
@@ -0,0 +1,321 @@
1
+ const DOUBLE_PRESS_WINDOW_MS = 250;
2
+ class ButtonGestureHandler {
3
+ entityId;
4
+ dispatch;
5
+ supportsDoublePress;
6
+ gestureState = 'idle';
7
+ resolveTimer = null;
8
+ holdFired = false;
9
+ // Set when 'release' fires a short press; suppresses the trailing confirmation
10
+ // event ('on', 'toggle', etc.) so the same physical press doesn't fire twice.
11
+ shortFired = false;
12
+ constructor(entityId, dispatch, supportsDoublePress) {
13
+ this.entityId = entityId;
14
+ this.dispatch = dispatch;
15
+ this.supportsDoublePress = supportsDoublePress;
16
+ }
17
+ handle(actionState, corr) {
18
+ if (actionState === '')
19
+ return;
20
+ const parsed = parseButtonAction(actionState);
21
+ if (!parsed)
22
+ return;
23
+ const { button, pressType } = parsed;
24
+ const isRelease = actionState.toLowerCase() === 'release';
25
+ if (pressType === 'hold') {
26
+ // Cancel any pending double-press window and fire hold exactly once per physical hold.
27
+ if (this.resolveTimer) {
28
+ clearTimeout(this.resolveTimer);
29
+ this.resolveTimer = null;
30
+ this.gestureState = 'idle';
31
+ }
32
+ if (!this.holdFired) {
33
+ this.holdFired = true;
34
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, ...corr });
35
+ }
36
+ return;
37
+ }
38
+ // pressType === 'short'
39
+ if (isRelease && this.holdFired) {
40
+ // Physical release after a hold — reset hold state, don't fire short press.
41
+ this.holdFired = false;
42
+ return;
43
+ }
44
+ if (!isRelease && this.shortFired) {
45
+ // Confirmation event ('on', 'toggle', etc.) after 'release' already fired — suppress.
46
+ this.shortFired = false;
47
+ return;
48
+ }
49
+ if (isRelease)
50
+ this.shortFired = true;
51
+ if (this.gestureState === 'idle') {
52
+ if (!this.supportsDoublePress) {
53
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, ...corr });
54
+ }
55
+ else {
56
+ this.gestureState = 'resolving';
57
+ this.resolveTimer = setTimeout(() => {
58
+ this.resolveTimer = null;
59
+ this.gestureState = 'idle';
60
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, ...corr });
61
+ }, DOUBLE_PRESS_WINDOW_MS);
62
+ }
63
+ }
64
+ else {
65
+ // resolving — second short press = double press
66
+ clearTimeout(this.resolveTimer);
67
+ this.resolveTimer = null;
68
+ this.gestureState = 'idle';
69
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, ...corr });
70
+ }
71
+ }
72
+ }
73
+ // Classifies a Z2M action state string.
74
+ // Numeric button prefix ("1_short_release") is separated out and returned as `button`.
75
+ // Recognises common Z2M short/hold conventions; returns null for unrecognised values.
76
+ export function parseButtonAction(state) {
77
+ const s = state.toLowerCase();
78
+ const prefixed = s.match(/^(\d+)[_-](.+)$/);
79
+ const button = prefixed?.[1];
80
+ const action = prefixed ? prefixed[2] : s;
81
+ if (/long|hold/.test(action))
82
+ return { button, pressType: 'hold' };
83
+ if (/short|click|single/.test(action) || action === 'on' || action === 'toggle' || action === 'release') {
84
+ return { button, pressType: 'short' };
85
+ }
86
+ return null;
87
+ }
88
+ // ---------- Trigger Engine ----------
89
+ export class TriggerEngine {
90
+ registry;
91
+ haClient;
92
+ onMatch;
93
+ mqttClient;
94
+ metrics;
95
+ buttonHandlers = new Map();
96
+ regexButtonTriggers = [];
97
+ durationTimers = new Map();
98
+ subscribedTopics = new Set();
99
+ started = false;
100
+ constructor(registry, haClient, onMatch, mqttClient, metrics) {
101
+ this.registry = registry;
102
+ this.haClient = haClient;
103
+ this.onMatch = onMatch;
104
+ this.mqttClient = mqttClient;
105
+ this.metrics = metrics;
106
+ this.rebuildButtonHandlers();
107
+ registry.onChange(() => {
108
+ this.rebuildButtonHandlers();
109
+ this.syncMqttSubscriptions();
110
+ });
111
+ }
112
+ // Reconciles the set of subscribed mqtt_in topics against the registry's current
113
+ // automations. Called on start() and again whenever the registry changes (hot
114
+ // reload / git-sync rescan) so topics added or removed after startup take effect.
115
+ syncMqttSubscriptions() {
116
+ if (!this.mqttClient || !this.started)
117
+ return;
118
+ const desired = new Set();
119
+ for (const automation of this.registry.getAll()) {
120
+ for (const trigger of automation.triggers) {
121
+ if (trigger.type === 'mqtt_in')
122
+ desired.add(trigger.topic);
123
+ }
124
+ }
125
+ for (const topic of desired) {
126
+ if (!this.subscribedTopics.has(topic)) {
127
+ this.mqttClient.subscribe(topic);
128
+ this.subscribedTopics.add(topic);
129
+ console.log(`[trigger-engine] mqtt subscribed: ${topic}`);
130
+ }
131
+ }
132
+ for (const topic of this.subscribedTopics) {
133
+ if (!desired.has(topic)) {
134
+ this.mqttClient.unsubscribe(topic);
135
+ this.subscribedTopics.delete(topic);
136
+ console.log(`[trigger-engine] mqtt unsubscribed: ${topic}`);
137
+ }
138
+ }
139
+ }
140
+ rebuildButtonHandlers() {
141
+ this.buttonHandlers.clear();
142
+ this.regexButtonTriggers = [];
143
+ const entityGestures = new Map();
144
+ const regexGestures = new Map();
145
+ for (const automation of this.registry.getAll()) {
146
+ for (const trigger of automation.triggers) {
147
+ if (trigger.type === 'button') {
148
+ if (typeof trigger.entity === 'string') {
149
+ if (!entityGestures.has(trigger.entity))
150
+ entityGestures.set(trigger.entity, new Set());
151
+ entityGestures.get(trigger.entity).add(trigger.gesture);
152
+ }
153
+ else {
154
+ if (!regexGestures.has(trigger.entity))
155
+ regexGestures.set(trigger.entity, new Set());
156
+ regexGestures.get(trigger.entity).add(trigger.gesture);
157
+ }
158
+ }
159
+ }
160
+ }
161
+ for (const [entity, gestures] of entityGestures) {
162
+ this.buttonHandlers.set(entity, new ButtonGestureHandler(entity, (e) => this.dispatch(e), gestures.has('double_press')));
163
+ }
164
+ for (const [pattern, gestures] of regexGestures) {
165
+ this.regexButtonTriggers.push({ pattern, gestures });
166
+ }
167
+ }
168
+ start() {
169
+ this.haClient.ready
170
+ .then(() => {
171
+ this.haClient.on('state_changed', (event) => {
172
+ this.metrics?.incrementCounter('homerun_ha_events_received_total', { event_type: 'state_changed' });
173
+ const corr = {
174
+ correlation_id: event.correlation_id,
175
+ ...(event.parent_correlation_id && { parent_correlation_id: event.parent_correlation_id }),
176
+ ...(event.root_correlation_id && { root_correlation_id: event.root_correlation_id }),
177
+ ...(event.parent_automation_id && { parent_automation_id: event.parent_automation_id }),
178
+ };
179
+ let handler = this.buttonHandlers.get(event.entity_id);
180
+ if (!handler) {
181
+ const matchingGestures = new Set();
182
+ for (const { pattern, gestures } of this.regexButtonTriggers) {
183
+ if (pattern.test(event.entity_id)) {
184
+ for (const g of gestures)
185
+ matchingGestures.add(g);
186
+ }
187
+ }
188
+ if (matchingGestures.size > 0) {
189
+ handler = new ButtonGestureHandler(event.entity_id, (e) => this.dispatch(e), matchingGestures.has('double_press'));
190
+ this.buttonHandlers.set(event.entity_id, handler);
191
+ }
192
+ }
193
+ if (handler) {
194
+ handler.handle(event.new_state.state, corr);
195
+ }
196
+ else {
197
+ this.dispatch({
198
+ type: 'state_changed',
199
+ entity_id: event.entity_id,
200
+ old_state: event.old_state,
201
+ new_state: event.new_state,
202
+ ...corr,
203
+ });
204
+ }
205
+ });
206
+ })
207
+ .catch((err) => {
208
+ console.error('[trigger-engine] failed to start:', err);
209
+ });
210
+ if (this.mqttClient) {
211
+ this.started = true;
212
+ this.syncMqttSubscriptions();
213
+ this.mqttClient.subscribe('homerun/trigger/+');
214
+ this.mqttClient.on('message', (topic, payload) => {
215
+ if (topic.startsWith('homerun/trigger/')) {
216
+ const automationId = topic.slice('homerun/trigger/'.length);
217
+ const automation = this.registry.getById(automationId);
218
+ if (!automation) {
219
+ console.warn(`[trigger-engine] manual trigger: no automation with id "${automationId}"`);
220
+ return;
221
+ }
222
+ const correlation_id = crypto.randomUUID();
223
+ this.onMatch(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
224
+ return;
225
+ }
226
+ const correlation_id = `mqtt-${Date.now()}`;
227
+ this.dispatch({
228
+ type: 'mqtt_in',
229
+ topic,
230
+ payload: payload.toString(),
231
+ correlation_id,
232
+ root_correlation_id: correlation_id,
233
+ });
234
+ });
235
+ }
236
+ }
237
+ // Entry point for Timer Manager loopback and resolved button gestures.
238
+ dispatch(event) {
239
+ this.matchAndFire(event);
240
+ }
241
+ // ---------- Private ----------
242
+ matchAndFire(event) {
243
+ for (const automation of this.registry.getAll()) {
244
+ for (const trigger of automation.triggers) {
245
+ if (matchesTrigger(trigger, event)) {
246
+ if (trigger.type === 'state_changed' && trigger.duration && event.type === 'state_changed') {
247
+ const key = `${automation.id}:${event.entity_id}`;
248
+ const existing = this.durationTimers.get(key);
249
+ if (existing !== undefined)
250
+ clearTimeout(existing);
251
+ const timer = setTimeout(() => {
252
+ this.durationTimers.delete(key);
253
+ if (this.haClient.state(event.entity_id)?.state === event.new_state.state) {
254
+ this.onMatch(automation, event);
255
+ }
256
+ }, trigger.duration);
257
+ this.durationTimers.set(key, timer);
258
+ }
259
+ else {
260
+ this.onMatch(automation, event);
261
+ }
262
+ break; // don't fire the same automation twice for one event
263
+ }
264
+ }
265
+ }
266
+ }
267
+ }
268
+ // ---------- Trigger matching ----------
269
+ function matchesTrigger(trigger, event) {
270
+ if (trigger.type !== event.type)
271
+ return false;
272
+ switch (trigger.type) {
273
+ case 'state_changed': {
274
+ if (event.type !== 'state_changed')
275
+ return false;
276
+ const entityMatch = typeof trigger.entity === 'string'
277
+ ? trigger.entity === event.entity_id
278
+ : trigger.entity.test(event.entity_id);
279
+ if (!entityMatch)
280
+ return false;
281
+ if (trigger.to !== undefined) {
282
+ const allowed = Array.isArray(trigger.to) ? trigger.to : [trigger.to];
283
+ if (!allowed.includes(event.new_state.state))
284
+ return false;
285
+ if (event.old_state?.state === event.new_state.state)
286
+ return false;
287
+ }
288
+ return true;
289
+ }
290
+ case 'timer_expired': {
291
+ if (event.type !== 'timer_expired')
292
+ return false;
293
+ return trigger.timerKey === event.timerKey;
294
+ }
295
+ case 'button': {
296
+ if (event.type !== 'button')
297
+ return false;
298
+ const entityMatch = typeof trigger.entity === 'string'
299
+ ? trigger.entity === event.entity_id
300
+ : trigger.entity.test(event.entity_id);
301
+ return (entityMatch &&
302
+ trigger.gesture === event.gesture &&
303
+ (trigger.button === undefined || trigger.button === event.button));
304
+ }
305
+ case 'schedule': {
306
+ if (event.type !== 'schedule')
307
+ return false;
308
+ return trigger.cron === event.cron;
309
+ }
310
+ case 'on_start':
311
+ return event.type === 'on_start';
312
+ case 'mqtt_in':
313
+ if (event.type !== 'mqtt_in')
314
+ return false;
315
+ return trigger.topic === event.topic;
316
+ default: {
317
+ const _exhaustive = trigger;
318
+ return false;
319
+ }
320
+ }
321
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,157 @@
1
+ import path from 'node:path';
2
+ import { connect } from 'mqtt';
3
+ import { loadConfig } from './framework/config.js';
4
+ import { HAClient } from './framework/ha-client.js';
5
+ import { AutomationRegistry } from './framework/registry.js';
6
+ import { EventPublisher } from './framework/event-publisher.js';
7
+ import { TimerManager } from './framework/timer-manager.js';
8
+ import { ActionRuntime } from './framework/action-runtime.js';
9
+ import { TriggerEngine } from './framework/trigger-engine.js';
10
+ import { Scheduler } from './framework/scheduler.js';
11
+ import { rescanAutomations, startHotReload } from './framework/hot-reload.js';
12
+ import { runPipeline } from './framework/pipeline.js';
13
+ import { ApiServer } from './framework/api-server.js';
14
+ import { PromMetricsBackend } from './framework/metrics-prom.js';
15
+ import { NoopMetricsBackend } from './framework/metrics.js';
16
+ process.on('uncaughtException', (err) => {
17
+ console.error('[homerun] uncaughtException:', err);
18
+ });
19
+ process.on('unhandledRejection', (reason) => {
20
+ console.error('[homerun] unhandledRejection:', reason);
21
+ });
22
+ // 0. Load and validate configuration before anything else.
23
+ const config = await loadConfig();
24
+ const { dry_run: dryRun } = config.options;
25
+ const metricsBackend = config.metrics.enabled ? new PromMetricsBackend(true) : new NoopMetricsBackend();
26
+ const lwtTopic = dryRun ? 'homerun/dev/status' : 'homerun/status';
27
+ const lwtPayload = JSON.stringify({ status: 'offline', timestamp: new Date().toISOString() });
28
+ // 1. Connect MQTT before anything else (EventPublisher and ActionRuntime need it).
29
+ const mqtt = connect(config.mqtt.url, {
30
+ will: { topic: lwtTopic, payload: lwtPayload, qos: 1, retain: true },
31
+ });
32
+ await new Promise((resolve, reject) => {
33
+ mqtt.once('connect', () => resolve());
34
+ mqtt.once('error', reject);
35
+ });
36
+ // 2. Instantiate components in dependency order.
37
+ // TimerManager holds a closure over `engine` — the late binding is intentional;
38
+ // `engine` is assigned before any timer can fire.
39
+ const haClient = new HAClient();
40
+ const registry = new AutomationRegistry();
41
+ const eventPublisher = new EventPublisher(mqtt, config.events.enabled);
42
+ let engine;
43
+ const timerManager = new TimerManager((e) => engine.dispatch(e));
44
+ const actionRuntime = new ActionRuntime({
45
+ haClient,
46
+ mqttClient: mqtt,
47
+ timerManager,
48
+ eventPublisher,
49
+ dryRun,
50
+ metrics: metricsBackend,
51
+ commandAck: config.commandAck,
52
+ });
53
+ // 3. Initial automation load — must complete before the engine and scheduler start.
54
+ const automationsDir = path.resolve(config.automations.dir);
55
+ await rescanAutomations(automationsDir, registry);
56
+ const initialCount = registry.getAll().length;
57
+ metricsBackend.setGauge('homerun_automations_loaded', initialCount);
58
+ console.log(`[homerun] loaded ${initialCount} automation(s)`);
59
+ // 4. Wire up the engine and scheduler.
60
+ // Track in-flight pipelines so graceful shutdown can drain before exit.
61
+ let shuttingDown = false;
62
+ let inFlight = 0;
63
+ let drainResolve = null;
64
+ function dispatchPipeline(automation, event) {
65
+ if (shuttingDown)
66
+ return;
67
+ inFlight++;
68
+ runPipeline(automation, event, haClient, { eventPublisher, actionRuntime, dryRun, metrics: metricsBackend })
69
+ .catch((err) => { console.error('[homerun] pipeline error:', err); })
70
+ .finally(() => {
71
+ inFlight--;
72
+ if (inFlight === 0 && drainResolve) {
73
+ drainResolve();
74
+ drainResolve = null;
75
+ }
76
+ });
77
+ }
78
+ engine = new TriggerEngine(registry, haClient, dispatchPipeline, mqtt, metricsBackend);
79
+ const scheduler = new Scheduler(registry.getAll(), (e) => engine.dispatch(e), haClient.ready);
80
+ engine.start();
81
+ scheduler.start();
82
+ // 5. Start hot-reload watcher (dev) and SIGUSR1 rescan (git-sync sidecar in k8s).
83
+ startHotReload(automationsDir, registry);
84
+ async function reload() {
85
+ await rescanAutomations(automationsDir, registry);
86
+ scheduler.sync(registry.getAll());
87
+ const count = registry.getAll().length;
88
+ metricsBackend.setGauge('homerun_automations_loaded', count);
89
+ console.log(`[homerun] rescan complete — ${count} automation(s) registered`);
90
+ eventPublisher.publishLifecycle('rescan_complete', count, dryRun);
91
+ }
92
+ process.on('SIGUSR1', () => {
93
+ console.log('[homerun] SIGUSR1 received — rescanning automations');
94
+ reload().catch((err) => {
95
+ console.error('[homerun] rescan failed:', err);
96
+ });
97
+ });
98
+ // 6. Start the HTTP API server.
99
+ let haReady = false;
100
+ const apiServer = new ApiServer({
101
+ registry,
102
+ onTrigger: dispatchPipeline,
103
+ onReload: reload,
104
+ isReady: () => haReady,
105
+ entityCount: () => haClient.entityCount,
106
+ eventPublisher,
107
+ dryRun,
108
+ metrics: config.metrics.enabled ? metricsBackend : undefined,
109
+ });
110
+ await apiServer.start(config.server.port);
111
+ // 7. Connect to HA last — state_changed events start flowing once ready resolves.
112
+ haClient.on('reconnected', () => {
113
+ console.log(`[homerun] reconnected — ${haClient.entityCount} entities refreshed`);
114
+ eventPublisher.publishLifecycle('ha_reconnected', registry.getAll().length, dryRun);
115
+ });
116
+ await haClient.connect(config.homeassistant.url, config.homeassistant.token);
117
+ await haClient.ready;
118
+ haReady = true;
119
+ const { labels, areas } = haClient.registryStats;
120
+ console.log(`[homerun] ready — ${haClient.entityCount} entities cached (${labels} labels, ${areas} areas)`);
121
+ eventPublisher.publishLifecycle('server_started', registry.getAll().length, dryRun);
122
+ // 8. Graceful SIGTERM shutdown.
123
+ process.on('SIGTERM', () => {
124
+ console.log('[homerun] SIGTERM received — starting graceful shutdown');
125
+ const { shutdown_timeout_ms: timeoutMs } = config.server;
126
+ shuttingDown = true;
127
+ const automationCount = registry.getAll().length;
128
+ eventPublisher.publishLifecycle('server_stopping', automationCount, dryRun);
129
+ const drain = () => {
130
+ if (inFlight === 0)
131
+ return Promise.resolve();
132
+ console.log(`[homerun] draining ${inFlight} in-flight pipeline(s)...`);
133
+ return new Promise((resolve) => {
134
+ drainResolve = resolve;
135
+ setTimeout(() => {
136
+ if (drainResolve) {
137
+ console.warn(`[homerun] shutdown: drain timed out with ${inFlight} pipeline(s) still running`);
138
+ drainResolve = null;
139
+ resolve();
140
+ }
141
+ }, timeoutMs);
142
+ });
143
+ };
144
+ Promise.resolve()
145
+ .then(() => apiServer.stop())
146
+ .then(() => { scheduler.stop(); timerManager.cancelAll(); })
147
+ .then(drain)
148
+ .then(() => { haClient.disconnect(); return mqtt.endAsync(); })
149
+ .then(() => {
150
+ console.log('[homerun] shutdown complete');
151
+ process.exit(0);
152
+ })
153
+ .catch((err) => {
154
+ console.error('[homerun] shutdown error:', err);
155
+ process.exit(1);
156
+ });
157
+ });
@@ -0,0 +1,6 @@
1
+ export { defineAutomation, abort, requireState, requireNumericState, UnavailableInputError } from './types/automation.js';
2
+ export type { Automation, Decision, Abort, HAState, HAContext } from './types/automation.js';
3
+ export type { Trigger, TriggerEvent } from './types/triggers.js';
4
+ export type { Action } from './types/actions.js';
5
+ export { HomeAssistant } from './services.js';
6
+ export type { LightTurnOnData, HvacMode, ClimateSetTemperatureData, MediaPlayerPlayMediaData, MediaPlayerRepeat } from './services.js';
@@ -0,0 +1,2 @@
1
+ export { defineAutomation, abort, requireState, requireNumericState, UnavailableInputError } from './types/automation.js';
2
+ export { HomeAssistant } from './services.js';