@ajclarkson/homerun 0.0.1-edge.c7ffb3a → 0.0.1-edge.ced2eaa

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.
@@ -9,6 +9,9 @@ export interface ExecutionContext {
9
9
  automationId: string;
10
10
  location: string;
11
11
  subsystem: string;
12
+ rootCorrelationId?: string;
13
+ parentCorrelationId?: string;
14
+ parentAutomationId?: string;
12
15
  }
13
16
  interface Deps {
14
17
  haClient: HAClient;
@@ -1,3 +1,11 @@
1
+ function safeStringify(err) {
2
+ try {
3
+ return JSON.stringify(err);
4
+ }
5
+ catch {
6
+ return String(err);
7
+ }
8
+ }
1
9
  export class ActionRuntime {
2
10
  deps;
3
11
  constructor(deps) {
@@ -15,7 +23,7 @@ export class ActionRuntime {
15
23
  const start = performance.now();
16
24
  try {
17
25
  if (!this.deps.dryRun) {
18
- await this.dispatch(action);
26
+ await this.dispatch(action, ctx);
19
27
  }
20
28
  const duration = (performance.now() - start) / 1000;
21
29
  this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
@@ -26,16 +34,31 @@ export class ActionRuntime {
26
34
  const duration = (performance.now() - start) / 1000;
27
35
  this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
28
36
  this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
29
- const reason = err instanceof Error ? err.message : String(err);
37
+ const reason = err instanceof Error
38
+ ? err.message
39
+ : typeof err === 'object' && err !== null
40
+ ? safeStringify(err)
41
+ : String(err);
30
42
  this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
31
43
  }
32
44
  }
33
- async dispatch(action) {
45
+ async dispatch(action, ctx) {
34
46
  switch (action.type) {
35
47
  case 'ha.call_service':
36
- await this.deps.haClient.callService(action.domain, action.service, action.target, action.data);
48
+ await this.deps.haClient.callService(action.domain, action.service, action.target, action.data, {
49
+ correlationId: ctx.correlationId,
50
+ rootCorrelationId: ctx.rootCorrelationId,
51
+ automationId: ctx.automationId,
52
+ });
37
53
  break;
38
54
  case 'mqtt.publish':
55
+ if (action.impliesEntity) {
56
+ this.deps.haClient.registerPendingWrite(action.impliesEntity, {
57
+ correlationId: ctx.correlationId,
58
+ rootCorrelationId: ctx.rootCorrelationId,
59
+ automationId: ctx.automationId,
60
+ });
61
+ }
39
62
  await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
40
63
  break;
41
64
  case 'timer.start':
@@ -54,6 +77,7 @@ export class ActionRuntime {
54
77
  return {
55
78
  schema: 'home.events.v1',
56
79
  correlation_id: ctx.correlationId,
80
+ root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
57
81
  automation_id: ctx.automationId,
58
82
  location: ctx.location,
59
83
  subsystem: ctx.subsystem,
@@ -61,6 +85,8 @@ export class ActionRuntime {
61
85
  actions: [action],
62
86
  timestamp: new Date().toISOString(),
63
87
  ...(this.deps.dryRun ? { dry_run: true } : {}),
88
+ ...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
89
+ ...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
64
90
  ...extra,
65
91
  };
66
92
  }
@@ -64,7 +64,8 @@ export class ApiServer {
64
64
  json(res, 404, { error: `no automation with id "${id}"` });
65
65
  return;
66
66
  }
67
- this.deps.onTrigger(automation, { type: 'on_start', correlation_id: crypto.randomUUID() });
67
+ const correlation_id = crypto.randomUUID();
68
+ this.deps.onTrigger(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
68
69
  json(res, 200, { ok: true });
69
70
  }
70
71
  postReload(res) {
@@ -3,6 +3,9 @@ import type { Action } from '../types/actions.js';
3
3
  export interface ObsEvent {
4
4
  schema: 'home.events.v1';
5
5
  correlation_id: string;
6
+ root_correlation_id?: string;
7
+ parent_correlation_id?: string;
8
+ parent_automation_id?: string;
6
9
  automation_id: string;
7
10
  location: string;
8
11
  subsystem: string;
@@ -23,6 +23,14 @@ export interface StateChangedEvent {
23
23
  old_state: EntityState | undefined;
24
24
  new_state: EntityState;
25
25
  correlation_id: string;
26
+ parent_correlation_id?: string;
27
+ root_correlation_id?: string;
28
+ parent_automation_id?: string;
29
+ }
30
+ export interface WriteOrigin {
31
+ correlationId: string;
32
+ rootCorrelationId?: string;
33
+ automationId: string;
26
34
  }
27
35
  export declare interface HAClient {
28
36
  on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
@@ -37,6 +45,7 @@ export declare class HAClient extends EventEmitter {
37
45
  private readonly labelToEntities;
38
46
  private readonly entityToLabels;
39
47
  private readonly areaToEntities;
48
+ private readonly pendingWrites;
40
49
  private connection;
41
50
  private reconnecting;
42
51
  private _readyResolve;
@@ -44,7 +53,12 @@ export declare class HAClient extends EventEmitter {
44
53
  readonly state: HAState;
45
54
  readonly context: HAContext;
46
55
  get entityCount(): number;
47
- callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>): Promise<void>;
56
+ get registryStats(): {
57
+ labels: number;
58
+ areas: number;
59
+ };
60
+ callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>, origin?: WriteOrigin): Promise<void>;
61
+ registerPendingWrite(entityId: string, origin: WriteOrigin): void;
48
62
  disconnect(): void;
49
63
  connect(url: string, token: string): Promise<void>;
50
64
  private repopulateCache;
@@ -1,10 +1,14 @@
1
1
  import { callService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
2
2
  import { EventEmitter } from 'node:events';
3
+ const PENDING_WRITE_TTL_MS = 2000;
3
4
  export class HAClient extends EventEmitter {
4
5
  stateCache = new Map();
5
6
  labelToEntities = new Map();
6
7
  entityToLabels = new Map();
7
8
  areaToEntities = new Map();
9
+ // entity_id -> origin of the write that's expected to produce a state_changed for it.
10
+ // Self-evicts after PENDING_WRITE_TTL_MS; consumed (deleted) the moment it's matched.
11
+ pendingWrites = new Map();
8
12
  connection = null;
9
13
  // True between a disconnect event and the next subscribeEntities callback.
10
14
  // During this window we repopulate silently — no state_changed events emitted.
@@ -23,11 +27,26 @@ export class HAClient extends EventEmitter {
23
27
  get entityCount() {
24
28
  return this.stateCache.size;
25
29
  }
26
- async callService(domain, service, target, data) {
30
+ get registryStats() {
31
+ return { labels: this.labelToEntities.size, areas: this.areaToEntities.size };
32
+ }
33
+ async callService(domain, service, target, data, origin) {
27
34
  if (!this.connection)
28
35
  throw new Error('HAClient not connected');
36
+ const entityId = target?.entity_id;
37
+ if (origin && entityId)
38
+ this.registerPendingWrite(entityId, origin);
29
39
  await callService(this.connection, domain, service, data, target);
30
40
  }
41
+ // Registers that `origin` is expected to produce a state_changed for `entityId` — consumed
42
+ // (and stamped as parent_correlation_id/parent_automation_id) by diffAndUpdate on match, or
43
+ // self-evicted after PENDING_WRITE_TTL_MS if nothing arrives. Used by ha.call_service writes
44
+ // (automatically, via target.entity_id) and by mqtt.publish writes that declare `impliesEntity`
45
+ // (explicitly, since a topic alone carries no entity information — see #28, #138).
46
+ registerPendingWrite(entityId, origin) {
47
+ this.pendingWrites.set(entityId, origin);
48
+ setTimeout(() => this.pendingWrites.delete(entityId), PENDING_WRITE_TTL_MS);
49
+ }
31
50
  disconnect() {
32
51
  this.connection?.close();
33
52
  this.connection = null;
@@ -88,7 +107,18 @@ export class HAClient extends EventEmitter {
88
107
  // last_updated changes whenever state or attributes change in HA.
89
108
  if (!old_state || old_state.last_updated !== new_state.last_updated) {
90
109
  this.stateCache.set(id, new_state);
91
- this.emit('state_changed', { entity_id: id, old_state, new_state, correlation_id: crypto.randomUUID() });
110
+ const correlation_id = crypto.randomUUID();
111
+ const origin = this.pendingWrites.get(id);
112
+ if (origin)
113
+ this.pendingWrites.delete(id);
114
+ this.emit('state_changed', {
115
+ entity_id: id,
116
+ old_state,
117
+ new_state,
118
+ correlation_id,
119
+ root_correlation_id: origin ? (origin.rootCorrelationId ?? origin.correlationId) : correlation_id,
120
+ ...(origin && { parent_correlation_id: origin.correlationId, parent_automation_id: origin.automationId }),
121
+ });
92
122
  }
93
123
  }
94
124
  // Prune entities that disappeared from the snapshot.
@@ -5,15 +5,19 @@ export async function runPipeline(automation, event, haClient, deps) {
5
5
  trigger_type: event.type,
6
6
  });
7
7
  const correlationId = event.correlation_id;
8
+ const rootCorrelationId = event.root_correlation_id ?? correlationId;
8
9
  const timestamp = new Date().toISOString();
9
10
  const base = {
10
11
  schema: 'home.events.v1',
11
12
  correlation_id: correlationId,
13
+ root_correlation_id: rootCorrelationId,
12
14
  automation_id: automation.id,
13
15
  location: automation.location,
14
16
  subsystem: automation.subsystem,
15
17
  timestamp,
16
18
  ...(deps.dryRun ? { dry_run: true } : {}),
19
+ ...(event.parent_correlation_id && { parent_correlation_id: event.parent_correlation_id }),
20
+ ...(event.parent_automation_id && { parent_automation_id: event.parent_automation_id }),
17
21
  };
18
22
  // Step 1: Enabled check
19
23
  if (automation.enabled === false) {
@@ -60,6 +64,9 @@ export async function runPipeline(automation, event, haClient, deps) {
60
64
  automationId: automation.id,
61
65
  location: automation.location,
62
66
  subsystem: automation.subsystem,
67
+ rootCorrelationId,
68
+ ...(event.parent_correlation_id && { parentCorrelationId: event.parent_correlation_id }),
69
+ ...(event.parent_automation_id && { parentAutomationId: event.parent_automation_id }),
63
70
  }),
64
71
  ]);
65
72
  }
@@ -10,7 +10,10 @@ export class AutomationRegistry {
10
10
  cb();
11
11
  }
12
12
  unregister(id) {
13
- this.automations.delete(id);
13
+ if (!this.automations.delete(id))
14
+ return;
15
+ for (const cb of this.changeCallbacks)
16
+ cb();
14
17
  }
15
18
  getAll() {
16
19
  return Array.from(this.automations.values());
@@ -12,7 +12,8 @@ export class Scheduler {
12
12
  start() {
13
13
  this.registerCronTriggers(this.automations);
14
14
  this.ready.then(() => {
15
- this.dispatch({ type: 'on_start', correlation_id: crypto.randomUUID() });
15
+ const correlation_id = crypto.randomUUID();
16
+ this.dispatch({ type: 'on_start', correlation_id, root_correlation_id: correlation_id });
16
17
  }).catch((err) => {
17
18
  console.error('[scheduler] ready promise rejected:', err);
18
19
  });
@@ -27,22 +28,28 @@ export class Scheduler {
27
28
  this.cleanups.length = 0;
28
29
  }
29
30
  registerCronTriggers(automations) {
31
+ const automationIdsByExpression = new Map();
30
32
  for (const automation of automations) {
31
33
  for (const trigger of automation.triggers) {
32
34
  if (trigger.type === 'schedule') {
33
- const { cron: expression } = trigger;
34
- try {
35
- const task = cron.schedule(expression, () => {
36
- this.dispatch({ type: 'schedule', cron: expression, correlation_id: crypto.randomUUID() });
37
- });
38
- this.cleanups.push(() => task.stop());
39
- console.log(`[scheduler] registered cron "${expression}" for ${automation.id}`);
40
- }
41
- catch (err) {
42
- console.error(`[scheduler] failed to register cron "${expression}" for ${automation.id}:`, err);
43
- }
35
+ const ids = automationIdsByExpression.get(trigger.cron) ?? [];
36
+ ids.push(automation.id);
37
+ automationIdsByExpression.set(trigger.cron, ids);
44
38
  }
45
39
  }
46
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
+ }
47
54
  }
48
55
  }
@@ -8,7 +8,8 @@ export class TimerManager {
8
8
  this.cancel(timerKey);
9
9
  const handle = setTimeout(() => {
10
10
  this.timers.delete(timerKey);
11
- this.dispatch({ type: 'timer_expired', timerKey, correlation_id: crypto.randomUUID() });
11
+ const correlation_id = crypto.randomUUID();
12
+ this.dispatch({ type: 'timer_expired', timerKey, correlation_id, root_correlation_id: correlation_id });
12
13
  }, delayMs);
13
14
  this.timers.set(timerKey, handle);
14
15
  }
@@ -17,7 +17,10 @@ export declare class TriggerEngine {
17
17
  private readonly buttonHandlers;
18
18
  private regexButtonTriggers;
19
19
  private readonly durationTimers;
20
+ private readonly subscribedTopics;
21
+ private started;
20
22
  constructor(registry: AutomationRegistry, haClient: HAClient, onMatch: (automation: Automation<unknown>, event: TriggerEvent) => void, mqttClient?: MqttClient | undefined, metrics?: MetricsBackend | undefined);
23
+ private syncMqttSubscriptions;
21
24
  private rebuildButtonHandlers;
22
25
  start(): void;
23
26
  dispatch(event: TriggerEvent): void;
@@ -14,7 +14,7 @@ class ButtonGestureHandler {
14
14
  this.dispatch = dispatch;
15
15
  this.supportsDoublePress = supportsDoublePress;
16
16
  }
17
- handle(actionState, correlationId) {
17
+ handle(actionState, corr) {
18
18
  if (actionState === '')
19
19
  return;
20
20
  const parsed = parseButtonAction(actionState);
@@ -31,7 +31,7 @@ class ButtonGestureHandler {
31
31
  }
32
32
  if (!this.holdFired) {
33
33
  this.holdFired = true;
34
- this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, correlation_id: correlationId });
34
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'hold', button, ...corr });
35
35
  }
36
36
  return;
37
37
  }
@@ -50,14 +50,14 @@ class ButtonGestureHandler {
50
50
  this.shortFired = true;
51
51
  if (this.gestureState === 'idle') {
52
52
  if (!this.supportsDoublePress) {
53
- this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
53
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, ...corr });
54
54
  }
55
55
  else {
56
56
  this.gestureState = 'resolving';
57
57
  this.resolveTimer = setTimeout(() => {
58
58
  this.resolveTimer = null;
59
59
  this.gestureState = 'idle';
60
- this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, correlation_id: correlationId });
60
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'single_press', button, ...corr });
61
61
  }, DOUBLE_PRESS_WINDOW_MS);
62
62
  }
63
63
  }
@@ -66,7 +66,7 @@ class ButtonGestureHandler {
66
66
  clearTimeout(this.resolveTimer);
67
67
  this.resolveTimer = null;
68
68
  this.gestureState = 'idle';
69
- this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, correlation_id: correlationId });
69
+ this.dispatch({ type: 'button', entity_id: this.entityId, gesture: 'double_press', button, ...corr });
70
70
  }
71
71
  }
72
72
  }
@@ -95,6 +95,8 @@ export class TriggerEngine {
95
95
  buttonHandlers = new Map();
96
96
  regexButtonTriggers = [];
97
97
  durationTimers = new Map();
98
+ subscribedTopics = new Set();
99
+ started = false;
98
100
  constructor(registry, haClient, onMatch, mqttClient, metrics) {
99
101
  this.registry = registry;
100
102
  this.haClient = haClient;
@@ -102,7 +104,38 @@ export class TriggerEngine {
102
104
  this.mqttClient = mqttClient;
103
105
  this.metrics = metrics;
104
106
  this.rebuildButtonHandlers();
105
- registry.onChange(() => 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
+ }
106
139
  }
107
140
  rebuildButtonHandlers() {
108
141
  this.buttonHandlers.clear();
@@ -137,6 +170,12 @@ export class TriggerEngine {
137
170
  .then(() => {
138
171
  this.haClient.on('state_changed', (event) => {
139
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
+ };
140
179
  let handler = this.buttonHandlers.get(event.entity_id);
141
180
  if (!handler) {
142
181
  const matchingGestures = new Set();
@@ -152,7 +191,7 @@ export class TriggerEngine {
152
191
  }
153
192
  }
154
193
  if (handler) {
155
- handler.handle(event.new_state.state, event.correlation_id);
194
+ handler.handle(event.new_state.state, corr);
156
195
  }
157
196
  else {
158
197
  this.dispatch({
@@ -160,7 +199,7 @@ export class TriggerEngine {
160
199
  entity_id: event.entity_id,
161
200
  old_state: event.old_state,
162
201
  new_state: event.new_state,
163
- correlation_id: event.correlation_id,
202
+ ...corr,
164
203
  });
165
204
  }
166
205
  });
@@ -169,16 +208,8 @@ export class TriggerEngine {
169
208
  console.error('[trigger-engine] failed to start:', err);
170
209
  });
171
210
  if (this.mqttClient) {
172
- const topics = new Set();
173
- for (const automation of this.registry.getAll()) {
174
- for (const trigger of automation.triggers) {
175
- if (trigger.type === 'mqtt_in')
176
- topics.add(trigger.topic);
177
- }
178
- }
179
- for (const topic of topics) {
180
- this.mqttClient.subscribe(topic);
181
- }
211
+ this.started = true;
212
+ this.syncMqttSubscriptions();
182
213
  this.mqttClient.subscribe('homerun/trigger/+');
183
214
  this.mqttClient.on('message', (topic, payload) => {
184
215
  if (topic.startsWith('homerun/trigger/')) {
@@ -188,14 +219,17 @@ export class TriggerEngine {
188
219
  console.warn(`[trigger-engine] manual trigger: no automation with id "${automationId}"`);
189
220
  return;
190
221
  }
191
- this.onMatch(automation, { type: 'on_start', correlation_id: crypto.randomUUID() });
222
+ const correlation_id = crypto.randomUUID();
223
+ this.onMatch(automation, { type: 'on_start', correlation_id, root_correlation_id: correlation_id });
192
224
  return;
193
225
  }
226
+ const correlation_id = `mqtt-${Date.now()}`;
194
227
  this.dispatch({
195
228
  type: 'mqtt_in',
196
229
  topic,
197
230
  payload: payload.toString(),
198
- correlation_id: `mqtt-${Date.now()}`,
231
+ correlation_id,
232
+ root_correlation_id: correlation_id,
199
233
  });
200
234
  });
201
235
  }
package/dist/src/index.js CHANGED
@@ -115,7 +115,8 @@ haClient.on('reconnected', () => {
115
115
  await haClient.connect(config.homeassistant.url, config.homeassistant.token);
116
116
  await haClient.ready;
117
117
  haReady = true;
118
- console.log(`[homerun] ready — ${haClient.entityCount} entities cached`);
118
+ const { labels, areas } = haClient.registryStats;
119
+ console.log(`[homerun] ready — ${haClient.entityCount} entities cached (${labels} labels, ${areas} areas)`);
119
120
  eventPublisher.publishLifecycle('server_started', registry.getAll().length, dryRun);
120
121
  // 8. Graceful SIGTERM shutdown.
121
122
  process.on('SIGTERM', () => {
@@ -11,6 +11,7 @@ export type Action = {
11
11
  topic: string;
12
12
  payload: string;
13
13
  retain?: boolean;
14
+ impliesEntity?: string;
14
15
  } | {
15
16
  type: 'timer.start';
16
17
  timerKey: string;
@@ -25,6 +25,8 @@ export type Trigger = {
25
25
  type TriggerEventBase = {
26
26
  correlation_id: string;
27
27
  parent_correlation_id?: string;
28
+ root_correlation_id?: string;
29
+ parent_automation_id?: string;
28
30
  };
29
31
  export type TriggerEvent = TriggerEventBase & ({
30
32
  type: 'state_changed';
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.c7ffb3a",
4
+ "version": "0.0.1-edge.ced2eaa",
5
5
  "description": "TypeScript automation framework for Home Assistant",
6
6
  "files": [
7
7
  "dist"