@coffeerunhobby/mcp-ha-connect 2.1.0 → 2.2.0

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 (74) hide show
  1. package/README.md +19 -3
  2. package/dist/config.d.ts +4 -0
  3. package/dist/config.js +6 -0
  4. package/dist/config.js.map +1 -1
  5. package/dist/haClient/automations.d.ts +18 -2
  6. package/dist/haClient/automations.js +25 -7
  7. package/dist/haClient/automations.js.map +1 -1
  8. package/dist/haClient/events.d.ts +60 -7
  9. package/dist/haClient/events.js +252 -47
  10. package/dist/haClient/events.js.map +1 -1
  11. package/dist/haClient/index.d.ts +2 -1
  12. package/dist/haClient/index.js +4 -3
  13. package/dist/haClient/index.js.map +1 -1
  14. package/dist/haClient/wsCommand.d.ts +16 -0
  15. package/dist/haClient/wsCommand.js +36 -0
  16. package/dist/haClient/wsCommand.js.map +1 -0
  17. package/dist/omadaClient/site.js +15 -2
  18. package/dist/omadaClient/site.js.map +1 -1
  19. package/dist/ownerQuestions/service.d.ts +167 -0
  20. package/dist/ownerQuestions/service.js +439 -0
  21. package/dist/ownerQuestions/service.js.map +1 -0
  22. package/dist/server/common.d.ts +3 -0
  23. package/dist/server/common.js +2 -1
  24. package/dist/server/common.js.map +1 -1
  25. package/dist/server/http.js +30 -6
  26. package/dist/server/http.js.map +1 -1
  27. package/dist/server/stream.d.ts +2 -0
  28. package/dist/server/stream.js +2 -1
  29. package/dist/server/stream.js.map +1 -1
  30. package/dist/tools/common.d.ts +1 -1
  31. package/dist/tools/common.js +4 -1
  32. package/dist/tools/common.js.map +1 -1
  33. package/dist/tools/homeassistant/askOwner.d.ts +29 -0
  34. package/dist/tools/homeassistant/askOwner.js +85 -0
  35. package/dist/tools/homeassistant/askOwner.js.map +1 -0
  36. package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
  37. package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
  38. package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
  39. package/dist/tools/index.d.ts +3 -0
  40. package/dist/tools/index.js +5 -1
  41. package/dist/tools/index.js.map +1 -1
  42. package/dist/utils/plainText.d.ts +22 -0
  43. package/dist/utils/plainText.js +39 -0
  44. package/dist/utils/plainText.js.map +1 -0
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CHANGELOG.md +19 -0
  48. package/openapi.json +1 -1
  49. package/package.json +1 -1
  50. package/src/config.ts +10 -0
  51. package/src/haClient/automations.ts +39 -7
  52. package/src/haClient/events.ts +289 -56
  53. package/src/haClient/index.ts +10 -4
  54. package/src/haClient/wsCommand.ts +44 -0
  55. package/src/omadaClient/site.ts +17 -2
  56. package/src/ownerQuestions/service.ts +536 -0
  57. package/src/server/common.ts +5 -1
  58. package/src/server/http.ts +30 -6
  59. package/src/server/stream.ts +4 -1
  60. package/src/tools/common.ts +4 -1
  61. package/src/tools/homeassistant/askOwner.ts +133 -0
  62. package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
  63. package/src/tools/index.ts +8 -1
  64. package/src/utils/plainText.ts +48 -0
  65. package/src/version.ts +1 -1
  66. package/tests/config.test.ts +12 -0
  67. package/tests/haClient/automationTrace.test.ts +59 -0
  68. package/tests/haClient/automations.test.ts +16 -0
  69. package/tests/haClient/events.test.ts +455 -0
  70. package/tests/haClient/eventsTls.test.ts +52 -0
  71. package/tests/omadaClient/ntp.test.ts +15 -0
  72. package/tests/ownerQuestions/service.test.ts +499 -0
  73. package/tests/ownerQuestions/tools.test.ts +186 -0
  74. package/tests/tools/homeassistant/handlers.test.ts +10 -1
@@ -39,13 +39,30 @@ export interface HaEvent {
39
39
  };
40
40
  }
41
41
 
42
+ /** Home Assistant answered a command with `success: false` (a refusal, not a connection problem). */
43
+ export class HaCommandError extends Error {
44
+ constructor(
45
+ readonly commandType: string,
46
+ readonly code: string
47
+ ) {
48
+ super(`Home Assistant rejected '${commandType}' (${code})`);
49
+ this.name = 'HaCommandError';
50
+ }
51
+ }
52
+
42
53
  export interface EventSubscription {
43
54
  id: string;
44
55
  subscriptionId: number;
56
+ /** The socket this subscription was sent on (HA forgets it when that socket closes). */
57
+ connection?: WebSocket | null;
58
+ /** The subscribe_events command in flight on that socket. */
59
+ sending?: Promise<number>;
45
60
  eventType?: string;
46
61
  domain?: string;
47
62
  entityId?: string;
48
63
  callback: (event: HaEvent) => void;
64
+ /** Called if Home Assistant refuses the subscription (e.g. a non-admin token); it is then dropped. */
65
+ onRejected?: (error: HaCommandError) => void;
49
66
  }
50
67
 
51
68
  export interface EventSubscriberConfig {
@@ -53,6 +70,16 @@ export interface EventSubscriberConfig {
53
70
  token: string;
54
71
  reconnectInterval?: number;
55
72
  maxReconnectAttempts?: number;
73
+ /** Upper bound for the exponential reconnect delay (ms). Default 60 s. */
74
+ maxReconnectDelay?: number;
75
+ /** Deadline for connecting and authenticating (ms). Default 30 s. */
76
+ connectTimeout?: number;
77
+ /** Verify the TLS certificate of a wss:// Home Assistant (HA_STRICT_SSL). Default true. */
78
+ strictSsl?: boolean;
79
+ /** How long a command waits for its result (ms). Default 30 s. */
80
+ commandTimeout?: number;
81
+ /** Delay before re-sending subscriptions that failed for a transient reason (ms). Default 30 s. */
82
+ resubscribeRetryDelay?: number;
56
83
  }
57
84
 
58
85
  /**
@@ -64,21 +91,33 @@ export class EventSubscriber extends EventEmitter {
64
91
  private messageId = 1;
65
92
  private authenticated = false;
66
93
  private subscriptions = new Map<string, EventSubscription>();
67
- private pendingSubscriptions: Array<{ resolve: (id: number) => void; reject: (error: Error) => void }> = [];
94
+ /** Commands awaiting their `result`, keyed by message id (HA answers by id). */
95
+ private pendingCommands = new Map<
96
+ number,
97
+ { type: string; resolve: (result: unknown) => void; reject: (error: Error) => void }
98
+ >();
68
99
  private reconnectAttempts = 0;
69
100
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
101
+ /** Deadline of the connect attempt in progress (cleared on auth_ok or close). */
102
+ private connectTimer: ReturnType<typeof setTimeout> | null = null;
103
+ /** Pending retry of subscriptions that failed for a transient reason. */
104
+ private resubscribeTimer: ReturnType<typeof setTimeout> | null = null;
70
105
  private isConnecting = false;
71
106
  private shouldReconnect = true;
72
107
 
73
108
  private readonly config: EventSubscriberConfig;
74
109
  private readonly reconnectInterval: number;
75
110
  private readonly maxReconnectAttempts: number;
111
+ private readonly maxReconnectDelay: number;
76
112
 
77
113
  constructor(config: EventSubscriberConfig) {
78
114
  super();
79
115
  this.config = config;
80
116
  this.reconnectInterval = config.reconnectInterval ?? 5000;
81
- this.maxReconnectAttempts = config.maxReconnectAttempts ?? 10;
117
+ // Keep retrying by default: a long Home Assistant outage must not leave the
118
+ // server permanently deaf to events (the delay is capped, see handleDisconnect).
119
+ this.maxReconnectAttempts = config.maxReconnectAttempts ?? Number.POSITIVE_INFINITY;
120
+ this.maxReconnectDelay = config.maxReconnectDelay ?? 60000;
82
121
  }
83
122
 
84
123
  /**
@@ -107,7 +146,20 @@ export class EventSubscriber extends EventEmitter {
107
146
 
108
147
  logger.info('Connecting to Home Assistant WebSocket', { url: wsUrl });
109
148
 
110
- this.ws = new WebSocket(wsUrl);
149
+ // Same TLS policy as the REST client: only HA_STRICT_SSL=false skips verification.
150
+ this.ws = new WebSocket(wsUrl, { rejectUnauthorized: this.config.strictSsl !== false });
151
+ const ws = this.ws;
152
+ // A connection that never authenticates must not hang its caller.
153
+ this.clearConnectTimer();
154
+ if (this.resubscribeTimer) {
155
+ clearTimeout(this.resubscribeTimer);
156
+ this.resubscribeTimer = null;
157
+ }
158
+ this.connectTimer = setTimeout(() => {
159
+ this.connectTimer = null;
160
+ reject(new Error('Timed out connecting to the Home Assistant WebSocket'));
161
+ ws.terminate();
162
+ }, this.config.connectTimeout ?? 30000);
111
163
 
112
164
  this.ws.on('open', () => {
113
165
  logger.info('WebSocket connection established');
@@ -125,15 +177,20 @@ export class EventSubscriber extends EventEmitter {
125
177
 
126
178
  this.ws.on('close', (code, reason) => {
127
179
  logger.info('WebSocket connection closed', { code, reason: reason.toString() });
180
+ this.clearConnectTimer();
128
181
  this.authenticated = false;
129
182
  this.isConnecting = false;
183
+ this.rejectPendingCommands(new Error('WebSocket connection closed'));
184
+ // Closed before authenticating: fail the connect attempt (no-op once resolved).
185
+ reject(new Error('Home Assistant closed the WebSocket before authentication'));
130
186
  this.handleDisconnect();
131
187
  });
132
188
 
133
189
  this.ws.on('error', (error) => {
190
+ // The detail (often the internal address) stays in the server log.
134
191
  logger.error('WebSocket error', { error: error.message });
135
192
  this.isConnecting = false;
136
- reject(error);
193
+ reject(new Error('Could not connect to the Home Assistant WebSocket'));
137
194
  });
138
195
  } catch (error) {
139
196
  this.isConnecting = false;
@@ -161,13 +218,18 @@ export class EventSubscriber extends EventEmitter {
161
218
  });
162
219
  break;
163
220
 
164
- case 'auth_ok':
221
+ case 'auth_ok': {
165
222
  logger.info('WebSocket authentication successful');
223
+ this.clearConnectTimer();
166
224
  this.authenticated = true;
167
225
  this.isConnecting = false;
168
226
  this.emit('connected');
169
227
  resolveConnect();
228
+ // HA forgets subscriptions with the connection: (re)send every registered
229
+ // one, including those queued while Home Assistant was unreachable.
230
+ void this.resubscribeAll();
170
231
  break;
232
+ }
171
233
 
172
234
  case 'auth_invalid':
173
235
  logger.error('WebSocket authentication failed', { message: message.message });
@@ -193,17 +255,159 @@ export class EventSubscriber extends EventEmitter {
193
255
  */
194
256
  private handleResult(message: Record<string, unknown>): void {
195
257
  const id = message.id as number;
196
- const success = message.success as boolean;
197
-
198
- if (this.pendingSubscriptions.length > 0) {
199
- const pending = this.pendingSubscriptions.shift()!;
200
- if (success) {
201
- pending.resolve(id);
202
- } else {
203
- const error = message.error as { message?: string } | undefined;
204
- pending.reject(new Error(error?.message ?? 'Subscription failed'));
258
+ const pending = this.pendingCommands.get(id);
259
+ if (!pending) {
260
+ return;
261
+ }
262
+ this.pendingCommands.delete(id);
263
+ if (message.success) {
264
+ pending.resolve(message.result);
265
+ } else {
266
+ // Callers and logs get only HA's error code (a fixed vocabulary such as
267
+ // "unauthorized" or "not_found"); HA's free-text message can carry URLs or
268
+ // other detail and is never passed on or logged.
269
+ const error = message.error as { code?: string; message?: string } | undefined;
270
+ const code = typeof error?.code === 'string' && /^[a-z_]{1,40}$/.test(error.code) ? error.code : 'unknown_error';
271
+ logger.warn('Home Assistant command failed', { type: pending.type, code });
272
+ pending.reject(new HaCommandError(pending.type, code));
273
+ }
274
+ }
275
+
276
+ private clearConnectTimer(): void {
277
+ if (this.connectTimer) {
278
+ clearTimeout(this.connectTimer);
279
+ this.connectTimer = null;
280
+ }
281
+ }
282
+
283
+ private rejectPendingCommands(error: Error): void {
284
+ for (const pending of this.pendingCommands.values()) {
285
+ pending.reject(error);
286
+ }
287
+ this.pendingCommands.clear();
288
+ }
289
+
290
+ /**
291
+ * Send a WebSocket command and resolve with its `result` (rejects on
292
+ * `success: false`, on disconnect, or after `timeoutMs`).
293
+ */
294
+ async sendCommand<T = unknown>(
295
+ command: { type: string; [key: string]: unknown },
296
+ timeoutMs = this.config.commandTimeout ?? 30000
297
+ ): Promise<T> {
298
+ return (await this.dispatchCommand<T>(command, timeoutMs)).result;
299
+ }
300
+
301
+ /** Like sendCommand, but also reports the message id the command was sent with. */
302
+ private async dispatchCommand<T>(
303
+ command: { type: string; [key: string]: unknown },
304
+ timeoutMs = this.config.commandTimeout ?? 30000
305
+ ): Promise<{ id: number; result: T }> {
306
+ if (!this.authenticated) {
307
+ await this.connect();
308
+ }
309
+ const { id, result } = this.beginCommand<T>(command, timeoutMs);
310
+ return { id, result: await result };
311
+ }
312
+
313
+ /**
314
+ * Send a command now and return its message id synchronously, with a promise
315
+ * for its result. Callers that must act on the id before the result arrives
316
+ * (subscriptions) use this directly.
317
+ */
318
+ private beginCommand<T>(
319
+ command: { type: string; [key: string]: unknown },
320
+ timeoutMs = this.config.commandTimeout ?? 30000
321
+ ): { id: number; result: Promise<T> } {
322
+ const id = this.messageId++;
323
+ const result = new Promise<T>((resolve, reject) => {
324
+ const timer = setTimeout(() => {
325
+ this.pendingCommands.delete(id);
326
+ reject(new Error(`Home Assistant command '${command.type}' timed out`));
327
+ }, timeoutMs);
328
+ this.pendingCommands.set(id, {
329
+ type: command.type,
330
+ resolve: (value) => {
331
+ clearTimeout(timer);
332
+ resolve(value as T);
333
+ },
334
+ reject: (error) => {
335
+ clearTimeout(timer);
336
+ reject(error);
337
+ },
338
+ });
339
+ try {
340
+ this.sendMessage({ ...command, id });
341
+ } catch (error) {
342
+ clearTimeout(timer);
343
+ this.pendingCommands.delete(id);
344
+ reject(error instanceof Error ? error : new Error(String(error)));
205
345
  }
346
+ });
347
+ return { id, result };
348
+ }
349
+
350
+ /** Send subscribe_events for every registered subscription on a new connection. */
351
+ private async resubscribeAll(): Promise<void> {
352
+ for (const sub of this.subscriptions.values()) {
353
+ if (sub.connection === this.ws) {
354
+ continue; // already subscribed (or in flight) on this connection
355
+ }
356
+ const connection = this.ws;
357
+ sub.connection = connection;
358
+ // HA identifies the subscription by this message's id and may send events
359
+ // right after its confirmation (even in the same read): accept them from
360
+ // the moment the command is sent, not when the confirmation is processed.
361
+ const { id, result } = this.beginCommand(
362
+ sub.eventType ? { type: 'subscribe_events', event_type: sub.eventType } : { type: 'subscribe_events' }
363
+ );
364
+ sub.subscriptionId = id;
365
+ sub.sending = result.then(() => id);
366
+ try {
367
+ await sub.sending;
368
+ logger.info('Event subscription active on new connection', { id: sub.id, eventType: sub.eventType });
369
+ } catch (error) {
370
+ if (this.subscriptions.get(sub.id) !== sub) {
371
+ continue; // unsubscribed meanwhile; unsubscribe() already cancelled it on HA's side
372
+ }
373
+ sub.subscriptionId = -1;
374
+ sub.connection = null;
375
+ if (error instanceof HaCommandError) {
376
+ // A refusal (e.g. a non-admin token for a non-allowlisted event) is
377
+ // permanent: drop it and tell the owner of the subscription.
378
+ this.subscriptions.delete(sub.id);
379
+ logger.error('Home Assistant refused an event subscription', { id: sub.id, eventType: sub.eventType, code: error.code });
380
+ sub.onRejected?.(error);
381
+ } else {
382
+ // Unconfirmed (e.g. timed out): HA may still have registered it. HA
383
+ // handles a connection's messages in order, so cancelling now is safe
384
+ // either way and keeps a retry from adding a duplicate.
385
+ if (this.authenticated && this.ws === connection) {
386
+ this.sendUnsubscribe(id);
387
+ }
388
+ logger.warn('Event subscription not confirmed; will retry', {
389
+ id: sub.id,
390
+ eventType: sub.eventType,
391
+ error: error instanceof Error ? error.message : String(error),
392
+ });
393
+ this.scheduleResubscribe();
394
+ }
395
+ }
396
+ }
397
+ }
398
+
399
+ /** Retry failed subscriptions later on the same connection (a reconnect also retries them). */
400
+ private scheduleResubscribe(): void {
401
+ if (this.resubscribeTimer) {
402
+ return;
206
403
  }
404
+ this.resubscribeTimer = setTimeout(() => {
405
+ this.resubscribeTimer = null;
406
+ if (this.authenticated) {
407
+ void this.resubscribeAll();
408
+ }
409
+ }, this.config.resubscribeRetryDelay ?? 30000);
410
+ this.resubscribeTimer.unref?.();
207
411
  }
208
412
 
209
413
  /**
@@ -213,9 +417,12 @@ export class EventSubscriber extends EventEmitter {
213
417
  const event = message.event as HaEvent;
214
418
  if (!event) return;
215
419
 
216
- // Emit to all matching subscriptions
420
+ // HA sends one copy of the event per subscription, tagged with that
421
+ // subscription's id: deliver each copy only to its own subscription, or two
422
+ // subscriptions to the same event type would each get every event twice.
423
+ const subscriptionId = message.id as number;
217
424
  for (const sub of this.subscriptions.values()) {
218
- if (this.eventMatchesSubscription(event, sub)) {
425
+ if (sub.subscriptionId === subscriptionId && this.eventMatchesSubscription(event, sub)) {
219
426
  try {
220
427
  sub.callback(event);
221
428
  } catch (error) {
@@ -277,7 +484,7 @@ export class EventSubscriber extends EventEmitter {
277
484
  }
278
485
 
279
486
  this.reconnectAttempts++;
280
- const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1);
487
+ const delay = Math.min(this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
281
488
 
282
489
  logger.info('Scheduling reconnect', {
283
490
  attempt: this.reconnectAttempts,
@@ -286,6 +493,7 @@ export class EventSubscriber extends EventEmitter {
286
493
  });
287
494
 
288
495
  this.reconnectTimer = setTimeout(() => {
496
+ this.reconnectTimer = null;
289
497
  this.connect().catch((error) => {
290
498
  logger.error('Reconnect failed', { error: error.message });
291
499
  });
@@ -304,10 +512,15 @@ export class EventSubscriber extends EventEmitter {
304
512
  /**
305
513
  * Subscribe to specific event type
306
514
  */
307
- async subscribeEventType(eventType: string, callback: (event: HaEvent) => void): Promise<string> {
515
+ async subscribeEventType(
516
+ eventType: string,
517
+ callback: (event: HaEvent) => void,
518
+ onRejected?: (error: HaCommandError) => void
519
+ ): Promise<string> {
308
520
  return this.subscribe({
309
521
  eventType,
310
522
  callback,
523
+ onRejected,
311
524
  });
312
525
  }
313
526
 
@@ -334,30 +547,34 @@ export class EventSubscriber extends EventEmitter {
334
547
  }
335
548
 
336
549
  /**
337
- * Internal subscribe method
550
+ * Internal subscribe method.
551
+ *
552
+ * The subscription is registered first and then sent on every authenticated
553
+ * connection: right away when connected, otherwise as soon as Home Assistant
554
+ * is reachable, and again after any reconnect (HA forgets subscriptions with
555
+ * the connection). It never fails because Home Assistant is down.
338
556
  */
339
557
  private async subscribe(options: {
340
558
  eventType?: string;
341
559
  domain?: string;
342
560
  entityId?: string;
343
561
  callback: (event: HaEvent) => void;
562
+ onRejected?: (error: HaCommandError) => void;
344
563
  }): Promise<string> {
345
- if (!this.authenticated) {
346
- await this.connect();
347
- }
348
-
349
- const subscriptionId = await this.sendSubscribeEvents(options.eventType);
350
564
  const id = `sub_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
351
-
352
- this.subscriptions.set(id, {
565
+ const entry: EventSubscription = {
353
566
  id,
354
- subscriptionId,
567
+ subscriptionId: -1,
568
+ connection: null,
355
569
  eventType: options.eventType,
356
570
  domain: options.domain,
357
571
  entityId: options.entityId,
358
572
  callback: options.callback,
359
- });
360
-
573
+ // Refusals that arrive later (after HA becomes reachable, or after a
574
+ // reconnect) are reported here.
575
+ onRejected: options.onRejected,
576
+ };
577
+ this.subscriptions.set(id, entry);
361
578
  logger.info('Created event subscription', {
362
579
  id,
363
580
  eventType: options.eventType,
@@ -365,27 +582,33 @@ export class EventSubscriber extends EventEmitter {
365
582
  entityId: options.entityId,
366
583
  });
367
584
 
368
- return id;
369
- }
370
-
371
- /**
372
- * Send subscribe_events command
373
- */
374
- private sendSubscribeEvents(eventType?: string): Promise<number> {
375
- return new Promise((resolve, reject) => {
376
- this.pendingSubscriptions.push({ resolve, reject });
377
-
378
- const message: Record<string, unknown> = {
379
- id: this.messageId++,
380
- type: 'subscribe_events',
381
- };
382
-
383
- if (eventType) {
384
- message.event_type = eventType;
585
+ if (!this.authenticated && !this.reconnectTimer) {
586
+ try {
587
+ await this.connect();
588
+ } catch (error) {
589
+ logger.warn('Home Assistant WebSocket not reachable; the subscription will be sent once it is', {
590
+ error: error instanceof Error ? error.message : String(error),
591
+ });
592
+ return id;
385
593
  }
386
-
387
- this.sendMessage(message);
388
- });
594
+ }
595
+ if (this.authenticated) {
596
+ // Return once Home Assistant has confirmed it. A refusal now is thrown to
597
+ // the caller (and the subscription dropped) rather than reported to
598
+ // onRejected; a transient failure leaves it queued.
599
+ entry.onRejected = undefined;
600
+ try {
601
+ await this.resubscribeAll();
602
+ await entry.sending;
603
+ } catch (error) {
604
+ if (error instanceof HaCommandError) {
605
+ throw error;
606
+ }
607
+ } finally {
608
+ entry.onRejected = options.onRejected;
609
+ }
610
+ }
611
+ return id;
389
612
  }
390
613
 
391
614
  /**
@@ -397,15 +620,24 @@ export class EventSubscriber extends EventEmitter {
397
620
  return;
398
621
  }
399
622
 
400
- // Send unsubscribe command
401
- this.sendMessage({
402
- id: this.messageId++,
403
- type: 'unsubscribe_events',
404
- subscription: sub.subscriptionId,
405
- });
406
-
623
+ // Forget it locally first, whatever the connection state, so it is never
624
+ // restored on a later reconnect.
407
625
  this.subscriptions.delete(subscriptionId);
408
626
  logger.info('Removed event subscription', { id: subscriptionId });
627
+
628
+ // Tell HA if it was sent on the current connection, confirmed or not: HA
629
+ // handles a connection's messages in order, so the cancel follows the subscribe.
630
+ if (this.authenticated && sub.connection === this.ws && sub.subscriptionId >= 0) {
631
+ this.sendUnsubscribe(sub.subscriptionId);
632
+ }
633
+ }
634
+
635
+ private sendUnsubscribe(haSubscriptionId: number): void {
636
+ try {
637
+ this.sendMessage({ id: this.messageId++, type: 'unsubscribe_events', subscription: haSubscriptionId });
638
+ } catch (error) {
639
+ logger.debug('Could not send unsubscribe_events', { error: error instanceof Error ? error.message : String(error) });
640
+ }
409
641
  }
410
642
 
411
643
  /**
@@ -429,6 +661,7 @@ export class EventSubscriber extends EventEmitter {
429
661
  clearTimeout(this.reconnectTimer);
430
662
  this.reconnectTimer = null;
431
663
  }
664
+ this.clearConnectTimer();
432
665
 
433
666
  if (this.ws) {
434
667
  this.ws.close();
@@ -18,7 +18,8 @@ import type {
18
18
  } from '../types/index.js';
19
19
  import { logger } from '../utils/logger.js';
20
20
 
21
- import { AutomationOperations } from './automations.js';
21
+ import { AutomationOperations, type AutomationTraceSummary } from './automations.js';
22
+ import { createHaWebSocketCommand } from './wsCommand.js';
22
23
  import { ConfigOperations } from './config.js';
23
24
  import { DeviceOperations } from './devices.js';
24
25
  import { EntityOperations } from './entities.js';
@@ -67,7 +68,12 @@ export class HaClient {
67
68
  this.stateOps = new StateOperations(this.request);
68
69
  this.serviceOps = new ServiceOperations(this.request);
69
70
  this.entityOps = new EntityOperations(this.stateOps);
70
- this.automationOps = new AutomationOperations(this.stateOps, this.serviceOps, this.request);
71
+ this.automationOps = new AutomationOperations(
72
+ this.stateOps,
73
+ this.serviceOps,
74
+ this.request,
75
+ createHaWebSocketCommand({ baseUrl: config.baseUrl, token: config.token, strictSsl: config.strictSsl })
76
+ );
71
77
  this.historyOps = new HistoryOperations(this.request);
72
78
  this.updateOps = new UpdateOperations(this.entityOps);
73
79
  this.configOps = new ConfigOperations(this.request);
@@ -226,8 +232,8 @@ export class HaClient {
226
232
  /**
227
233
  * Get automation execution trace
228
234
  */
229
- async getAutomationTrace(entityId: string): Promise<unknown[]> {
230
- return this.automationOps.getAutomationTrace(entityId);
235
+ async getAutomationTrace(entityId: string, limit?: number): Promise<AutomationTraceSummary[]> {
236
+ return this.automationOps.getAutomationTrace(entityId, limit);
231
237
  }
232
238
 
233
239
  // ===== History Operations =====
@@ -0,0 +1,44 @@
1
+ /**
2
+ * One-shot Home Assistant WebSocket command: connect, authenticate, send one
3
+ * command, return its result, disconnect. For the few HA APIs that exist only
4
+ * over WebSocket (e.g. automation traces), usable in every transport mode.
5
+ */
6
+
7
+ import { EventSubscriber } from './events.js';
8
+
9
+ export type HaWebSocketCommand = <T = unknown>(command: { type: string; [key: string]: unknown }) => Promise<T>;
10
+
11
+ export function createHaWebSocketCommand(config: {
12
+ baseUrl: string;
13
+ token: string;
14
+ strictSsl?: boolean;
15
+ /** One deadline for connecting, authenticating and the command (ms). Default 30 s. */
16
+ timeoutMs?: number;
17
+ }): HaWebSocketCommand {
18
+ const timeoutMs = config.timeoutMs ?? 30000;
19
+ return async <T>(command: { type: string; [key: string]: unknown }): Promise<T> => {
20
+ const connection = new EventSubscriber({
21
+ baseUrl: config.baseUrl,
22
+ token: config.token,
23
+ strictSsl: config.strictSsl,
24
+ maxReconnectAttempts: 0,
25
+ connectTimeout: timeoutMs,
26
+ });
27
+ let timer: ReturnType<typeof setTimeout> | undefined;
28
+ const deadline = new Promise<never>((_, reject) => {
29
+ timer = setTimeout(() => reject(new Error(`Home Assistant command '${command.type}' timed out`)), timeoutMs);
30
+ });
31
+ const work = (async () => {
32
+ await connection.connect();
33
+ return await connection.sendCommand<T>(command, timeoutMs);
34
+ })();
35
+ // If the deadline wins, the abandoned attempt still settles later: observe it.
36
+ work.catch(() => undefined);
37
+ try {
38
+ return await Promise.race([work, deadline]);
39
+ } finally {
40
+ clearTimeout(timer);
41
+ connection.disconnect();
42
+ }
43
+ };
44
+ }
@@ -132,14 +132,29 @@ export function buildSiteUpdateBody(site: OmadaSiteInfo, ntpServers: string[], n
132
132
  const dst: NonNullable<SiteUpdateBody['dst']> = {};
133
133
  if (enable !== undefined) dst.enable = enable;
134
134
  if (mode !== undefined) dst.mode = mode;
135
- if (start !== undefined) dst.start = start;
136
- if (end !== undefined) dst.end = end;
135
+ // With DST off the controller returns start/end as `{}` but rejects them on
136
+ // write ("should not be null"), so only complete points are sent back.
137
+ if (isCompleteDstTime(start)) dst.start = start;
138
+ if (isCompleteDstTime(end)) dst.end = end;
137
139
  if (offset !== undefined) dst.offset = offset;
140
+ if (enable === true && (dst.start === undefined || dst.end === undefined)) {
141
+ throw new Error('Site has daylight saving enabled but an incomplete start/end; refusing to write');
142
+ }
138
143
  body.dst = dst;
139
144
  }
140
145
  return body;
141
146
  }
142
147
 
148
+ const DST_TIME_FIELDS = ['month', 'serial', 'day', 'hour', 'minute'] as const;
149
+
150
+ function isCompleteDstTime(value: unknown): value is DstTime {
151
+ if (typeof value !== 'object' || value === null) {
152
+ return false;
153
+ }
154
+ const point = value as Record<string, unknown>;
155
+ return DST_TIME_FIELDS.every((field) => typeof point[field] === 'number');
156
+ }
157
+
143
158
  /**
144
159
  * Validate a configured default site (`OMADA_SITE_ID`) against the controller's
145
160
  * live site list.