@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.
- package/README.md +19 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/haClient/automations.d.ts +18 -2
- package/dist/haClient/automations.js +25 -7
- package/dist/haClient/automations.js.map +1 -1
- package/dist/haClient/events.d.ts +60 -7
- package/dist/haClient/events.js +252 -47
- package/dist/haClient/events.js.map +1 -1
- package/dist/haClient/index.d.ts +2 -1
- package/dist/haClient/index.js +4 -3
- package/dist/haClient/index.js.map +1 -1
- package/dist/haClient/wsCommand.d.ts +16 -0
- package/dist/haClient/wsCommand.js +36 -0
- package/dist/haClient/wsCommand.js.map +1 -0
- package/dist/ownerQuestions/service.d.ts +167 -0
- package/dist/ownerQuestions/service.js +439 -0
- package/dist/ownerQuestions/service.js.map +1 -0
- package/dist/server/common.d.ts +3 -0
- package/dist/server/common.js +2 -1
- package/dist/server/common.js.map +1 -1
- package/dist/server/http.js +30 -6
- package/dist/server/http.js.map +1 -1
- package/dist/server/stream.d.ts +2 -0
- package/dist/server/stream.js +2 -1
- package/dist/server/stream.js.map +1 -1
- package/dist/tools/common.d.ts +1 -1
- package/dist/tools/common.js +4 -1
- package/dist/tools/common.js.map +1 -1
- package/dist/tools/homeassistant/askOwner.d.ts +29 -0
- package/dist/tools/homeassistant/askOwner.js +85 -0
- package/dist/tools/homeassistant/askOwner.js.map +1 -0
- package/dist/tools/homeassistant/getAutomationTrace.d.ts +5 -0
- package/dist/tools/homeassistant/getAutomationTrace.js +10 -5
- package/dist/tools/homeassistant/getAutomationTrace.js.map +1 -1
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.js +5 -1
- package/dist/tools/index.js.map +1 -1
- package/dist/utils/plainText.d.ts +22 -0
- package/dist/utils/plainText.js +39 -0
- package/dist/utils/plainText.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CHANGELOG.md +15 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +10 -0
- package/src/haClient/automations.ts +39 -7
- package/src/haClient/events.ts +289 -56
- package/src/haClient/index.ts +10 -4
- package/src/haClient/wsCommand.ts +44 -0
- package/src/ownerQuestions/service.ts +536 -0
- package/src/server/common.ts +5 -1
- package/src/server/http.ts +30 -6
- package/src/server/stream.ts +4 -1
- package/src/tools/common.ts +4 -1
- package/src/tools/homeassistant/askOwner.ts +133 -0
- package/src/tools/homeassistant/getAutomationTrace.ts +13 -7
- package/src/tools/index.ts +8 -1
- package/src/utils/plainText.ts +48 -0
- package/src/version.ts +1 -1
- package/tests/config.test.ts +12 -0
- package/tests/haClient/automationTrace.test.ts +59 -0
- package/tests/haClient/automations.test.ts +16 -0
- package/tests/haClient/events.test.ts +455 -0
- package/tests/haClient/eventsTls.test.ts +52 -0
- package/tests/ownerQuestions/service.test.ts +499 -0
- package/tests/ownerQuestions/tools.test.ts +186 -0
- package/tests/tools/homeassistant/handlers.test.ts +10 -1
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
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
package/package.json
CHANGED
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<
|
|
220
|
-
if (!this.
|
|
221
|
-
throw new Error('
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/haClient/events.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
401
|
-
|
|
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();
|