@coffeerunhobby/mcp-ha-connect 2.1.1 → 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 (70) 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/ownerQuestions/service.d.ts +167 -0
  18. package/dist/ownerQuestions/service.js +439 -0
  19. package/dist/ownerQuestions/service.js.map +1 -0
  20. package/dist/server/common.d.ts +3 -0
  21. package/dist/server/common.js +2 -1
  22. package/dist/server/common.js.map +1 -1
  23. package/dist/server/http.js +30 -6
  24. package/dist/server/http.js.map +1 -1
  25. package/dist/server/stream.d.ts +2 -0
  26. package/dist/server/stream.js +2 -1
  27. package/dist/server/stream.js.map +1 -1
  28. package/dist/tools/common.d.ts +1 -1
  29. package/dist/tools/common.js +4 -1
  30. package/dist/tools/common.js.map +1 -1
  31. package/dist/tools/homeassistant/askOwner.d.ts +29 -0
  32. package/dist/tools/homeassistant/askOwner.js +85 -0
  33. package/dist/tools/homeassistant/askOwner.js.map +1 -0
  34. package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
  35. package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
  36. package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
  37. package/dist/tools/index.d.ts +3 -0
  38. package/dist/tools/index.js +5 -1
  39. package/dist/tools/index.js.map +1 -1
  40. package/dist/utils/plainText.d.ts +22 -0
  41. package/dist/utils/plainText.js +39 -0
  42. package/dist/utils/plainText.js.map +1 -0
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/docs/CHANGELOG.md +15 -0
  46. package/openapi.json +1 -1
  47. package/package.json +1 -1
  48. package/src/config.ts +10 -0
  49. package/src/haClient/automations.ts +39 -7
  50. package/src/haClient/events.ts +289 -56
  51. package/src/haClient/index.ts +10 -4
  52. package/src/haClient/wsCommand.ts +44 -0
  53. package/src/ownerQuestions/service.ts +536 -0
  54. package/src/server/common.ts +5 -1
  55. package/src/server/http.ts +30 -6
  56. package/src/server/stream.ts +4 -1
  57. package/src/tools/common.ts +4 -1
  58. package/src/tools/homeassistant/askOwner.ts +133 -0
  59. package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
  60. package/src/tools/index.ts +8 -1
  61. package/src/utils/plainText.ts +48 -0
  62. package/src/version.ts +1 -1
  63. package/tests/config.test.ts +12 -0
  64. package/tests/haClient/automationTrace.test.ts +59 -0
  65. package/tests/haClient/automations.test.ts +16 -0
  66. package/tests/haClient/events.test.ts +455 -0
  67. package/tests/haClient/eventsTls.test.ts +52 -0
  68. package/tests/ownerQuestions/service.test.ts +499 -0
  69. package/tests/ownerQuestions/tools.test.ts +186 -0
  70. package/tests/tools/homeassistant/handlers.test.ts +10 -1
package/dist/version.d.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export declare const VERSION = "2.1.1";
5
+ export declare const VERSION = "2.2.0";
package/dist/version.js CHANGED
@@ -2,5 +2,5 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '2.1.1';
5
+ export const VERSION = '2.2.0';
6
6
  //# sourceMappingURL=version.js.map
package/docs/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ ### 2.2.0
2
+ - **New: owner questions — approve or deny from the phone.** `askOwner` sends a sticky, persistent notification with 1-3 buttons (default Approve / Deny) and returns a `requestId` at once; `getOwnerAnswer` waits up to 50 s per call and returns `answered` (button index and label, `answeredAt`, the HA user who tapped), `pending`, `timeout`, `cancelled` or `unknown`; `cancelOwnerQuestion` withdraws a question and marks the notification "Cancelled". Two calls rather than one long one because Cloudflare cuts requests at ~100 s. Questions stay open up to 7 days (default 10 min); the phone removes the notification at expiry by itself.
3
+ - **No database, restart-proof:** the requestId carries the question, buttons, target, expiry and asking client, signed with a key derived from `MCP_AUTH_SECRET`; each button's action id is signed separately and cannot be derived from the requestId. After a restart the server still recognises open questions and the taps on them. Only a tap during the restart itself is lost (the question then expires as NO).
4
+ - **Security:** a tap counts only with origin REMOTE and a user (the mobile app webhook), so an automation firing a fake `mobile_app_notification_action` cannot approve anything; tampered ids, extended expiries and swapped button indexes are rejected; only the asking token `sub` can read or cancel; at most 3 open questions and 30 per hour per client; first tap wins; late taps are ignored. Needs `NOTIFY`.
5
+ - **Plain text only:** question at most 256 characters (line breaks allowed), title 60, button labels 20. Text is NFC-normalized (Romanian diacritics are fine), then emoji, control and invisible characters (zero-width joiners, bidi overrides), variation selectors and stacked combining marks are refused with the offending character named — never silently stripped or cut.
6
+ - **`MCP_OWNER_QUESTIONS_HA_USERS`**: comma-separated HA user ids allowed to answer (the `answeredBy` of an answer); unset = any HA user, with a startup warning.
7
+ - Known limit, accepted: cancellations and answers are kept in memory only. After a restart, someone with a Home Assistant login could re-send an old button id through HA's API and answer a question that had been cancelled or denied (the phone no longer shows its buttons; that access already controls the house).
8
+ - Needs HTTP mode, an **admin** Home Assistant token (button taps are not on HA's non-admin event allowlist) and `MCP_AUTH_SECRET`. If Home Assistant refuses the tap subscription at startup, the three tools are not offered (logged); if it refuses later, `askOwner` returns an error saying so instead of sending questions nobody can answer. New `MCP_OWNER_QUESTIONS_ENABLED` (default `true`).
9
+ - **Fix — `getAutomationTrace` always answered "Not Found".** Home Assistant has no REST trace endpoint; traces exist only over the WebSocket (`trace/list`, admin-only) and are keyed by the automation's `id` attribute, not its entity id. The tool now uses a one-off WebSocket command, returns the newest runs first (new `limit`, default 5, max 20) with trigger, state and errors, and explains when an automation defined in YAML without an `id` has no traces.
10
+ - **Fix — Home Assistant events stopped after an HA restart.** The WebSocket client reconnected but never re-sent its subscriptions (HA forgets them with the connection), so `/subscribe_events` went silent after every HA restart. Subscriptions are now restored on reconnect; the client keeps retrying with the delay capped at 60 s instead of giving up after 10 attempts; commands are matched to their results by id (they were matched in arrival order); connecting has a deadline and fails at once if HA closes before authenticating; and a subscription made while Home Assistant is unreachable (also at server startup) is queued and sent once HA is back, instead of failing (an SSE client stays connected and receives events when HA returns; previously owner questions and SSE stayed unavailable until a server restart). Unsubscribing works while HA is offline (the subscription is forgotten for good, never restored on reconnect), and a subscription cancelled before HA confirmed it is cancelled on HA's side too. A subscription HA refuses (e.g. a non-admin token) is reported and dropped; one whose confirmation is lost is cancelled and retried (no duplicate subscriptions on HA); and an event HA sends right after confirming a subscription is no longer dropped. Errors from these WebSocket commands reach callers and logs as HA's error code only (e.g. `Home Assistant rejected 'trace/list' (unauthorized)`), never HA's free text or the internal address; the detail stays in the server log. The WebSocket connections now follow `HA_STRICT_SSL` like the REST client.
11
+ - **Fix — duplicate events.** With two subscriptions to the same event type, each received every event twice (HA sends one copy per subscription). Each copy now goes only to its own subscription.
12
+ - **Logging — tool arguments are logged at debug level only.** The info-level `Tool invoked` line now carries just the tool and session; the arguments (message and question text, entity names, MAC addresses) appear only with `LOG_LEVEL=debug`.
13
+ - README: `MCP_SSE_EVENTS_ENABLED` defaults to `true` (the README said `false`).
14
+ - +88 tests (owner question service and tools incl. a real MCP round trip and concurrent rate limits, plain-text validation, WebSocket client against a fake HA server incl. reconnects, deadlines and the TLS policy, trace lookup; the security checks are mutation-tested).
15
+
1
16
  ### 2.1.1
2
17
  - **Fix — `omada_setSiteNtpServers` was rejected by the controller on sites with daylight saving off.** Omada returns the DST start/end as empty objects when DST is off, but rejects them on write ("Daylight saving parameter [month] should not be null"). The site update now sends DST start/end only when complete; with DST on and an incomplete start/end it refuses to write. The rejected 2.1.0 call changed nothing (the controller validates before saving).
3
18
  - +2 tests (the live DST-off shape; DST on with an incomplete start/end).
package/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Home Assistant MCP Wrapper",
5
- "version": "2.1.1",
5
+ "version": "2.2.0",
6
6
  "description": "REST endpoints that wrap MCP tools for Home Assistant integration."
7
7
  },
8
8
  "servers": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffeerunhobby/mcp-ha-connect",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Smart home MCP server for Home Assistant plus Local AI integration",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/config.ts CHANGED
@@ -80,6 +80,8 @@ const envSchema = z
80
80
 
81
81
  // SSE Event Subscription Configuration
82
82
  sseEventsEnabled: createBooleanStringSchema(true),
83
+ ownerQuestionsEnabled: createBooleanStringSchema(true),
84
+ ownerQuestionsHaUsers: z.string().optional(),
83
85
  sseEventsPath: z.string().optional(),
84
86
 
85
87
  // Rate Limiting Configuration
@@ -230,6 +232,10 @@ export interface EnvironmentConfig {
230
232
 
231
233
  // SSE Event Subscription Configuration
232
234
  sseEventsEnabled: boolean;
235
+ /** askOwner / getOwnerAnswer / cancelOwnerQuestion (HTTP mode with Home Assistant). */
236
+ ownerQuestionsEnabled: boolean;
237
+ /** HA user ids allowed to answer owner questions; empty = any HA user. */
238
+ ownerQuestionsHaUsers: string[];
233
239
  sseEventsPath: string;
234
240
 
235
241
  // Rate Limiting Configuration
@@ -301,6 +307,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
301
307
 
302
308
  // SSE Event Subscription Configuration
303
309
  sseEventsEnabled: env.MCP_SSE_EVENTS_ENABLED,
310
+ ownerQuestionsEnabled: env.MCP_OWNER_QUESTIONS_ENABLED,
311
+ ownerQuestionsHaUsers: env.MCP_OWNER_QUESTIONS_HA_USERS,
304
312
  sseEventsPath: env.MCP_SSE_EVENTS_PATH,
305
313
 
306
314
  // Rate Limiting Configuration
@@ -401,6 +409,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
401
409
 
402
410
  // SSE Event Subscription Configuration
403
411
  sseEventsEnabled: parsed.data.sseEventsEnabled,
412
+ ownerQuestionsEnabled: parsed.data.ownerQuestionsEnabled,
413
+ ownerQuestionsHaUsers: (parsed.data.ownerQuestionsHaUsers ?? '').split(',').map((u) => u.trim()).filter(Boolean),
404
414
  sseEventsPath: parsed.data.sseEventsPath ?? '/subscribe_events',
405
415
 
406
416
  // Rate Limiting Configuration
@@ -8,6 +8,19 @@ import { logger } from '../utils/logger.js';
8
8
  import type { StateOperations } from './states.js';
9
9
  import type { ServiceOperations } from './services.js';
10
10
  import type { RequestHandler } from './request.js';
11
+ import type { HaWebSocketCommand } from './wsCommand.js';
12
+
13
+ /** One run of an automation, as `trace/list` summarizes it. */
14
+ export interface AutomationTraceSummary {
15
+ run_id: string;
16
+ state?: string;
17
+ script_execution?: string | null;
18
+ last_step?: string | null;
19
+ error?: string;
20
+ trigger?: string;
21
+ timestamp?: { start: string; finish: string | null };
22
+ [key: string]: unknown;
23
+ }
11
24
 
12
25
  /**
13
26
  * Automation-related operations for the Home Assistant API.
@@ -16,7 +29,8 @@ export class AutomationOperations {
16
29
  constructor(
17
30
  private readonly stateOps: StateOperations,
18
31
  private readonly serviceOps?: ServiceOperations,
19
- private readonly request?: RequestHandler
32
+ private readonly request?: RequestHandler,
33
+ private readonly wsCommand?: HaWebSocketCommand
20
34
  ) {}
21
35
 
22
36
  /**
@@ -216,14 +230,32 @@ export class AutomationOperations {
216
230
  /**
217
231
  * Get automation trace (execution history)
218
232
  */
219
- async getAutomationTrace(entityId: string): Promise<unknown[]> {
220
- if (!this.request) {
221
- throw new Error('RequestHandler not available');
233
+ async getAutomationTrace(entityId: string, limit = 5): Promise<AutomationTraceSummary[]> {
234
+ if (!this.wsCommand) {
235
+ throw new Error('Automation traces need the Home Assistant WebSocket API, which is not available');
222
236
  }
223
237
 
224
- const automationId = entityId.replace('automation.', '');
225
- logger.debug('Fetching automation trace', { automationId });
238
+ // Traces are WebSocket-only (there is no REST endpoint) and are keyed by the
239
+ // automation's config id (its `id` attribute), not by the entity id.
240
+ const state = await this.stateOps.getState(entityId);
241
+ if (!state) {
242
+ throw new Error(`Automation ${entityId} not found`);
243
+ }
244
+ const itemId = state.attributes.id;
245
+ if (itemId === undefined || itemId === null || itemId === '') {
246
+ throw new Error(
247
+ `Automation ${entityId} has no 'id' (an automation defined in YAML without an id), so Home Assistant keeps no traces for it`
248
+ );
249
+ }
250
+ logger.debug('Fetching automation traces', { entityId, itemId });
226
251
 
227
- return this.request.get<unknown[]>(`/trace/automation/${encodeURIComponent(automationId)}`);
252
+ const traces = await this.wsCommand<AutomationTraceSummary[]>({
253
+ type: 'trace/list',
254
+ domain: 'automation',
255
+ item_id: String(itemId),
256
+ });
257
+ return [...(traces ?? [])]
258
+ .sort((a, b) => (b.timestamp?.start ?? '').localeCompare(a.timestamp?.start ?? ''))
259
+ .slice(0, limit);
228
260
  }
229
261
  }
@@ -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();