@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.
- 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/omadaClient/site.js +15 -2
- package/dist/omadaClient/site.js.map +1 -1
- 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 +19 -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/omadaClient/site.ts +17 -2
- 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/omadaClient/ntp.test.ts +15 -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/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();
|
package/src/haClient/index.ts
CHANGED
|
@@ -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(
|
|
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<
|
|
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
|
+
}
|
package/src/omadaClient/site.ts
CHANGED
|
@@ -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
|
-
|
|
136
|
-
|
|
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.
|