@smarthome.exchange/api 0.1.0 → 1.0.1

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.
@@ -2,9 +2,63 @@ import * as plugins from './plugins.js';
2
2
 
3
3
  export interface IShxApiClientOptions {
4
4
  hubUrl?: string;
5
+ homeId: string;
6
+ authorizationHeaderProvider: TShxAuthorizationHeaderProvider;
7
+ socketConnectionPreparationTimeoutMs?: number;
5
8
  }
6
9
 
10
+ export interface IShxAuthorizationContext {
11
+ readonly homeId: string;
12
+ readonly endpoint: string;
13
+ readonly transport: 'http';
14
+ readonly method: plugins.shxInterfaces.request.IReq_ListDevices['method']
15
+ | plugins.shxInterfaces.request.IReq_ListAgents['method']
16
+ | plugins.shxInterfaces.request.IReq_ListTools['method']
17
+ | plugins.shxInterfaces.request.IReq_GetHomeSnapshot['method']
18
+ | plugins.shxInterfaces.request.IReq_ExecuteToolPlan['method']
19
+ | plugins.shxInterfaces.request.IReq_ListApprovals['method']
20
+ | plugins.shxInterfaces.request.IReq_SubmitApproval['method'];
21
+ readonly abortSignal?: AbortSignal;
22
+ }
23
+
24
+ export interface IShxSocketAuthorizationContext extends Omit<
25
+ IShxAuthorizationContext, 'method' | 'transport' | 'abortSignal'
26
+ > {
27
+ readonly method: 'connect';
28
+ readonly transport: 'websocket';
29
+ readonly abortSignal: AbortSignal;
30
+ readonly attempt: number;
31
+ readonly deadline: number;
32
+ }
33
+
34
+ export type TShxAuthorizationContext = IShxAuthorizationContext | IShxSocketAuthorizationContext;
35
+
36
+ export type TShxAuthorizationHeaderProvider = (
37
+ contextArg: TShxAuthorizationContext
38
+ ) => Promise<string>;
39
+
40
+ export interface IShxApiRequestOptions {
41
+ abortSignal?: AbortSignal;
42
+ timeoutMs?: number;
43
+ useCache?: boolean;
44
+ }
45
+
46
+ export type TListDevicesRequest = Omit<
47
+ plugins.shxInterfaces.request.IReq_ListDevices['request'], 'context'
48
+ >;
49
+ export type TListToolsRequest = Omit<
50
+ plugins.shxInterfaces.request.IReq_ListTools['request'], 'context'
51
+ >;
52
+ export type TListApprovalsRequest = Omit<
53
+ plugins.shxInterfaces.request.IReq_ListApprovals['request'], 'context'
54
+ >;
55
+ export type TSubmitApprovalRequest = Omit<
56
+ plugins.shxInterfaces.request.IReq_SubmitApproval['request'], 'context'
57
+ >;
58
+
7
59
  export interface IHomeSnapshotWatchOptions {
60
+ abortSignal?: AbortSignal;
61
+ timeoutMs?: number;
8
62
  intervalMs?: number;
9
63
  immediate?: boolean;
10
64
  onError?: (errorArg: unknown) => void | Promise<void>;
@@ -16,75 +70,377 @@ export type THomeSnapshotWatchCallback = (
16
70
 
17
71
  export type THomeSnapshotWatchStop = () => Promise<void>;
18
72
 
73
+ const maxLifecycleOperationCount = 64;
74
+
19
75
  export class SmarthomeExchangeApiClient {
20
- private hubUrl: string;
21
- public typedrouter = new plugins.typedrequest.TypedRouter();
22
- public typedsocketClient?: plugins.typedsocket.TypedSocket;
76
+ readonly #homeId: string;
77
+ readonly #httpEndpoint: string;
78
+ readonly #hubUrl: string;
79
+ readonly #socketConnectionPreparationTimeoutMs?: number;
80
+ readonly #authorizationHeaderProvider: TShxAuthorizationHeaderProvider;
81
+ private lifecycleTail = Promise.resolve();
82
+ private lifecycleOperationCount = 0;
83
+ private queuedLifecycleOperation?: {
84
+ target: 'started' | 'stopped';
85
+ promise: Promise<void>;
86
+ };
87
+ private stopIntentGeneration = 0;
88
+ private pendingStartAbortController?: AbortController;
89
+ private socketPendingStop?: plugins.typedsocket.TypedSocket;
90
+ private socketNeedsCleanup = false;
91
+ #typedsocketClient?: plugins.typedsocket.TypedSocket;
92
+ public readonly typedrouter = new plugins.typedrequest.TypedRouter();
23
93
 
24
- constructor(optionsArg: IShxApiClientOptions = {}) {
25
- this.hubUrl = (optionsArg.hubUrl || 'http://localhost:8080').replace(/\/$/, '');
94
+ constructor(optionsArg: IShxApiClientOptions) {
95
+ this.assertKnownProperties(
96
+ optionsArg, ['hubUrl', 'homeId', 'authorizationHeaderProvider', 'socketConnectionPreparationTimeoutMs'], 'Client options'
97
+ );
98
+ if (!optionsArg || typeof optionsArg.homeId !== 'string'
99
+ || optionsArg.homeId.length < 1 || optionsArg.homeId.length > 200
100
+ || optionsArg.homeId.trim() !== optionsArg.homeId
101
+ || typeof optionsArg.authorizationHeaderProvider !== 'function'
102
+ || (optionsArg.hubUrl !== undefined && typeof optionsArg.hubUrl !== 'string')) {
103
+ throw new TypeError('The API client requires a homeId and authorizationHeaderProvider.');
104
+ }
105
+ let hubUrl: URL;
106
+ try {
107
+ hubUrl = new URL(optionsArg.hubUrl ?? 'http://localhost:8080');
108
+ } catch {
109
+ throw new TypeError('hubUrl must be an absolute HTTP or HTTPS URL.');
110
+ }
111
+ if (!['http:', 'https:'].includes(hubUrl.protocol)
112
+ || hubUrl.username || hubUrl.password || hubUrl.search || hubUrl.hash) {
113
+ throw new TypeError('hubUrl must be an HTTP or HTTPS URL without credentials, query, or fragment.');
114
+ }
115
+ const preparationTimeoutMs = optionsArg.socketConnectionPreparationTimeoutMs;
116
+ if (preparationTimeoutMs !== undefined && (!Number.isInteger(preparationTimeoutMs)
117
+ || preparationTimeoutMs < 1
118
+ || preparationTimeoutMs > plugins.typedsocket.TYPEDSOCKET_MAX_CONNECTION_PREPARATION_TIMEOUT_MS)) {
119
+ throw new TypeError('Socket credential preparation requires a positive deadline no greater than 10000ms.');
120
+ }
121
+ this.#socketConnectionPreparationTimeoutMs = preparationTimeoutMs;
122
+ this.#hubUrl = hubUrl.href.replace(/\/$/, '');
123
+ hubUrl.pathname = `${hubUrl.pathname.replace(/\/+$/, '')}/typedrequest`;
124
+ this.#httpEndpoint = hubUrl.href;
125
+ this.#homeId = optionsArg.homeId;
126
+ this.#authorizationHeaderProvider = optionsArg.authorizationHeaderProvider;
26
127
  }
27
128
 
28
- private get httpEndpoint() {
29
- return `${this.hubUrl}/typedrequest`;
129
+ public get homeId(): string {
130
+ return this.#homeId;
30
131
  }
31
132
 
32
- private createWsRequest<T extends plugins.typedRequestInterfaces.ITypedRequest>(
33
- operationArg: T['method']
34
- ) {
35
- return this.typedsocketClient?.createTypedRequest<T>(operationArg);
133
+ public get typedsocketClient(): plugins.typedsocket.TypedSocket | undefined {
134
+ return this.#typedsocketClient;
36
135
  }
37
136
 
38
137
  private createHttpRequest<T extends plugins.typedRequestInterfaces.ITypedRequest>(
39
138
  operationArg: T['method']
40
139
  ) {
41
- return new plugins.typedrequest.TypedRequest<T>(this.httpEndpoint, operationArg);
140
+ return new plugins.typedrequest.TypedRequest<T>(this.#httpEndpoint, operationArg);
141
+ }
142
+
143
+ private captureRequestOptions(optionsArg: IShxApiRequestOptions): IShxApiRequestOptions {
144
+ this.assertKnownProperties(optionsArg, ['abortSignal', 'timeoutMs', 'useCache'], 'Request options');
145
+ const options = { ...optionsArg };
146
+ if ((options.abortSignal !== undefined && !(options.abortSignal instanceof AbortSignal))
147
+ || (options.useCache !== undefined && typeof options.useCache !== 'boolean')
148
+ || (options.timeoutMs !== undefined && (!Number.isInteger(options.timeoutMs)
149
+ || options.timeoutMs < 1 || options.timeoutMs > 2_147_483_647))) {
150
+ throw new TypeError('Request options require an AbortSignal, boolean cache option, and positive timer-safe deadline.');
151
+ }
152
+ return options;
42
153
  }
43
154
 
44
155
  private async fire<T extends plugins.typedRequestInterfaces.ITypedRequest>(
45
- operationArg: T['method'],
46
- payloadArg: T['request']
156
+ operationArg: IShxAuthorizationContext['method'] & T['method'],
157
+ requestPayloadArg: T['request'],
158
+ optionsArg: IShxApiRequestOptions = {}
47
159
  ): Promise<T['response']> {
48
- const wsRequest = this.createWsRequest<T>(operationArg);
49
- if (wsRequest) {
50
- return wsRequest.fire(payloadArg);
160
+ const requestOptions = this.captureRequestOptions(optionsArg);
161
+ requestOptions.abortSignal?.throwIfAborted();
162
+ if (this.#typedsocketClient) {
163
+ return this.#typedsocketClient.createTypedRequest<T>(operationArg, undefined, {
164
+ timeoutMs: requestOptions.timeoutMs,
165
+ }).fire(requestPayloadArg, {
166
+ ...requestOptions,
167
+ maxRetries: 0,
168
+ });
51
169
  }
52
- return this.createHttpRequest<T>(operationArg).fire(payloadArg);
170
+ const authorization = await this.getAuthorization(Object.freeze({
171
+ homeId: this.#homeId,
172
+ endpoint: this.#httpEndpoint,
173
+ method: operationArg,
174
+ transport: 'http',
175
+ abortSignal: requestOptions.abortSignal,
176
+ }));
177
+ requestOptions.abortSignal?.throwIfAborted();
178
+ return this.createHttpRequest<T>(operationArg).fire(requestPayloadArg, {
179
+ ...requestOptions,
180
+ maxRetries: 0,
181
+ headers: { authorization },
182
+ });
53
183
  }
54
184
 
55
- public async start() {
56
- this.typedsocketClient = await plugins.typedsocket.TypedSocket.createClient(
57
- this.typedrouter,
58
- this.hubUrl
59
- );
185
+ private async getAuthorization(contextArg: TShxAuthorizationContext): Promise<string> {
186
+ const signal = contextArg.abortSignal;
187
+ signal?.throwIfAborted();
188
+ let removeAbortHandler = () => {};
189
+ let authorization: string;
190
+ try {
191
+ authorization = await new Promise<string>((resolve, reject) => {
192
+ const onAbort = () => reject(signal?.reason);
193
+ signal?.addEventListener('abort', onAbort, { once: true });
194
+ removeAbortHandler = () => signal?.removeEventListener('abort', onAbort);
195
+ if (signal?.aborted) {
196
+ onAbort();
197
+ return;
198
+ }
199
+ void Promise.resolve(this.#authorizationHeaderProvider(contextArg)).then(resolve, reject);
200
+ });
201
+ } finally {
202
+ removeAbortHandler();
203
+ }
204
+ if (typeof authorization !== 'string' || authorization.trim().length === 0) {
205
+ throw new TypeError('authorizationHeaderProvider must return a nonempty Authorization header.');
206
+ }
207
+ return authorization;
208
+ }
209
+
210
+ private async prepareSocketConnection(
211
+ contextArg: plugins.typedsocket.ITypedSocketConnectionPreparationContext
212
+ ): Promise<plugins.typedsocket.ITypedSocketPreparedConnectionOptions> {
213
+ if (!contextArg.supportsHeaders) {
214
+ throw new plugins.typedsocket.TypedSocketConnectionPreparationError('unsupported-headers');
215
+ }
216
+ const authorization = await this.getAuthorization(Object.freeze({
217
+ homeId: this.#homeId,
218
+ endpoint: contextArg.url,
219
+ method: 'connect',
220
+ transport: 'websocket',
221
+ abortSignal: contextArg.abortSignal,
222
+ attempt: contextArg.attempt,
223
+ deadline: contextArg.deadline,
224
+ }));
225
+ contextArg.abortSignal.throwIfAborted();
226
+ return { headers: { authorization } };
227
+ }
228
+
229
+ private prepareRequest<T extends object>(
230
+ payloadArg: T, allowedKeysArg: readonly string[], omitUndefinedFiltersArg = false
231
+ ) {
232
+ this.assertKnownProperties(payloadArg, allowedKeysArg, 'Request');
233
+ const normalizedPayload = { ...payloadArg };
234
+ if (omitUndefinedFiltersArg) {
235
+ for (const key of allowedKeysArg) {
236
+ if (Object.getOwnPropertyDescriptor(normalizedPayload, key)?.value === undefined) {
237
+ Reflect.deleteProperty(normalizedPayload, key);
238
+ }
239
+ }
240
+ }
241
+ this.assertJsonData(normalizedPayload);
242
+ return { ...structuredClone(normalizedPayload), context: { homeId: this.#homeId } };
243
+ }
244
+
245
+ private assertJsonData(valueArg: unknown): void {
246
+ const activeObjects = new WeakSet<object>();
247
+ const pending: { value: unknown; exiting?: boolean }[] = [{ value: valueArg }];
248
+ while (pending.length > 0) {
249
+ const entry = pending.pop();
250
+ if (!entry) continue;
251
+ const value = entry.value;
252
+ if (entry.exiting) {
253
+ if (value && typeof value === 'object') activeObjects.delete(value);
254
+ continue;
255
+ }
256
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') continue;
257
+ if (typeof value === 'number' && Number.isFinite(value)) continue;
258
+ if (!value || typeof value !== 'object' || activeObjects.has(value)) {
259
+ throw new TypeError('Request payload must contain finite, acyclic JSON data.');
260
+ }
261
+ const isArray = Array.isArray(value);
262
+ if (!isArray && ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
263
+ throw new TypeError('Request payload must contain plain JSON objects.');
264
+ }
265
+ const keys = Reflect.ownKeys(value);
266
+ if (isArray && keys.length !== value.length + 1) {
267
+ throw new TypeError('Request payload arrays must contain only populated indices.');
268
+ }
269
+ activeObjects.add(value);
270
+ pending.push({ value, exiting: true });
271
+ for (const key of keys) {
272
+ if (isArray && key === 'length') continue;
273
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
274
+ if (typeof key !== 'string' || !descriptor || !('value' in descriptor)
275
+ || !descriptor.enumerable
276
+ || (isArray && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= value.length))) {
277
+ throw new TypeError('Request payload cannot contain accessors or unsupported properties.');
278
+ }
279
+ pending.push({ value: descriptor.value });
280
+ }
281
+ }
282
+ }
283
+
284
+ private assertKnownProperties(valueArg: unknown, allowedKeysArg: readonly string[], labelArg: string) {
285
+ if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)
286
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(valueArg))) {
287
+ throw new TypeError(`${labelArg} must be a plain object.`);
288
+ }
289
+ for (const key of Reflect.ownKeys(valueArg)) {
290
+ const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
291
+ if (typeof key !== 'string' || !allowedKeysArg.includes(key)
292
+ || !descriptor || !('value' in descriptor) || !descriptor.enumerable) {
293
+ throw new TypeError(`${labelArg} contains unsupported properties.`);
294
+ }
295
+ }
296
+ }
297
+
298
+ private queueLifecycleOperation(targetArg: 'started' | 'stopped'): Promise<void> {
299
+ if (this.queuedLifecycleOperation?.target === targetArg) {
300
+ return this.queuedLifecycleOperation.promise;
301
+ }
302
+
303
+ if (this.lifecycleOperationCount >= maxLifecycleOperationCount) {
304
+ return Promise.reject(
305
+ new Error(
306
+ `SmarthomeExchangeApiClient lifecycle queue is limited to ${maxLifecycleOperationCount} active and queued operations.`
307
+ )
308
+ );
309
+ }
310
+
311
+ const startStopIntentGeneration = this.stopIntentGeneration;
312
+ if (targetArg === 'stopped') {
313
+ this.stopIntentGeneration++;
314
+ this.pendingStartAbortController?.abort();
315
+ }
316
+
317
+ this.lifecycleOperationCount++;
318
+ const operationPromise = this.lifecycleTail.then(async () => {
319
+ if (targetArg === 'started') {
320
+ if (this.stopIntentGeneration !== startStopIntentGeneration) {
321
+ return;
322
+ }
323
+ if (this.socketNeedsCleanup) {
324
+ throw new Error(
325
+ 'Cannot start while the existing TypedSocket requires successful cleanup.'
326
+ );
327
+ }
328
+ if (!this.#typedsocketClient) {
329
+ const startAbortController = new AbortController();
330
+ this.pendingStartAbortController = startAbortController;
331
+ try {
332
+ const createdSocket = await plugins.typedsocket.TypedSocket.createClient(
333
+ this.typedrouter,
334
+ this.#hubUrl,
335
+ {
336
+ abortSignal: startAbortController.signal,
337
+ autoReconnect: false,
338
+ prepareConnection: (contextArg) => this.prepareSocketConnection(contextArg),
339
+ connectionPreparationTimeoutMs: this.#socketConnectionPreparationTimeoutMs,
340
+ }
341
+ );
342
+ if (this.stopIntentGeneration !== startStopIntentGeneration) {
343
+ this.socketPendingStop = createdSocket;
344
+ } else {
345
+ this.#typedsocketClient = createdSocket;
346
+ }
347
+ } catch (error) {
348
+ if (
349
+ startAbortController.signal.aborted &&
350
+ this.stopIntentGeneration !== startStopIntentGeneration &&
351
+ error instanceof plugins.typedsocket.TypedSocketClientStartupAbortedError
352
+ ) {
353
+ return;
354
+ }
355
+ throw error;
356
+ } finally {
357
+ if (this.pendingStartAbortController === startAbortController) {
358
+ this.pendingStartAbortController = undefined;
359
+ }
360
+ }
361
+ }
362
+ return;
363
+ }
364
+
365
+ const socketToStop = this.socketPendingStop ?? this.#typedsocketClient;
366
+ if (!socketToStop) {
367
+ return;
368
+ }
369
+ try {
370
+ await socketToStop.stop();
371
+ } catch (error) {
372
+ this.socketNeedsCleanup = true;
373
+ throw error;
374
+ }
375
+ if (this.socketPendingStop === socketToStop) {
376
+ this.socketPendingStop = undefined;
377
+ }
378
+ if (this.#typedsocketClient === socketToStop) {
379
+ this.#typedsocketClient = undefined;
380
+ }
381
+ this.socketNeedsCleanup = false;
382
+ });
383
+ const lifecycleOperation = {
384
+ target: targetArg,
385
+ promise: operationPromise,
386
+ };
387
+ this.queuedLifecycleOperation = lifecycleOperation;
388
+ this.lifecycleTail = operationPromise.catch(() => undefined);
389
+ const finishOperation = () => {
390
+ this.lifecycleOperationCount--;
391
+ if (this.queuedLifecycleOperation === lifecycleOperation) {
392
+ this.queuedLifecycleOperation = undefined;
393
+ }
394
+ };
395
+ void operationPromise.then(finishOperation, finishOperation);
396
+ return operationPromise;
397
+ }
398
+
399
+ public start(): Promise<void> {
400
+ return this.queueLifecycleOperation('started');
60
401
  }
61
402
 
62
- public async stop() {
63
- await this.typedsocketClient?.stop();
64
- this.typedsocketClient = undefined;
403
+ public stop(): Promise<void> {
404
+ return this.queueLifecycleOperation('stopped');
65
405
  }
66
406
 
67
- public async listDevices(requestArg: plugins.shxInterfaces.request.IReq_ListDevices['request'] = {}) {
68
- return this.fire<plugins.shxInterfaces.request.IReq_ListDevices>('listDevices', requestArg);
407
+ public async listDevices(requestArg: TListDevicesRequest = {}, optionsArg?: IShxApiRequestOptions) {
408
+ return this.fire<plugins.shxInterfaces.request.IReq_ListDevices>(
409
+ 'listDevices', this.prepareRequest(requestArg, ['room', 'capability'], true), optionsArg
410
+ );
69
411
  }
70
412
 
71
- public async listAgents() {
72
- return this.fire<plugins.shxInterfaces.request.IReq_ListAgents>('listAgents', {});
413
+ public async listAgents(optionsArg?: IShxApiRequestOptions) {
414
+ return this.fire<plugins.shxInterfaces.request.IReq_ListAgents>('listAgents', this.prepareRequest({}, []), optionsArg);
73
415
  }
74
416
 
75
- public async listTools(requestArg: plugins.shxInterfaces.request.IReq_ListTools['request'] = {}) {
76
- return this.fire<plugins.shxInterfaces.request.IReq_ListTools>('listTools', requestArg);
417
+ public async listTools(requestArg: TListToolsRequest = {}, optionsArg?: IShxApiRequestOptions) {
418
+ return this.fire<plugins.shxInterfaces.request.IReq_ListTools>(
419
+ 'listTools', this.prepareRequest(requestArg, ['ownerId'], true), optionsArg
420
+ );
77
421
  }
78
422
 
79
- public async getHomeSnapshot() {
80
- return this.fire<plugins.shxInterfaces.request.IReq_GetHomeSnapshot>('getHomeSnapshot', {});
423
+ public async getHomeSnapshot(optionsArg?: IShxApiRequestOptions) {
424
+ return this.fire<plugins.shxInterfaces.request.IReq_GetHomeSnapshot>(
425
+ 'getHomeSnapshot', this.prepareRequest({}, []), optionsArg
426
+ );
81
427
  }
82
428
 
83
429
  public watchHomeSnapshot(
84
430
  callbackArg: THomeSnapshotWatchCallback,
85
431
  optionsArg: IHomeSnapshotWatchOptions = {}
86
432
  ): THomeSnapshotWatchStop {
87
- const intervalMs = optionsArg.intervalMs ?? 5000;
433
+ this.assertKnownProperties(
434
+ optionsArg, ['abortSignal', 'timeoutMs', 'intervalMs', 'immediate', 'onError'], 'Watcher options'
435
+ );
436
+ const options = { ...optionsArg };
437
+ if (typeof callbackArg !== 'function'
438
+ || (options.immediate !== undefined && typeof options.immediate !== 'boolean')
439
+ || (options.onError !== undefined && typeof options.onError !== 'function')) {
440
+ throw new TypeError('Snapshot watchers require a callback, boolean immediate option, and optional error handler.');
441
+ }
442
+ this.captureRequestOptions({ abortSignal: options.abortSignal, timeoutMs: options.timeoutMs });
443
+ const intervalMs = options.intervalMs ?? 5000;
88
444
  if (!Number.isInteger(intervalMs) || intervalMs < 1 || intervalMs > 2_147_483_647) {
89
445
  throw new Error(
90
446
  'watchHomeSnapshot intervalMs must be an integer from 1 through 2147483647.'
@@ -94,19 +450,31 @@ export class SmarthomeExchangeApiClient {
94
450
  let stopped = false;
95
451
  let timeout: ReturnType<typeof setTimeout> | undefined;
96
452
  let activePoll: Promise<void> | undefined;
453
+ let activeRequestController: AbortController | undefined;
97
454
  let userHandlerActive = false;
98
455
 
99
456
  const clearScheduledPoll = () => {
100
- if (timeout) {
457
+ if (timeout !== undefined) {
101
458
  clearTimeout(timeout);
102
459
  timeout = undefined;
103
460
  }
104
461
  };
105
462
 
463
+ const stopForReason = async (reasonArg?: unknown) => {
464
+ stopped = true;
465
+ clearScheduledPoll();
466
+ options.abortSignal?.removeEventListener('abort', onExternalAbort);
467
+ activeRequestController?.abort(reasonArg);
468
+ const pollToWait = userHandlerActive ? undefined : activePoll;
469
+ await pollToWait;
470
+ };
471
+
472
+ const onExternalAbort = () => {
473
+ void stopForReason(options.abortSignal?.reason);
474
+ };
475
+
106
476
  const scheduleNextPoll = () => {
107
- if (stopped) {
108
- return;
109
- }
477
+ if (stopped) return;
110
478
  timeout = setTimeout(() => {
111
479
  timeout = undefined;
112
480
  activePoll = runPoll();
@@ -114,13 +482,11 @@ export class SmarthomeExchangeApiClient {
114
482
  };
115
483
 
116
484
  const handlePollError = async (errorArg: unknown) => {
117
- if (stopped) {
118
- return;
119
- }
120
- if (optionsArg.onError) {
485
+ if (stopped) return;
486
+ if (options.onError) {
121
487
  userHandlerActive = true;
122
488
  try {
123
- await optionsArg.onError(errorArg);
489
+ await options.onError(errorArg);
124
490
  } catch (errorHandlerError) {
125
491
  console.error('watchHomeSnapshot error handler failed:', errorHandlerError);
126
492
  } finally {
@@ -132,8 +498,13 @@ export class SmarthomeExchangeApiClient {
132
498
  };
133
499
 
134
500
  const runPoll = async () => {
501
+ const controller = new AbortController();
502
+ activeRequestController = controller;
135
503
  try {
136
- const snapshotResponse = await this.getHomeSnapshot();
504
+ const snapshotResponse = await this.getHomeSnapshot({
505
+ abortSignal: controller.signal,
506
+ timeoutMs: options.timeoutMs,
507
+ });
137
508
  if (!stopped) {
138
509
  userHandlerActive = true;
139
510
  try {
@@ -145,36 +516,49 @@ export class SmarthomeExchangeApiClient {
145
516
  } catch (error) {
146
517
  await handlePollError(error);
147
518
  } finally {
519
+ controller.abort();
520
+ if (activeRequestController === controller) activeRequestController = undefined;
148
521
  activePoll = undefined;
149
522
  scheduleNextPoll();
150
523
  }
151
524
  };
152
525
 
153
- if (optionsArg.immediate === false) {
526
+ options.abortSignal?.addEventListener('abort', onExternalAbort, { once: true });
527
+ if (options.abortSignal?.aborted) {
528
+ stopped = true;
529
+ options.abortSignal.removeEventListener('abort', onExternalAbort);
530
+ } else if (options.immediate === false) {
154
531
  scheduleNextPoll();
155
532
  } else {
156
533
  activePoll = runPoll();
157
534
  }
158
535
 
159
- return async () => {
160
- stopped = true;
161
- clearScheduledPoll();
162
- const pollToWait = userHandlerActive ? undefined : activePoll;
163
- await pollToWait;
164
- };
536
+ return () => stopForReason();
165
537
  }
166
538
 
167
- public async executeToolPlan(planArg: plugins.shxInterfaces.data.IToolPlan) {
168
- return this.fire<plugins.shxInterfaces.request.IReq_ExecuteToolPlan>('executeToolPlan', {
169
- plan: planArg,
170
- });
539
+ public async executeToolPlan(planArg: plugins.shxInterfaces.data.IToolPlan, optionsArg?: IShxApiRequestOptions) {
540
+ const request = this.prepareRequest({ plan: planArg }, ['plan']);
541
+ this.assertKnownProperties(
542
+ request.plan, ['id', 'callerId', 'title', 'reason', 'confidence', 'calls'], 'Tool plan'
543
+ );
544
+ if (!Array.isArray(request.plan.calls)) throw new TypeError('Tool plan calls must be an array.');
545
+ for (const call of request.plan.calls) {
546
+ this.assertKnownProperties(call, ['id', 'toolId', 'callerId', 'input'], 'Tool call');
547
+ }
548
+ return this.fire<plugins.shxInterfaces.request.IReq_ExecuteToolPlan>(
549
+ 'executeToolPlan', request, optionsArg
550
+ );
171
551
  }
172
552
 
173
- public async listApprovals(requestArg: plugins.shxInterfaces.request.IReq_ListApprovals['request'] = {}) {
174
- return this.fire<plugins.shxInterfaces.request.IReq_ListApprovals>('listApprovals', requestArg);
553
+ public async listApprovals(requestArg: TListApprovalsRequest = {}, optionsArg?: IShxApiRequestOptions) {
554
+ return this.fire<plugins.shxInterfaces.request.IReq_ListApprovals>(
555
+ 'listApprovals', this.prepareRequest(requestArg, ['status'], true), optionsArg
556
+ );
175
557
  }
176
558
 
177
- public async submitApproval(requestArg: plugins.shxInterfaces.request.IReq_SubmitApproval['request']) {
178
- return this.fire<plugins.shxInterfaces.request.IReq_SubmitApproval>('submitApproval', requestArg);
559
+ public async submitApproval(requestArg: TSubmitApprovalRequest, optionsArg?: IShxApiRequestOptions) {
560
+ return this.fire<plugins.shxInterfaces.request.IReq_SubmitApproval>(
561
+ 'submitApproval', this.prepareRequest(requestArg, ['approvalId', 'expectedActionDigest', 'decision']), optionsArg
562
+ );
179
563
  }
180
564
  }