@smarthome.exchange/api 0.1.0 → 1.0.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/changelog.md +25 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.smarthomeexchangeapiclient.d.ts +56 -13
- package/dist_ts/classes.smarthomeexchangeapiclient.js +351 -52
- package/package.json +10 -9
- package/readme.md +68 -11
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.smarthomeexchangeapiclient.ts +444 -60
|
@@ -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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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.
|
|
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
|
-
|
|
29
|
-
return
|
|
129
|
+
public get homeId(): string {
|
|
130
|
+
return this.#homeId;
|
|
30
131
|
}
|
|
31
132
|
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
156
|
+
operationArg: IShxAuthorizationContext['method'] & T['method'],
|
|
157
|
+
requestPayloadArg: T['request'],
|
|
158
|
+
optionsArg: IShxApiRequestOptions = {}
|
|
47
159
|
): Promise<T['response']> {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
this.typedsocketClient = undefined;
|
|
403
|
+
public stop(): Promise<void> {
|
|
404
|
+
return this.queueLifecycleOperation('stopped');
|
|
65
405
|
}
|
|
66
406
|
|
|
67
|
-
public async listDevices(requestArg:
|
|
68
|
-
return this.fire<plugins.shxInterfaces.request.IReq_ListDevices>(
|
|
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:
|
|
76
|
-
return this.fire<plugins.shxInterfaces.request.IReq_ListTools>(
|
|
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>(
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
}
|
|
120
|
-
if (optionsArg.onError) {
|
|
485
|
+
if (stopped) return;
|
|
486
|
+
if (options.onError) {
|
|
121
487
|
userHandlerActive = true;
|
|
122
488
|
try {
|
|
123
|
-
await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
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:
|
|
174
|
-
return this.fire<plugins.shxInterfaces.request.IReq_ListApprovals>(
|
|
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:
|
|
178
|
-
return this.fire<plugins.shxInterfaces.request.IReq_SubmitApproval>(
|
|
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
|
}
|