@ajclarkson/homerun 0.0.1-edge.3ff83fe → 0.0.1-edge.465375

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.
@@ -20,13 +20,20 @@ interface Deps {
20
20
  eventPublisher: EventPublisher;
21
21
  dryRun: boolean;
22
22
  metrics?: MetricsBackend;
23
+ commandAck?: {
24
+ enabled: boolean;
25
+ timeoutMs: number;
26
+ };
23
27
  }
24
28
  export declare class ActionRuntime {
25
29
  private readonly deps;
26
30
  constructor(deps: Deps);
31
+ private handleAckTimeout;
27
32
  execute(actions: Action[], ctx: ExecutionContext): Promise<void>;
28
33
  private runAction;
29
34
  private dispatch;
30
- private makeEvent;
35
+ private baseFields;
36
+ private makeStartedEvent;
37
+ private makeResultEvent;
31
38
  }
32
39
  export {};
@@ -6,10 +6,85 @@ function safeStringify(err) {
6
6
  return String(err);
7
7
  }
8
8
  }
9
+ // HA `data` keys are conventionally the same name as the attribute they end up setting
10
+ // (temperature, brightness, position, ...) — true across core and third-party integrations
11
+ // as a platform-wide naming convention, not something homerun needs to hand-map per service.
12
+ // These are the known exceptions: call parameters that don't name a resulting attribute.
13
+ const NON_ATTRIBUTE_DATA_KEYS = new Set(['transition', 'entity_id']);
14
+ // `set_value` on these domains sets the entity's top-level `state` directly — unlike most
15
+ // services, where a `data` key names the attribute it ends up on, these have no attribute
16
+ // called `value` at all. Found the hard way (#153): computeExpectedAck's generic convention
17
+ // falsely flagged every number/input_text room-sensor feed call as an ack timeout, since
18
+ // `attributes.value` never existed to satisfy.
19
+ const SET_VALUE_TO_STATE_DOMAINS = new Set(['number', 'input_number', 'input_text', 'input_select', 'text']);
20
+ // Domains/services excluded from tracking entirely rather than guessing at a convention that
21
+ // doesn't hold cleanly — group membership attributes don't reliably come back as an exact
22
+ // match of what was passed (ordering, or reflecting the whole group rather than the delta).
23
+ // scene.turn_on is here for a different reason (#156): a scene entity's `state` is a
24
+ // last-activated timestamp, not 'on'/'off', so the generic turn_on special-case never
25
+ // matches — and even a timestamp-based check would only confirm HA accepted the call, not
26
+ // that the scene's member entities (the things that actually matter) responded. Real
27
+ // coverage would mean tracking each member entity individually — see #155.
28
+ const UNTRACKED_SERVICES = new Set(['media_player.join', 'media_player.unjoin', 'scene.turn_on']);
29
+ // Computes the field/attribute -> value map a dispatched call is expected to produce, or
30
+ // undefined if there's nothing to track (no inferable fields, an excluded service, or the
31
+ // entity already matches every expected field — an idempotent call that legitimately produces
32
+ // no state_changed at all). See #55's design discussion for why this is convention-based
33
+ // rather than a per-service table.
34
+ function computeExpectedAck(action, current) {
35
+ if (UNTRACKED_SERVICES.has(`${action.domain}.${action.service}`))
36
+ return undefined;
37
+ const expected = {};
38
+ if (action.service === 'turn_on') {
39
+ expected.state = 'on';
40
+ }
41
+ else if (action.service === 'turn_off') {
42
+ expected.state = 'off';
43
+ }
44
+ else if (action.service === 'set_value' && SET_VALUE_TO_STATE_DOMAINS.has(action.domain) && action.data?.value !== undefined) {
45
+ expected.state = action.data.value;
46
+ }
47
+ for (const [key, value] of Object.entries(action.data ?? {})) {
48
+ if (NON_ATTRIBUTE_DATA_KEYS.has(key))
49
+ continue;
50
+ if ('state' in expected && key === 'value' && SET_VALUE_TO_STATE_DOMAINS.has(action.domain))
51
+ continue;
52
+ expected[key] = value;
53
+ }
54
+ if (Object.keys(expected).length === 0)
55
+ return undefined;
56
+ // See ha-client.ts's ackSatisfied for why `state` needs string coercion (#158) — the same
57
+ // number-vs-string gap applies here to the idempotency pre-check.
58
+ const alreadySatisfied = Object.entries(expected).every(([key, value]) => key === 'state' ? current !== undefined && String(current.state) === String(value) : current?.attributes[key] === value);
59
+ if (alreadySatisfied)
60
+ return undefined;
61
+ return expected;
62
+ }
9
63
  export class ActionRuntime {
10
64
  deps;
11
65
  constructor(deps) {
12
66
  this.deps = deps;
67
+ this.deps.haClient.on('action_ack_timeout', (e) => this.handleAckTimeout(e));
68
+ }
69
+ handleAckTimeout(e) {
70
+ this.deps.metrics?.incrementCounter('homerun_action_ack_timeout_total', {
71
+ location: e.location,
72
+ action_type: e.action.type,
73
+ });
74
+ this.deps.eventPublisher.publishActionEvent({
75
+ schema: 'home.events.v2',
76
+ correlation_id: e.correlationId,
77
+ root_correlation_id: e.rootCorrelationId ?? e.correlationId,
78
+ automation_id: e.automationId,
79
+ location: e.location,
80
+ subsystem: e.subsystem,
81
+ timestamp: new Date().toISOString(),
82
+ ...(this.deps.dryRun ? { dry_run: true } : {}),
83
+ event_type: 'action_ack_timeout',
84
+ action: e.action,
85
+ entity: e.entity_id,
86
+ expected: e.expected,
87
+ });
13
88
  }
14
89
  async execute(actions, ctx) {
15
90
  for (const action of actions) {
@@ -18,7 +93,7 @@ export class ActionRuntime {
18
93
  }
19
94
  async runAction(action, ctx) {
20
95
  const labels = { location: ctx.location, action_type: action.type };
21
- this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_started', action));
96
+ this.deps.eventPublisher.publishActionEvent(this.makeStartedEvent(ctx, action));
22
97
  this.deps.metrics?.incrementCounter('homerun_actions_dispatched_total', labels);
23
98
  const start = performance.now();
24
99
  try {
@@ -28,29 +103,45 @@ export class ActionRuntime {
28
103
  const duration = (performance.now() - start) / 1000;
29
104
  this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
30
105
  this.deps.metrics?.incrementCounter('homerun_actions_succeeded_total', labels);
31
- this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason: 'ok' }));
106
+ this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'ok'));
32
107
  }
33
108
  catch (err) {
34
109
  const duration = (performance.now() - start) / 1000;
35
110
  this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
36
111
  this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
37
- const reason = err instanceof Error
112
+ const error = err instanceof Error
38
113
  ? err.message
39
114
  : typeof err === 'object' && err !== null
40
115
  ? safeStringify(err)
41
116
  : String(err);
42
- this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
117
+ this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'error', error));
43
118
  }
44
119
  }
45
120
  async dispatch(action, ctx) {
46
121
  switch (action.type) {
47
- case 'ha.call_service':
122
+ case 'ha.call_service': {
123
+ const entityId = action.target?.entity_id;
124
+ if (this.deps.commandAck?.enabled && entityId) {
125
+ const expected = computeExpectedAck(action, this.deps.haClient.state(entityId));
126
+ if (expected) {
127
+ this.deps.haClient.registerPendingAck(entityId, {
128
+ correlationId: ctx.correlationId,
129
+ rootCorrelationId: ctx.rootCorrelationId,
130
+ automationId: ctx.automationId,
131
+ location: ctx.location,
132
+ subsystem: ctx.subsystem,
133
+ action,
134
+ expected,
135
+ }, this.deps.commandAck.timeoutMs);
136
+ }
137
+ }
48
138
  await this.deps.haClient.callService(action.domain, action.service, action.target, action.data, {
49
139
  correlationId: ctx.correlationId,
50
140
  rootCorrelationId: ctx.rootCorrelationId,
51
141
  automationId: ctx.automationId,
52
142
  });
53
143
  break;
144
+ }
54
145
  case 'mqtt.publish':
55
146
  if (action.impliesEntity) {
56
147
  this.deps.haClient.registerPendingWrite(action.impliesEntity, {
@@ -73,21 +164,30 @@ export class ActionRuntime {
73
164
  }
74
165
  }
75
166
  }
76
- makeEvent(ctx, event_type, action, extra = {}) {
167
+ baseFields(ctx) {
77
168
  return {
78
- schema: 'home.events.v1',
169
+ schema: 'home.events.v2',
79
170
  correlation_id: ctx.correlationId,
80
171
  root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
81
172
  automation_id: ctx.automationId,
82
173
  location: ctx.location,
83
174
  subsystem: ctx.subsystem,
84
- event_type,
85
- actions: [action],
86
175
  timestamp: new Date().toISOString(),
87
176
  ...(this.deps.dryRun ? { dry_run: true } : {}),
88
177
  ...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
89
178
  ...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
90
- ...extra,
179
+ };
180
+ }
181
+ makeStartedEvent(ctx, action) {
182
+ return { ...this.baseFields(ctx), event_type: 'action_started', action };
183
+ }
184
+ makeResultEvent(ctx, action, status, error) {
185
+ return {
186
+ ...this.baseFields(ctx),
187
+ event_type: 'action_result',
188
+ action,
189
+ status,
190
+ ...(error !== undefined && { error }),
91
191
  };
92
192
  }
93
193
  }
@@ -20,6 +20,13 @@ declare const ConfigSchema: z.ZodObject<{
20
20
  metrics: z.ZodDefault<z.ZodObject<{
21
21
  enabled: z.ZodDefault<z.ZodBoolean>;
22
22
  }, z.core.$strip>>;
23
+ events: z.ZodDefault<z.ZodObject<{
24
+ enabled: z.ZodDefault<z.ZodBoolean>;
25
+ }, z.core.$strip>>;
26
+ commandAck: z.ZodDefault<z.ZodObject<{
27
+ enabled: z.ZodDefault<z.ZodBoolean>;
28
+ timeoutMs: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
29
+ }, z.core.$strip>>;
23
30
  }, z.core.$strip>;
24
31
  export type HomerunConfig = z.infer<typeof ConfigSchema>;
25
32
  export declare function parseConfig(configContent: string, secretsContent?: string): HomerunConfig;
@@ -9,6 +9,8 @@ function isSecretRef(v) {
9
9
  const secretTag = defineScalarTag('!secret', {
10
10
  implicit: false,
11
11
  resolve: (source) => ({ __secret: source }),
12
+ // Load-only tag — config is never dumped back to YAML, so identify() is unreachable.
13
+ identify: () => false,
12
14
  });
13
15
  const SCHEMA_WITH_SECRET = CORE_SCHEMA.withTags(secretTag);
14
16
  const ConfigSchema = z.object({
@@ -32,6 +34,17 @@ const ConfigSchema = z.object({
32
34
  metrics: z.object({
33
35
  enabled: z.boolean().default(false),
34
36
  }).default({ enabled: false }),
37
+ events: z.object({
38
+ // Defaults to true — preserves existing behaviour for deployments that don't set this.
39
+ enabled: z.boolean().default(true),
40
+ }).default({ enabled: true }),
41
+ commandAck: z.object({
42
+ // Opt-in: unlike events.enabled, this is new/experimental behaviour with no prior
43
+ // installed base to preserve, so it defaults off rather than needing a reason to disable.
44
+ enabled: z.boolean().default(false),
45
+ // Needs to accommodate slow Zigbee mesh round-trips, not just fast local calls.
46
+ timeoutMs: z.coerce.number().int().min(0).default(8_000),
47
+ }).default({ enabled: false, timeoutMs: 8_000 }),
35
48
  });
36
49
  function resolveSecrets(value, secrets) {
37
50
  if (isSecretRef(value)) {
@@ -1,22 +1,46 @@
1
1
  import type { MqttClient } from 'mqtt';
2
2
  import type { Action } from '../types/actions.js';
3
- export interface ObsEvent {
4
- schema: 'home.events.v1';
3
+ import type { TriggerSummary } from '../types/triggers.js';
4
+ type ObsEventBase = {
5
+ schema: 'home.events.v2';
5
6
  correlation_id: string;
6
- root_correlation_id?: string;
7
+ root_correlation_id: string;
7
8
  parent_correlation_id?: string;
8
9
  parent_automation_id?: string;
9
10
  automation_id: string;
10
11
  location: string;
11
12
  subsystem: string;
12
- event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
13
- decision?: string;
14
- reason?: string;
15
- inputs?: Record<string, unknown>;
16
- actions?: Action[];
17
- dry_run?: boolean;
18
13
  timestamp: string;
19
- }
14
+ dry_run?: boolean;
15
+ };
16
+ export type ObsEvent = ObsEventBase & ({
17
+ event_type: 'decision';
18
+ trigger: TriggerSummary;
19
+ decision: string;
20
+ reason?: string;
21
+ conditions?: Record<string, unknown>;
22
+ actions: Action[];
23
+ hasAction: boolean;
24
+ } | {
25
+ event_type: 'abort';
26
+ trigger: TriggerSummary;
27
+ abort_kind: 'disabled' | 'unhandled_error' | 'guard' | 'unavailable_input';
28
+ reason?: string;
29
+ entity?: string;
30
+ } | {
31
+ event_type: 'action_started';
32
+ action: Action;
33
+ } | {
34
+ event_type: 'action_result';
35
+ action: Action;
36
+ status: 'ok' | 'error';
37
+ error?: string;
38
+ } | {
39
+ event_type: 'action_ack_timeout';
40
+ action: Action;
41
+ entity: string;
42
+ expected: Record<string, unknown>;
43
+ });
20
44
  export type LifecycleEventType = 'server_started' | 'server_stopping' | 'rescan_complete' | 'ha_reconnected';
21
45
  export interface LifecycleEvent {
22
46
  schema: 'home.lifecycle.v1';
@@ -27,11 +51,15 @@ export interface LifecycleEvent {
27
51
  }
28
52
  export declare class EventPublisher {
29
53
  private readonly mqtt;
54
+ private readonly enabled;
30
55
  private readonly listeners;
31
- constructor(mqtt: MqttClient);
56
+ constructor(mqtt: MqttClient, enabled?: boolean);
32
57
  subscribe(listener: (event: ObsEvent) => void): () => void;
33
58
  publishDecision(event: ObsEvent): void;
34
59
  publishActionEvent(event: ObsEvent): void;
35
60
  publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
36
61
  private publish;
62
+ private safeSerialize;
63
+ private notifyListeners;
37
64
  }
65
+ export {};
@@ -1,8 +1,14 @@
1
1
  export class EventPublisher {
2
2
  mqtt;
3
+ enabled;
3
4
  listeners = [];
4
- constructor(mqtt) {
5
+ // `enabled` gates MQTT publishing of decision/action ObsEvents only (mirrors metrics.enabled;
6
+ // see #144) — publishLifecycle (status/online heartbeat) is unaffected, and listeners (the
7
+ // /events SSE endpoint, any future persistent store) are always notified regardless, so
8
+ // consumers that don't want the MQTT stream aren't forced to lose in-process observability too.
9
+ constructor(mqtt, enabled = true) {
5
10
  this.mqtt = mqtt;
11
+ this.enabled = enabled;
6
12
  }
7
13
  subscribe(listener) {
8
14
  this.listeners.push(listener);
@@ -13,18 +19,25 @@ export class EventPublisher {
13
19
  };
14
20
  }
15
21
  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
+ if (this.enabled) {
23
+ const payload = this.safeSerialize(event);
24
+ if (payload !== undefined) {
25
+ const ns = event.dry_run ? 'homerun/dev' : 'homerun';
26
+ this.publish(`${ns}/events`, payload, false);
27
+ this.publish(`${ns}/${event.location}/${event.subsystem}/decision`, payload, true);
28
+ }
29
+ }
30
+ this.notifyListeners(event);
22
31
  }
23
32
  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);
33
+ if (this.enabled) {
34
+ const payload = this.safeSerialize(event);
35
+ if (payload !== undefined) {
36
+ const ns = event.dry_run ? 'homerun/dev' : 'homerun';
37
+ this.publish(`${ns}/events`, payload, false);
38
+ }
39
+ }
40
+ this.notifyListeners(event);
28
41
  }
29
42
  publishLifecycle(type, automationCount, dryRun = false) {
30
43
  const event = {
@@ -44,4 +57,29 @@ export class EventPublisher {
44
57
  console.error(`[EventPublisher] MQTT publish failed on ${topic}:`, err);
45
58
  });
46
59
  }
60
+ // Observability must never affect automation behaviour — a decision/action event that fails
61
+ // to serialize (e.g. conditions defaulting to a context object with a circular reference) must
62
+ // not throw, since runPipeline awaits this alongside actionRuntime.execute() in the same
63
+ // Promise.all; a synchronous throw here would silently prevent real actions from ever running.
64
+ safeSerialize(event) {
65
+ try {
66
+ return JSON.stringify(event);
67
+ }
68
+ catch (err) {
69
+ console.error(`[EventPublisher] failed to serialize ${event.event_type} event for ${event.automation_id} — MQTT publish skipped:`, err);
70
+ return undefined;
71
+ }
72
+ }
73
+ // Isolates listeners from each other and from the publisher — the same reliability principle
74
+ // applied to subscribers (e.g. the /events SSE endpoint) as to serialization above.
75
+ notifyListeners(event) {
76
+ for (const l of this.listeners) {
77
+ try {
78
+ l(event);
79
+ }
80
+ catch (err) {
81
+ console.error('[EventPublisher] listener threw, continuing:', err);
82
+ }
83
+ }
84
+ }
47
85
  }
@@ -1,5 +1,6 @@
1
1
  import { type HassServiceTarget } from 'home-assistant-js-websocket';
2
2
  import { EventEmitter } from 'node:events';
3
+ import type { Action } from '../types/actions.js';
3
4
  export interface EntityState {
4
5
  entity_id: string;
5
6
  state: string;
@@ -32,13 +33,27 @@ export interface WriteOrigin {
32
33
  rootCorrelationId?: string;
33
34
  automationId: string;
34
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
+ }
35
48
  export declare interface HAClient {
36
49
  on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
37
50
  on(event: 'ready', listener: () => void): this;
38
51
  on(event: 'reconnected', listener: () => void): this;
52
+ on(event: 'action_ack_timeout', listener: (e: AckTimeoutEvent) => void): this;
39
53
  emit(event: 'state_changed', e: StateChangedEvent): boolean;
40
54
  emit(event: 'ready'): boolean;
41
55
  emit(event: 'reconnected'): boolean;
56
+ emit(event: 'action_ack_timeout', e: AckTimeoutEvent): boolean;
42
57
  }
43
58
  export declare class HAClient extends EventEmitter {
44
59
  private readonly stateCache;
@@ -46,6 +61,7 @@ export declare class HAClient extends EventEmitter {
46
61
  private readonly entityToLabels;
47
62
  private readonly areaToEntities;
48
63
  private readonly pendingWrites;
64
+ private readonly pendingAcks;
49
65
  private connection;
50
66
  private reconnecting;
51
67
  private _readyResolve;
@@ -59,6 +75,7 @@ export declare class HAClient extends EventEmitter {
59
75
  };
60
76
  callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>, origin?: WriteOrigin): Promise<void>;
61
77
  registerPendingWrite(entityId: string, origin: WriteOrigin): void;
78
+ registerPendingAck(entityId: string, origin: AckOrigin, timeoutMs: number): void;
62
79
  disconnect(): void;
63
80
  connect(url: string, token: string): Promise<void>;
64
81
  private repopulateCache;
@@ -1,6 +1,12 @@
1
1
  import { callService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
2
2
  import { EventEmitter } from 'node:events';
3
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
+ }
4
10
  export class HAClient extends EventEmitter {
5
11
  stateCache = new Map();
6
12
  labelToEntities = new Map();
@@ -9,6 +15,10 @@ export class HAClient extends EventEmitter {
9
15
  // entity_id -> origin of the write that's expected to produce a state_changed for it.
10
16
  // Self-evicts after PENDING_WRITE_TTL_MS; consumed (deleted) the moment it's matched.
11
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();
12
22
  connection = null;
13
23
  // True between a disconnect event and the next subscribeEntities callback.
14
24
  // During this window we repopulate silently — no state_changed events emitted.
@@ -47,6 +57,18 @@ export class HAClient extends EventEmitter {
47
57
  this.pendingWrites.set(entityId, origin);
48
58
  setTimeout(() => this.pendingWrites.delete(entityId), PENDING_WRITE_TTL_MS);
49
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
+ }
50
72
  disconnect() {
51
73
  this.connection?.close();
52
74
  this.connection = null;
@@ -104,9 +126,17 @@ export class HAClient extends EventEmitter {
104
126
  for (const [id, rawEntity] of Object.entries(entities)) {
105
127
  const old_state = this.stateCache.get(id);
106
128
  const new_state = toEntityState(id, rawEntity);
107
- // last_updated changes whenever state or attributes change in HA.
108
- if (!old_state || old_state.last_updated !== new_state.last_updated) {
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) {
109
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
+ }
110
140
  const correlation_id = crypto.randomUUID();
111
141
  const origin = this.pendingWrites.get(id);
112
142
  if (origin)
@@ -1,4 +1,5 @@
1
- import { isAbort } from '../types/automation.js';
1
+ import { summarizeTrigger } from '../types/triggers.js';
2
+ import { isAbort, UnavailableInputError } from '../types/automation.js';
2
3
  export async function runPipeline(automation, event, haClient, deps) {
3
4
  deps.metrics?.incrementCounter('homerun_pipeline_runs_total', {
4
5
  location: automation.location,
@@ -7,8 +8,9 @@ export async function runPipeline(automation, event, haClient, deps) {
7
8
  const correlationId = event.correlation_id;
8
9
  const rootCorrelationId = event.root_correlation_id ?? correlationId;
9
10
  const timestamp = new Date().toISOString();
11
+ const trigger = summarizeTrigger(event);
10
12
  const base = {
11
- schema: 'home.events.v1',
13
+ schema: 'home.events.v2',
12
14
  correlation_id: correlationId,
13
15
  root_correlation_id: rootCorrelationId,
14
16
  automation_id: automation.id,
@@ -21,7 +23,7 @@ export async function runPipeline(automation, event, haClient, deps) {
21
23
  };
22
24
  // Step 1: Enabled check
23
25
  if (automation.enabled === false) {
24
- deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: 'disabled' });
26
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'disabled', trigger });
25
27
  return;
26
28
  }
27
29
  // Step 2: Context
@@ -29,12 +31,17 @@ export async function runPipeline(automation, event, haClient, deps) {
29
31
  try {
30
32
  ctx = automation.context(haClient.state, haClient.context, event);
31
33
  }
32
- catch {
33
- deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
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
+ }
34
41
  return;
35
42
  }
36
43
  if (isAbort(ctx)) {
37
- deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: ctx.reason });
44
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'guard', reason: ctx.reason, trigger });
38
45
  return;
39
46
  }
40
47
  // Step 3: Reduce
@@ -43,18 +50,24 @@ export async function runPipeline(automation, event, haClient, deps) {
43
50
  result = automation.reduce(ctx);
44
51
  }
45
52
  catch {
46
- deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', reason: 'unhandled_error' });
53
+ deps.eventPublisher.publishDecision({ ...base, event_type: 'abort', abort_kind: 'unhandled_error', trigger });
47
54
  return;
48
55
  }
49
56
  // Step 4: Validate — safe defaults
50
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;
51
62
  const decision = {
52
63
  ...base,
53
64
  event_type: 'decision',
65
+ trigger,
54
66
  decision: result.decision,
55
67
  reason: result.reason,
56
- inputs: result.inputs,
68
+ conditions,
57
69
  actions,
70
+ hasAction: actions.length > 0,
58
71
  };
59
72
  // Step 5: Fanout
60
73
  await Promise.all([
package/dist/src/index.js CHANGED
@@ -38,7 +38,7 @@ await new Promise((resolve, reject) => {
38
38
  // `engine` is assigned before any timer can fire.
39
39
  const haClient = new HAClient();
40
40
  const registry = new AutomationRegistry();
41
- const eventPublisher = new EventPublisher(mqtt);
41
+ const eventPublisher = new EventPublisher(mqtt, config.events.enabled);
42
42
  let engine;
43
43
  const timerManager = new TimerManager((e) => engine.dispatch(e));
44
44
  const actionRuntime = new ActionRuntime({
@@ -48,6 +48,7 @@ const actionRuntime = new ActionRuntime({
48
48
  eventPublisher,
49
49
  dryRun,
50
50
  metrics: metricsBackend,
51
+ commandAck: config.commandAck,
51
52
  });
52
53
  // 3. Initial automation load — must complete before the engine and scheduler start.
53
54
  const automationsDir = path.resolve(config.automations.dir);
package/dist/src/lib.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { defineAutomation, abort } from './types/automation.js';
1
+ export { defineAutomation, abort, requireState, requireNumericState, UnavailableInputError } from './types/automation.js';
2
2
  export type { Automation, Decision, Abort, HAState, HAContext } from './types/automation.js';
3
3
  export type { Trigger, TriggerEvent } from './types/triggers.js';
4
4
  export type { Action } from './types/actions.js';
package/dist/src/lib.js CHANGED
@@ -1,2 +1,2 @@
1
- export { defineAutomation, abort } from './types/automation.js';
1
+ export { defineAutomation, abort, requireState, requireNumericState, UnavailableInputError } from './types/automation.js';
2
2
  export { HomeAssistant } from './services.js';
@@ -14,4 +14,5 @@ interface TestOptions {
14
14
  }
15
15
  export declare function testAutomation<C>(automation: Automation<C>, options: TestOptions): Decision;
16
16
  export declare function testAbort<C>(automation: Automation<C>, options: TestOptions): Abort;
17
+ export declare function testUnavailable<C>(automation: Automation<C>, options: TestOptions): string;
17
18
  export {};
@@ -1,6 +1,6 @@
1
- import { isAbort } from './types/automation.js';
2
- function run(automation, options) {
3
- const { event, state = {}, ha = {} } = options;
1
+ import { isAbort, UnavailableInputError } from './types/automation.js';
2
+ function buildStateAndHa(options) {
3
+ const { state = {}, ha = {} } = options;
4
4
  const stateFunc = (entityId) => {
5
5
  const entry = state[entityId];
6
6
  if (!entry)
@@ -18,10 +18,17 @@ function run(automation, options) {
18
18
  labelsFor: ha.labelsFor ?? (() => []),
19
19
  entitiesByArea: ha.entitiesByArea ?? (() => []),
20
20
  };
21
- const ctx = automation.context(stateFunc, haContext, event);
21
+ return { stateFunc, haContext };
22
+ }
23
+ function run(automation, options) {
24
+ const { stateFunc, haContext } = buildStateAndHa(options);
25
+ const ctx = automation.context(stateFunc, haContext, options.event);
22
26
  if (isAbort(ctx))
23
27
  return ctx;
24
- return automation.reduce(ctx);
28
+ const result = automation.reduce(ctx);
29
+ // Mirrors runPipeline's default in src/framework/pipeline.ts, so tests observe the same
30
+ // `conditions` a real run would publish.
31
+ return { ...result, conditions: result.conditions ?? ctx };
25
32
  }
26
33
  export function testAutomation(automation, options) {
27
34
  const result = run(automation, options);
@@ -35,3 +42,19 @@ export function testAbort(automation, options) {
35
42
  throw new Error(`expected abort but got decision: ${result.decision}`);
36
43
  return result;
37
44
  }
45
+ // For automations using requireState()/requireNumericState(), which throw
46
+ // UnavailableInputError rather than returning Abort — testAbort() can't observe these since
47
+ // run() never catches an exception thrown out of context(). Returns the entity id the
48
+ // automation required, so tests can assert on which input was missing.
49
+ export function testUnavailable(automation, options) {
50
+ const { stateFunc, haContext } = buildStateAndHa(options);
51
+ try {
52
+ automation.context(stateFunc, haContext, options.event);
53
+ }
54
+ catch (err) {
55
+ if (err instanceof UnavailableInputError)
56
+ return err.entityId;
57
+ throw err;
58
+ }
59
+ throw new Error('expected UnavailableInputError but context() completed normally');
60
+ }
@@ -1,4 +1,4 @@
1
- export type Action = {
1
+ export interface HaCallServiceAction {
2
2
  type: 'ha.call_service';
3
3
  domain: string;
4
4
  service: string;
@@ -6,7 +6,8 @@ export type Action = {
6
6
  entity_id: string;
7
7
  };
8
8
  data?: Record<string, unknown>;
9
- } | {
9
+ }
10
+ export type Action = HaCallServiceAction | {
10
11
  type: 'mqtt.publish';
11
12
  topic: string;
12
13
  payload: string;
@@ -6,7 +6,7 @@ export interface Decision {
6
6
  decision: string;
7
7
  reason?: string;
8
8
  actions: Action[];
9
- inputs?: Record<string, unknown>;
9
+ conditions?: Record<string, unknown>;
10
10
  }
11
11
  export type Abort = {
12
12
  abort: true;
@@ -14,6 +14,12 @@ export type Abort = {
14
14
  };
15
15
  export declare const abort: (reason: string) => Abort;
16
16
  export declare function isAbort(value: unknown): value is Abort;
17
+ export declare class UnavailableInputError extends Error {
18
+ readonly entityId: string;
19
+ constructor(entityId: string);
20
+ }
21
+ export declare function requireState(state: HAState, entityId: Parameters<HAState>[0]): string;
22
+ export declare function requireNumericState(state: HAState, entityId: Parameters<HAState>[0]): number;
17
23
  export interface Automation<C> {
18
24
  id: string;
19
25
  location: string;
@@ -2,6 +2,41 @@ export const abort = (reason) => ({ abort: true, reason });
2
2
  export function isAbort(value) {
3
3
  return typeof value === 'object' && value !== null && value.abort === true;
4
4
  }
5
+ // ---------- Required state ----------
6
+ // Covers the dominant abort() pattern found across real automations (~70% of call sites,
7
+ // per #142's audit): a required entity's state is missing or invalid, so context() can't
8
+ // proceed. Thrown, not returned — pipeline.ts already wraps context() in try/catch, so this
9
+ // collapses `const x = state(id)?.state; if (x === undefined) return abort(...)` at every call
10
+ // site down to one line, with no per-call guard needed. Caught specially in pipeline.ts and
11
+ // classified as abort_kind: 'unavailable_input' with the entity name, distinct from a genuine
12
+ // bug (abort_kind: 'unhandled_error').
13
+ export class UnavailableInputError extends Error {
14
+ entityId;
15
+ constructor(entityId) {
16
+ super(`required entity unavailable: ${entityId}`);
17
+ this.entityId = entityId;
18
+ this.name = 'UnavailableInputError';
19
+ }
20
+ }
21
+ // HA's own sentinel values for "entity is registered but not producing readings" — an
22
+ // offline zigbee device, an integration that hasn't polled yet. Distinct from the entity
23
+ // key being absent from the state cache entirely, but the same "unavailable" in practice:
24
+ // every real audited call site treated these three cases identically.
25
+ const HA_UNAVAILABLE_STATES = new Set(['unavailable', 'unknown']);
26
+ export function requireState(state, entityId) {
27
+ const value = state(entityId)?.state;
28
+ if (value === undefined || HA_UNAVAILABLE_STATES.has(value)) {
29
+ throw new UnavailableInputError(String(entityId));
30
+ }
31
+ return value;
32
+ }
33
+ export function requireNumericState(state, entityId) {
34
+ const raw = requireState(state, entityId);
35
+ const parsed = parseFloat(raw);
36
+ if (!Number.isFinite(parsed))
37
+ throw new UnavailableInputError(String(entityId));
38
+ return parsed;
39
+ }
5
40
  // Identity function — provides type inference on C so the reduce argument
6
41
  // is typed correctly without the user annotating the context shape explicitly.
7
42
  export function defineAutomation(automation) {
@@ -51,4 +51,27 @@ export type TriggerEvent = TriggerEventBase & ({
51
51
  topic: string;
52
52
  payload: string;
53
53
  });
54
+ export type TriggerSummary = {
55
+ type: 'state_changed';
56
+ entity_id: string;
57
+ to: string;
58
+ from?: string;
59
+ } | {
60
+ type: 'schedule';
61
+ cron: string;
62
+ } | {
63
+ type: 'on_start';
64
+ } | {
65
+ type: 'timer_expired';
66
+ timerKey: string;
67
+ } | {
68
+ type: 'button';
69
+ entity_id: string;
70
+ gesture: 'single_press' | 'double_press' | 'hold';
71
+ button?: string;
72
+ } | {
73
+ type: 'mqtt_in';
74
+ topic: string;
75
+ };
76
+ export declare function summarizeTrigger(event: TriggerEvent): TriggerSummary;
54
77
  export {};
@@ -1 +1,26 @@
1
- export {};
1
+ export function summarizeTrigger(event) {
2
+ switch (event.type) {
3
+ case 'state_changed':
4
+ return {
5
+ type: 'state_changed',
6
+ entity_id: event.entity_id,
7
+ to: event.new_state.state,
8
+ ...(event.old_state && { from: event.old_state.state }),
9
+ };
10
+ case 'schedule':
11
+ return { type: 'schedule', cron: event.cron };
12
+ case 'on_start':
13
+ return { type: 'on_start' };
14
+ case 'timer_expired':
15
+ return { type: 'timer_expired', timerKey: event.timerKey };
16
+ case 'button':
17
+ return {
18
+ type: 'button',
19
+ entity_id: event.entity_id,
20
+ gesture: event.gesture,
21
+ ...(event.button !== undefined && { button: event.button }),
22
+ };
23
+ case 'mqtt_in':
24
+ return { type: 'mqtt_in', topic: event.topic };
25
+ }
26
+ }
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.3ff83fe",
4
+ "version": "0.0.1-edge.465375",
5
5
  "description": "TypeScript automation framework for Home Assistant",
6
6
  "files": [
7
7
  "dist"