@ajclarkson/homerun 0.0.1-edge.52a28ef → 0.0.1-edge.554fca4

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.
@@ -8,18 +8,14 @@ export function inferStateType(entity, allObservedStates) {
8
8
  case 'binary_sensor':
9
9
  case 'input_boolean':
10
10
  case 'switch':
11
- return "'on' | 'off'";
11
+ return "'on' | 'off' | 'unavailable' | 'unknown'";
12
12
  case 'input_select': {
13
13
  const options = entity.attributes.options;
14
14
  if (Array.isArray(options) && options.length > 0) {
15
- return options.map((o) => `'${o}'`).join(' | ');
15
+ return options.map((o) => `'${o}'`).join(' | ') + " | 'unavailable' | 'unknown'";
16
16
  }
17
17
  return 'string';
18
18
  }
19
- case 'person': {
20
- const states = allObservedStates ?? [entity.state];
21
- return [...new Set(states)].map((s) => `'${s}'`).join(' | ');
22
- }
23
19
  default:
24
20
  return 'string';
25
21
  }
@@ -37,18 +33,16 @@ export function generateFileContent(states) {
37
33
  const entries = states
38
34
  .map((s) => {
39
35
  const stateType = inferStateType(s, personStates.get(s.entity_id));
40
- return ` '${s.entity_id}': { state: ${stateType} };`;
36
+ return ` '${s.entity_id}': { state: ${stateType} };`;
41
37
  })
42
38
  .join('\n');
43
39
  return `// generated — do not edit — run: npm run generate:ha-types
44
- export interface HAEntities {
40
+ declare global {
41
+ interface HAEntities {
45
42
  ${entries}
43
+ }
46
44
  }
47
-
48
- export type HAState = {
49
- <E extends keyof HAEntities>(entity: E): HAEntities[E]['state'];
50
- <E extends string>(entity: E): string | undefined;
51
- };
45
+ export {};
52
46
  `;
53
47
  }
54
48
  // ---------- CLI ----------
@@ -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;
@@ -17,13 +20,20 @@ interface Deps {
17
20
  eventPublisher: EventPublisher;
18
21
  dryRun: boolean;
19
22
  metrics?: MetricsBackend;
23
+ commandAck?: {
24
+ enabled: boolean;
25
+ timeoutMs: number;
26
+ };
20
27
  }
21
28
  export declare class ActionRuntime {
22
29
  private readonly deps;
23
30
  constructor(deps: Deps);
31
+ private handleAckTimeout;
24
32
  execute(actions: Action[], ctx: ExecutionContext): Promise<void>;
25
33
  private runAction;
26
34
  private dispatch;
27
- private makeEvent;
35
+ private baseFields;
36
+ private makeStartedEvent;
37
+ private makeResultEvent;
28
38
  }
29
39
  export {};
@@ -1,7 +1,90 @@
1
+ function safeStringify(err) {
2
+ try {
3
+ return JSON.stringify(err);
4
+ }
5
+ catch {
6
+ return String(err);
7
+ }
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
+ }
1
63
  export class ActionRuntime {
2
64
  deps;
3
65
  constructor(deps) {
4
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
+ });
5
88
  }
6
89
  async execute(actions, ctx) {
7
90
  for (const action of actions) {
@@ -10,32 +93,63 @@ export class ActionRuntime {
10
93
  }
11
94
  async runAction(action, ctx) {
12
95
  const labels = { location: ctx.location, action_type: action.type };
13
- this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_started', action));
96
+ this.deps.eventPublisher.publishActionEvent(this.makeStartedEvent(ctx, action));
14
97
  this.deps.metrics?.incrementCounter('homerun_actions_dispatched_total', labels);
15
98
  const start = performance.now();
16
99
  try {
17
100
  if (!this.deps.dryRun) {
18
- await this.dispatch(action);
101
+ await this.dispatch(action, ctx);
19
102
  }
20
103
  const duration = (performance.now() - start) / 1000;
21
104
  this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
22
105
  this.deps.metrics?.incrementCounter('homerun_actions_succeeded_total', labels);
23
- this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason: 'ok' }));
106
+ this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'ok'));
24
107
  }
25
108
  catch (err) {
26
109
  const duration = (performance.now() - start) / 1000;
27
110
  this.deps.metrics?.observeHistogram('homerun_action_duration_seconds', duration, labels);
28
111
  this.deps.metrics?.incrementCounter('homerun_actions_failed_total', labels);
29
- const reason = err instanceof Error ? err.message : String(err);
30
- this.deps.eventPublisher.publishActionEvent(this.makeEvent(ctx, 'action_result', action, { reason }));
112
+ const error = err instanceof Error
113
+ ? err.message
114
+ : typeof err === 'object' && err !== null
115
+ ? safeStringify(err)
116
+ : String(err);
117
+ this.deps.eventPublisher.publishActionEvent(this.makeResultEvent(ctx, action, 'error', error));
31
118
  }
32
119
  }
33
- async dispatch(action) {
120
+ async dispatch(action, ctx) {
34
121
  switch (action.type) {
35
- case 'ha.call_service':
36
- await this.deps.haClient.callService(action.domain, action.service, action.target, action.data);
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
+ }
138
+ await this.deps.haClient.callService(action.domain, action.service, action.target, action.data, {
139
+ correlationId: ctx.correlationId,
140
+ rootCorrelationId: ctx.rootCorrelationId,
141
+ automationId: ctx.automationId,
142
+ });
37
143
  break;
144
+ }
38
145
  case 'mqtt.publish':
146
+ if (action.impliesEntity) {
147
+ this.deps.haClient.registerPendingWrite(action.impliesEntity, {
148
+ correlationId: ctx.correlationId,
149
+ rootCorrelationId: ctx.rootCorrelationId,
150
+ automationId: ctx.automationId,
151
+ });
152
+ }
39
153
  await this.deps.mqttClient.publishAsync(action.topic, action.payload, { retain: action.retain ?? false });
40
154
  break;
41
155
  case 'timer.start':
@@ -50,18 +164,30 @@ export class ActionRuntime {
50
164
  }
51
165
  }
52
166
  }
53
- makeEvent(ctx, event_type, action, extra = {}) {
167
+ baseFields(ctx) {
54
168
  return {
55
- schema: 'home.events.v1',
169
+ schema: 'home.events.v2',
56
170
  correlation_id: ctx.correlationId,
171
+ root_correlation_id: ctx.rootCorrelationId ?? ctx.correlationId,
57
172
  automation_id: ctx.automationId,
58
173
  location: ctx.location,
59
174
  subsystem: ctx.subsystem,
60
- event_type,
61
- actions: [action],
62
175
  timestamp: new Date().toISOString(),
63
176
  ...(this.deps.dryRun ? { dry_run: true } : {}),
64
- ...extra,
177
+ ...(ctx.parentCorrelationId && { parent_correlation_id: ctx.parentCorrelationId }),
178
+ ...(ctx.parentAutomationId && { parent_automation_id: ctx.parentAutomationId }),
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 }),
65
191
  };
66
192
  }
67
193
  }
@@ -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) {
@@ -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,19 +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;
7
+ root_correlation_id: string;
8
+ parent_correlation_id?: string;
9
+ parent_automation_id?: string;
6
10
  automation_id: string;
7
11
  location: string;
8
12
  subsystem: string;
9
- event_type: 'decision' | 'abort' | 'action_started' | 'action_result';
10
- decision?: string;
11
- reason?: string;
12
- inputs?: Record<string, unknown>;
13
- actions?: Action[];
14
- dry_run?: boolean;
15
13
  timestamp: string;
16
- }
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
+ });
17
44
  export type LifecycleEventType = 'server_started' | 'server_stopping' | 'rescan_complete' | 'ha_reconnected';
18
45
  export interface LifecycleEvent {
19
46
  schema: 'home.lifecycle.v1';
@@ -24,11 +51,15 @@ export interface LifecycleEvent {
24
51
  }
25
52
  export declare class EventPublisher {
26
53
  private readonly mqtt;
54
+ private readonly enabled;
27
55
  private readonly listeners;
28
- constructor(mqtt: MqttClient);
56
+ constructor(mqtt: MqttClient, enabled?: boolean);
29
57
  subscribe(listener: (event: ObsEvent) => void): () => void;
30
58
  publishDecision(event: ObsEvent): void;
31
59
  publishActionEvent(event: ObsEvent): void;
32
60
  publishLifecycle(type: LifecycleEventType, automationCount: number, dryRun?: boolean): void;
33
61
  private publish;
62
+ private safeSerialize;
63
+ private notifyListeners;
34
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;
@@ -7,7 +8,12 @@ export interface EntityState {
7
8
  last_changed: string;
8
9
  last_updated: string;
9
10
  }
10
- export type HAState = (entity: string) => EntityState | undefined;
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;
11
17
  export interface HAContext {
12
18
  entitiesByLabel: (label: string) => string[];
13
19
  labelsFor: (entity: string) => string[];
@@ -18,20 +24,44 @@ export interface StateChangedEvent {
18
24
  old_state: EntityState | undefined;
19
25
  new_state: EntityState;
20
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;
21
47
  }
22
48
  export declare interface HAClient {
23
49
  on(event: 'state_changed', listener: (e: StateChangedEvent) => void): this;
24
50
  on(event: 'ready', listener: () => void): this;
25
51
  on(event: 'reconnected', listener: () => void): this;
52
+ on(event: 'action_ack_timeout', listener: (e: AckTimeoutEvent) => void): this;
26
53
  emit(event: 'state_changed', e: StateChangedEvent): boolean;
27
54
  emit(event: 'ready'): boolean;
28
55
  emit(event: 'reconnected'): boolean;
56
+ emit(event: 'action_ack_timeout', e: AckTimeoutEvent): boolean;
29
57
  }
30
58
  export declare class HAClient extends EventEmitter {
31
59
  private readonly stateCache;
32
60
  private readonly labelToEntities;
33
61
  private readonly entityToLabels;
34
62
  private readonly areaToEntities;
63
+ private readonly pendingWrites;
64
+ private readonly pendingAcks;
35
65
  private connection;
36
66
  private reconnecting;
37
67
  private _readyResolve;
@@ -39,10 +69,17 @@ export declare class HAClient extends EventEmitter {
39
69
  readonly state: HAState;
40
70
  readonly context: HAContext;
41
71
  get entityCount(): number;
42
- callService(domain: string, service: string, target?: HassServiceTarget, data?: Record<string, unknown>): Promise<void>;
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;
43
79
  disconnect(): void;
44
80
  connect(url: string, token: string): Promise<void>;
45
81
  private repopulateCache;
46
82
  private diffAndUpdate;
47
83
  private loadEntityRegistry;
48
84
  }
85
+ export {};