@smarthome.exchange/integrations 3.0.0 → 4.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 +11 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/integrations/hue/hue.classes.client.d.ts +26 -3
- package/dist_ts/integrations/hue/hue.classes.client.js +254 -55
- package/dist_ts/integrations/hue/hue.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/hue/hue.classes.configflow.js +73 -30
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +2 -2
- package/dist_ts/integrations/hue/hue.classes.integration.js +58 -110
- package/dist_ts/integrations/hue/hue.mapper.d.ts +2 -0
- package/dist_ts/integrations/hue/hue.mapper.js +8 -6
- package/dist_ts/integrations/hue/hue.types.d.ts +32 -1
- package/dist_ts/integrations/hue/hue.validation.d.ts +8 -3
- package/dist_ts/integrations/hue/hue.validation.js +100 -9
- package/package.json +3 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/integrations/hue/hue.classes.client.ts +213 -49
- package/ts/integrations/hue/hue.classes.configflow.ts +64 -27
- package/ts/integrations/hue/hue.classes.integration.ts +52 -97
- package/ts/integrations/hue/hue.mapper.ts +8 -5
- package/ts/integrations/hue/hue.types.ts +37 -1
- package/ts/integrations/hue/hue.validation.ts +89 -10
- package/ts/integrations/hue/readme.md +48 -11
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { NoSqlConfigStore } from '../../core/classes.configstore.js';
|
|
3
|
+
import { NativeIntegrationOwner, type INativeOperationLease } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
5
|
+
import type { IIntegrationSetupContext } from '../../core/types.js';
|
|
6
|
+
import type { IHueConfig, IHueEvent, IHueLightStatePatch, IHueResourceResponse, IHueCredentialRequest, IHueIntegrationOptions } from './hue.types.js';
|
|
7
|
+
import { captureHueOptions, hueContentType, hueData, hueKey, hueOrigin, hueRecord, hueUuid, normalizeHueConfig, parseHueEnvelope, parseHueEvents, parseHueResources, validateHuePatch } from './hue.validation.js';
|
|
4
8
|
|
|
5
9
|
interface IHueConnection {
|
|
6
10
|
cancel(): void;
|
|
@@ -44,8 +48,30 @@ class HueEventDecoder {
|
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
export class HuePairingError extends Error {
|
|
52
|
+
constructor(public readonly linkButtonRequired = false) { super(linkButtonRequired ? 'Press the Hue bridge link button, then retry.' : 'Hue pairing or credential creation was dispatched; do not replay it.'); }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface IHueOperation {
|
|
56
|
+
readonly lease: INativeOperationLease;
|
|
57
|
+
readonly signal: AbortSignal;
|
|
58
|
+
readonly deadline: number;
|
|
59
|
+
readonly actual: Set<Promise<unknown>>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface IHueClientOperation {
|
|
63
|
+
getResources(signalArg?: AbortSignal): Promise<IHueResourceResponse>;
|
|
64
|
+
setLightState(idArg: string, stateArg: IHueLightStatePatch, signalArg?: AbortSignal): Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
47
67
|
export class HueClient {
|
|
48
68
|
readonly #config: ReturnType<typeof normalizeHueConfig>;
|
|
69
|
+
readonly #context: INativeAdmissionContext;
|
|
70
|
+
readonly #options: Readonly<IHueIntegrationOptions>;
|
|
71
|
+
readonly #owner: NativeIntegrationOwner;
|
|
72
|
+
readonly #ownsOwner: boolean;
|
|
73
|
+
readonly #store?: NoSqlConfigStore;
|
|
74
|
+
readonly #ownsStore: boolean;
|
|
49
75
|
readonly #agent: plugins.https.Agent;
|
|
50
76
|
readonly #lifetime = new AbortController();
|
|
51
77
|
readonly #connections = new Set<IHueConnection>();
|
|
@@ -58,69 +84,162 @@ export class HueClient {
|
|
|
58
84
|
#healthy = false;
|
|
59
85
|
#eventFailure = false;
|
|
60
86
|
|
|
61
|
-
constructor(configArg: IHueConfig,
|
|
87
|
+
constructor(configArg: IHueConfig, contextArg: IIntegrationSetupContext, optionsArg: IHueIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
|
|
62
88
|
this.#config = normalizeHueConfig(configArg);
|
|
89
|
+
this.#options = captureHueOptions(optionsArg);
|
|
90
|
+
this.#ownsOwner = !ownerArg;
|
|
91
|
+
this.#owner = ownerArg ?? new NativeIntegrationOwner('hue', { homeId: this.#options.homeId, capacity: this.#options.capacity });
|
|
92
|
+
this.#context = this.#owner.admission.admit(contextArg, this.#config.homeId);
|
|
93
|
+
if (this.#options.homeId !== undefined && this.#options.homeId !== this.#context.homeId) throw new Error('Hue constructor home does not match the host home.');
|
|
94
|
+
if (this.#context.configStore && this.#options.db) throw new Error('Hue requires one selected credential store.');
|
|
95
|
+
if (this.#options.db && !(this.#options.db instanceof plugins.nosqldb.SmartdataDb)) throw new TypeError('Hue requires an actual caller-owned database.');
|
|
96
|
+
this.#ownsStore = !this.#context.configStore && Boolean(this.#options.db);
|
|
97
|
+
this.#store = this.#context.configStore ?? (this.#options.db && this.#options.keyProvider ? new NoSqlConfigStore({ db: this.#options.db, keyProvider: this.#options.keyProvider, homeId: this.#context.homeId, integrationDomain: 'hue', timeoutMs: this.#config.requestTimeoutMs }) : undefined);
|
|
98
|
+
if (!this.#options.credentialProvider && !this.#store) throw new Error('Hue requires an explicit credential provider or an actual encrypted config store.');
|
|
63
99
|
this.#agent = new plugins.https.Agent({ keepAlive: false, maxSockets: 3, maxTotalSockets: 3 });
|
|
64
|
-
|
|
65
|
-
else setupSignal?.addEventListener('abort', this.abortSetup, { once: true });
|
|
100
|
+
this.#context.abortSignal?.addEventListener('abort', this.abortSetup, { once: true });
|
|
66
101
|
}
|
|
67
102
|
|
|
103
|
+
public get canCreateCredential(): boolean { return Boolean(this.#options.credentialProvider?.create || !this.#options.credentialProvider && this.#store); }
|
|
104
|
+
|
|
68
105
|
public isHealthy(): boolean {
|
|
69
|
-
return !this.#lifetime.signal.aborted && this.#healthy && !this.#eventFailure && this.#callbackFailures.size === 0;
|
|
106
|
+
return !this.#destroyPromise && !this.#lifetime.signal.aborted && !this.#owner.signal.aborted && !this.#context.abortSignal?.aborted && this.#healthy && !this.#eventFailure && this.#callbackFailures.size === 0;
|
|
70
107
|
}
|
|
71
108
|
|
|
72
109
|
public getResources(abortSignalArg?: AbortSignal): Promise<IHueResourceResponse> {
|
|
73
|
-
return this.run(() => this.readResources(
|
|
110
|
+
return this.run((operation) => this.readResources(operation), abortSignalArg);
|
|
74
111
|
}
|
|
75
112
|
|
|
76
|
-
private async readResources(
|
|
113
|
+
private async readResources(operationArg: IHueOperation, keyArg?: string): Promise<IHueResourceResponse> {
|
|
77
114
|
try {
|
|
78
|
-
const
|
|
79
|
-
const
|
|
115
|
+
const key = keyArg ?? await this.credential(operationArg);
|
|
116
|
+
const data = parseHueEnvelope(await this.request('GET', '/clip/v2/resource', undefined, operationArg.signal, key));
|
|
117
|
+
this.active(operationArg);
|
|
118
|
+
const resources = parseHueResources(data, this.#config.bridgeId, this.#context.homeId);
|
|
80
119
|
this.#healthy = true;
|
|
81
120
|
return resources;
|
|
82
121
|
} catch (error) { this.#healthy = false; throw error; }
|
|
83
122
|
}
|
|
84
123
|
|
|
85
|
-
public
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
124
|
+
public setLightState(lightIdArg: string, stateArg: IHueLightStatePatch, abortSignalArg?: AbortSignal): Promise<void> {
|
|
125
|
+
const { lightId, state } = this.captureWrite(lightIdArg, stateArg);
|
|
126
|
+
return this.run((operation) => this.write(operation, lightId, state), abortSignalArg);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A captured runtime command shares one operation lease, provider deadline and one write attempt. */
|
|
130
|
+
public execute<TResult>(workArg: (clientArg: IHueClientOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
|
|
131
|
+
return this.run(async (operation) => {
|
|
132
|
+
let open = true;
|
|
133
|
+
let written = false;
|
|
134
|
+
const check = (signal?: AbortSignal): void => { if (!open) throw new Error('Hue command session is closed.'); signal?.throwIfAborted(); this.active(operation); };
|
|
135
|
+
const session: IHueClientOperation = Object.freeze({
|
|
136
|
+
getResources: async (signal?: AbortSignal) => { check(signal); return this.readResources(operation); },
|
|
137
|
+
setLightState: async (id: string, patch: IHueLightStatePatch, signal?: AbortSignal) => {
|
|
138
|
+
check(signal);
|
|
139
|
+
if (written) throw new Error('Hue command permits one write attempt.');
|
|
140
|
+
const { lightId, state } = this.captureWrite(id, patch);
|
|
141
|
+
written = true;
|
|
142
|
+
await this.write(operation, lightId, state);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
try { return await this.foreign(operation, () => workArg(session)); }
|
|
146
|
+
finally { open = false; }
|
|
147
|
+
}, signalArg);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private captureWrite(idArg: string, patchArg: IHueLightStatePatch): { lightId: string; state: IHueLightStatePatch } {
|
|
151
|
+
if (!hueUuid(idArg)) throw new Error('Hue light target must be a resource UUID.');
|
|
152
|
+
const captured = hueRecord(patchArg);
|
|
89
153
|
validateHuePatch(captured as IHueLightStatePatch);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
154
|
+
return { lightId: idArg.toLowerCase(), state: { ...(captured.on === undefined ? {} : { on: captured.on as boolean }), ...(captured.brightness === undefined ? {} : { brightness: captured.brightness as number }) } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async write(operationArg: IHueOperation, lightId: string, state: IHueLightStatePatch): Promise<void> {
|
|
158
|
+
this.permission('lights', lightId, Object.keys(state));
|
|
159
|
+
const key = await this.credential(operationArg);
|
|
160
|
+
const snapshot = await this.readResources(operationArg, key);
|
|
161
|
+
const light = snapshot.lights.find((lightArg) => lightArg.id === lightId);
|
|
162
|
+
if (!light || !light.available) throw new Error('Hue light target is unknown or unavailable.');
|
|
163
|
+
if (state.brightness !== undefined && !light.dimming) throw new Error('Hue light does not support brightness.');
|
|
164
|
+
const body = { ...(state.on === undefined ? {} : { on: { on: state.on } }), ...(state.brightness === undefined ? {} : { dimming: { brightness: state.brightness } }) };
|
|
165
|
+
this.active(operationArg);
|
|
166
|
+
const data = parseHueEnvelope(await this.request('PUT', `/clip/v2/resource/light/${lightId}`, body, operationArg.signal, key));
|
|
167
|
+
if (data.length !== 1) throw new Error('Hue write acknowledgement does not identify the target light.');
|
|
168
|
+
const acknowledgement = hueRecord(data[0]);
|
|
169
|
+
if (typeof acknowledgement.rid !== 'string' || acknowledgement.rid.toLowerCase() !== lightId || acknowledgement.rtype !== 'light') throw new Error('Hue write acknowledgement does not identify the target light.');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
public acquireAppKey(deviceTypeArg = 'smarthome.exchange', signalArg?: AbortSignal): Promise<string> {
|
|
173
|
+
if (typeof deviceTypeArg !== 'string' || !/^[a-z0-9_.#-]{1,40}$/i.test(deviceTypeArg)) throw new Error('Invalid Hue pairing client name.');
|
|
174
|
+
return this.run(async (operation) => {
|
|
175
|
+
this.permission('config', this.#config.bridgeId, ['pair', 'credentials:create']);
|
|
176
|
+
if (!this.canCreateCredential) throw new Error('Hue pairing requires credential creation by the selected provider or encrypted store.');
|
|
177
|
+
let dispatched = false;
|
|
178
|
+
try {
|
|
179
|
+
const result = await this.request('POST', '/api', { devicetype: deviceTypeArg }, operation.signal, undefined, () => { dispatched = true; });
|
|
180
|
+
if (!Array.isArray(result) || result.length !== 1) throw new Error('Invalid Hue pairing acknowledgement.');
|
|
181
|
+
const entry = hueRecord(result[0]);
|
|
182
|
+
if (entry.error !== undefined && hueRecord(entry.error).type === 101) throw new HuePairingError(true);
|
|
183
|
+
return hueKey(hueRecord(entry.success).username);
|
|
184
|
+
} catch (error) { if (error instanceof HuePairingError && error.linkButtonRequired) throw error; if (dispatched) throw new HuePairingError(); throw error; }
|
|
185
|
+
}, signalArg);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
public createCredential(appKeyArg: string, signalArg?: AbortSignal): Promise<void> {
|
|
189
|
+
const key = hueKey(appKeyArg);
|
|
190
|
+
return this.run(async (operation) => {
|
|
191
|
+
this.permission('config', this.#config.bridgeId, ['credentials:create']);
|
|
192
|
+
if (!this.canCreateCredential) throw new Error('Hue credential creation is unavailable.');
|
|
193
|
+
await this.readResources(operation, key);
|
|
194
|
+
const request = this.credentialRequest(operation);
|
|
195
|
+
let dispatched = false;
|
|
196
|
+
try {
|
|
197
|
+
if (this.#options.credentialProvider?.create) await this.foreign(operation, () => { dispatched = true; return this.#options.credentialProvider!.create!(request, key); });
|
|
198
|
+
else {
|
|
199
|
+
await this.foreign(operation, () => this.#store!.initialize({ signal: operation.signal }));
|
|
200
|
+
await this.foreign(operation, () => { dispatched = true; return this.#store!.create(this.storeKey(), { version: 1, homeId: request.homeId, origin: request.origin, bridgeId: request.bridgeId, credentialReference: request.credentialReference, appKey: key }); });
|
|
201
|
+
}
|
|
202
|
+
this.active(operation);
|
|
203
|
+
} catch (error) { if (dispatched) throw new HuePairingError(); throw error; }
|
|
204
|
+
}, signalArg);
|
|
107
205
|
}
|
|
108
206
|
|
|
109
207
|
public subscribeToEvents(handlerArg: (eventArg: IHueEvent) => unknown,
|
|
110
208
|
onErrorArg: (errorArg: Error) => unknown, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
111
209
|
if (typeof handlerArg !== 'function' || typeof onErrorArg !== 'function') throw new TypeError('Hue subscribers must be functions.');
|
|
112
|
-
|
|
210
|
+
let resolveSubscription: (closeArg: () => Promise<void>) => void = () => {};
|
|
211
|
+
let rejectSubscription: (errorArg: unknown) => void = () => {};
|
|
212
|
+
let issued = false;
|
|
213
|
+
const subscribed = new Promise<() => Promise<void>>((resolve, reject) => { resolveSubscription = resolve; rejectSubscription = reject; });
|
|
214
|
+
let admitted: Promise<void>;
|
|
215
|
+
try {
|
|
216
|
+
admitted = this.run(async (operation) => {
|
|
217
|
+
const key = await this.credential(operation);
|
|
218
|
+
const close = await this.subscribe(operation, key, handlerArg, onErrorArg, abortSignalArg);
|
|
219
|
+
this.active(operation);
|
|
220
|
+
issued = true;
|
|
221
|
+
let closing: Promise<void> | undefined;
|
|
222
|
+
resolveSubscription(() => closing ??= close().then(() => admitted));
|
|
223
|
+
}, abortSignalArg);
|
|
224
|
+
} catch (error) { rejectSubscription(error); return subscribed; }
|
|
225
|
+
void admitted.then(() => { if (!issued) rejectSubscription(new Error('Hue subscription failed.')); }, (error: unknown) => {
|
|
226
|
+
if (!issued) rejectSubscription(error);
|
|
227
|
+
else { this.#callbackFailures.add(new Error('Hue subscription lifecycle failed.')); this.#eventFailure = true; }
|
|
228
|
+
});
|
|
229
|
+
return subscribed;
|
|
113
230
|
}
|
|
114
231
|
|
|
115
|
-
private async subscribe(handlerArg: (eventArg: IHueEvent) => unknown,
|
|
232
|
+
private async subscribe(operationArg: IHueOperation, keyArg: string, handlerArg: (eventArg: IHueEvent) => unknown,
|
|
116
233
|
onErrorArg: (errorArg: Error) => unknown, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
117
234
|
this.assertOpen(abortSignalArg);
|
|
118
235
|
if (this.#streamCount >= 2) throw new Error('Hue event subscription limit reached.');
|
|
119
236
|
this.#streamCount++;
|
|
120
237
|
let resolveClosed: () => void = () => {};
|
|
121
238
|
const closed = new Promise<void>((resolveArg) => { resolveClosed = resolveArg; });
|
|
239
|
+
operationArg.actual.add(closed);
|
|
240
|
+
void closed.then(() => operationArg.actual.delete(closed));
|
|
122
241
|
const abort = new AbortController();
|
|
123
|
-
const signal = AbortSignal.any([this.#lifetime.signal, abort.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
242
|
+
const signal = AbortSignal.any([this.#lifetime.signal, this.#owner.signal, abort.signal, ...(this.#context.abortSignal ? [this.#context.abortSignal] : []), ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
124
243
|
let active = true;
|
|
125
244
|
signal.addEventListener('abort', () => { active = false; }, { once: true });
|
|
126
245
|
const callbacks = new Set<Promise<void>>();
|
|
@@ -161,7 +280,7 @@ export class HueClient {
|
|
|
161
280
|
};
|
|
162
281
|
request = plugins.https.request(this.requestOptions('GET', '/eventstream/clip/v2', signal, {
|
|
163
282
|
accept: 'text/event-stream', 'cache-control': 'no-cache',
|
|
164
|
-
}), (responseArg) => {
|
|
283
|
+
}, keyArg), (responseArg) => {
|
|
165
284
|
if (responseArg.statusCode !== 200 || !hueContentType(responseArg.headers['content-type'], 'text/event-stream')) {
|
|
166
285
|
responseArg.destroy();
|
|
167
286
|
settleFailure(new Error('Hue event stream rejected or returned an invalid content type.'));
|
|
@@ -203,7 +322,7 @@ export class HueClient {
|
|
|
203
322
|
request.end();
|
|
204
323
|
});
|
|
205
324
|
try {
|
|
206
|
-
await ready;
|
|
325
|
+
await this.foreign(operationArg, () => ready);
|
|
207
326
|
} catch (error) {
|
|
208
327
|
connection.cancel();
|
|
209
328
|
if (!request) { this.#connections.delete(connection); this.#streamCount--; resolveClosed(); }
|
|
@@ -220,7 +339,7 @@ export class HueClient {
|
|
|
220
339
|
public destroy(): Promise<void> {
|
|
221
340
|
if (!this.#destroyPromise) {
|
|
222
341
|
this.#destroyPromise = Promise.resolve().then(() => this.drain());
|
|
223
|
-
this.
|
|
342
|
+
this.#context.abortSignal?.removeEventListener('abort', this.abortSetup);
|
|
224
343
|
this.#lifetime.abort();
|
|
225
344
|
}
|
|
226
345
|
return this.#destroyPromise;
|
|
@@ -233,7 +352,7 @@ export class HueClient {
|
|
|
233
352
|
this.#retryPromise = Promise.resolve().then(async () => {
|
|
234
353
|
await this.#destroyPromise!.catch(() => undefined);
|
|
235
354
|
this.#callbackFailures.clear();
|
|
236
|
-
await this.drain();
|
|
355
|
+
await this.drain(true);
|
|
237
356
|
});
|
|
238
357
|
const settled = (): void => { this.#retryPromise = undefined; };
|
|
239
358
|
void this.#retryPromise.then(settled, settled);
|
|
@@ -243,7 +362,7 @@ export class HueClient {
|
|
|
243
362
|
|
|
244
363
|
private readonly abortSetup = (): void => { void this.destroy().catch(() => undefined); };
|
|
245
364
|
|
|
246
|
-
private async drain(): Promise<void> {
|
|
365
|
+
private async drain(retryArg = false): Promise<void> {
|
|
247
366
|
const results = await Promise.allSettled([
|
|
248
367
|
...[...this.#connections].map(async (connectionArg) => { connectionArg.cancel(); await connectionArg.closed; }),
|
|
249
368
|
Promise.resolve().then(() => { if (!this.#agentClosed) { this.#agent.destroy(); this.#agentClosed = true; } }),
|
|
@@ -251,36 +370,80 @@ export class HueClient {
|
|
|
251
370
|
await Promise.allSettled([...this.#operations]);
|
|
252
371
|
const failures = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected')
|
|
253
372
|
.map(() => new Error('Hue transport cleanup failed.'));
|
|
254
|
-
|
|
373
|
+
const other = await Promise.allSettled([
|
|
374
|
+
...(this.#ownsStore && this.#store ? [retryArg ? this.#store.retryCleanup() : this.#store.close()] : []),
|
|
375
|
+
...(this.#ownsOwner ? [retryArg ? this.#owner.retryCleanup() : this.#owner.close()] : []),
|
|
376
|
+
]);
|
|
377
|
+
failures.push(...other.filter((result) => result.status === 'rejected').map(() => new Error('Hue owned cleanup failed.')), ...this.#callbackFailures);
|
|
255
378
|
if (failures.length) throw new AggregateError(failures, 'Hue cleanup failed; explicit cleanup retry is required.');
|
|
256
379
|
}
|
|
257
380
|
|
|
258
|
-
private run<TResult>(workArg: () => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
|
|
381
|
+
private run<TResult>(workArg: (operationArg: IHueOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
|
|
259
382
|
this.assertOpen(signalArg);
|
|
260
|
-
|
|
261
|
-
|
|
383
|
+
const operation = this.#owner.runOperation(this.#context, this.#config.homeId, async (lease) => {
|
|
384
|
+
const deadlineAbort = new AbortController();
|
|
385
|
+
const deadline = performance.now() + this.#config.requestTimeoutMs;
|
|
386
|
+
const signal = AbortSignal.any([lease.signal, this.#lifetime.signal, deadlineAbort.signal, ...(signalArg ? [signalArg] : [])]);
|
|
387
|
+
const actual = new Set<Promise<unknown>>();
|
|
388
|
+
lease.own(async () => { while (actual.size) await Promise.allSettled([...actual]); });
|
|
389
|
+
const timer = setTimeout(() => deadlineAbort.abort(new Error('Hue request timed out.')), this.#config.requestTimeoutMs);
|
|
390
|
+
try { return await workArg({ lease, signal, deadline, actual }); }
|
|
391
|
+
finally { clearTimeout(timer); }
|
|
392
|
+
});
|
|
262
393
|
this.#operations.add(operation);
|
|
263
394
|
void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
|
|
264
395
|
return operation;
|
|
265
396
|
}
|
|
266
397
|
|
|
398
|
+
private async foreign<TResult>(operationArg: IHueOperation, workArg: () => Promise<TResult>): Promise<TResult> {
|
|
399
|
+
this.active(operationArg);
|
|
400
|
+
if (operationArg.actual.size >= 16) throw new Error('Hue actual callback capacity reached.');
|
|
401
|
+
const actual = Promise.resolve().then(() => { this.active(operationArg); return workArg(); });
|
|
402
|
+
operationArg.actual.add(actual);
|
|
403
|
+
void actual.then(() => operationArg.actual.delete(actual), () => operationArg.actual.delete(actual));
|
|
404
|
+
let abort: () => void = () => {};
|
|
405
|
+
const cancelled = new Promise<never>((_resolve, reject) => { abort = () => reject(this.cancelled(operationArg)); operationArg.signal.addEventListener('abort', abort, { once: true }); if (operationArg.signal.aborted) abort(); });
|
|
406
|
+
try { const value = await Promise.race([actual, cancelled]); this.active(operationArg); return value; }
|
|
407
|
+
catch { if (operationArg.signal.aborted || performance.now() >= operationArg.deadline) throw this.cancelled(operationArg); throw new Error('Hue operation failed.'); }
|
|
408
|
+
finally { operationArg.signal.removeEventListener('abort', abort); }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private active(operationArg: IHueOperation): void {
|
|
412
|
+
if (operationArg.signal.aborted || performance.now() >= operationArg.deadline || this.#destroyPromise) throw this.cancelled(operationArg);
|
|
413
|
+
operationArg.lease.assertOpen();
|
|
414
|
+
}
|
|
415
|
+
private cancelled(operationArg: IHueOperation): Error { return new Error(performance.now() >= operationArg.deadline || operationArg.signal.reason instanceof Error && operationArg.signal.reason.message === 'Hue request timed out.' ? 'Hue request timed out.' : 'Hue request cancelled.'); }
|
|
416
|
+
private credentialRequest(operationArg: IHueOperation): IHueCredentialRequest { return Object.freeze({ homeId: this.#context.homeId, origin: hueOrigin(this.#config), bridgeId: this.#config.bridgeId, credentialReference: this.#config.credentialReference, signal: operationArg.signal, deadline: operationArg.deadline }); }
|
|
417
|
+
private storeKey(): string { return `hue.credential.${encodeURIComponent(this.#config.credentialReference)}`; }
|
|
418
|
+
private async credential(operationArg: IHueOperation): Promise<string> {
|
|
419
|
+
const request = this.credentialRequest(operationArg);
|
|
420
|
+
if (this.#options.credentialProvider) return hueKey(await this.foreign(operationArg, () => this.#options.credentialProvider!.resolve(request)));
|
|
421
|
+
await this.foreign(operationArg, () => this.#store!.initialize({ signal: operationArg.signal }));
|
|
422
|
+
const envelope = hueData(await this.foreign(operationArg, () => this.#store!.get(this.storeKey())), ['version', 'homeId', 'origin', 'bridgeId', 'credentialReference', 'appKey']);
|
|
423
|
+
if (envelope.version !== 1 || envelope.homeId !== request.homeId || envelope.origin !== request.origin || envelope.bridgeId !== request.bridgeId || envelope.credentialReference !== request.credentialReference) throw new Error('Hue credential reference does not match the selected home and bridge.');
|
|
424
|
+
return hueKey(envelope.appKey);
|
|
425
|
+
}
|
|
426
|
+
private permission(resourceArg: 'config' | 'lights', idArg: string, fieldsArg: readonly string[]): void {
|
|
427
|
+
if (!this.#options.controlPermissions?.some((permission) => permission.resource === resourceArg && permission.resourceId === idArg && fieldsArg.every((field) => permission.fields.includes(field)))) throw new Error('Hue control requires exact owner authorization.');
|
|
428
|
+
}
|
|
429
|
+
|
|
267
430
|
private assertOpen(abortSignalArg?: AbortSignal): void {
|
|
268
|
-
if (this.#destroyPromise || this.#lifetime.signal.aborted || abortSignalArg?.aborted) throw new Error('Hue client operation was cancelled or the client is closed.');
|
|
431
|
+
if (this.#destroyPromise || this.#lifetime.signal.aborted || this.#owner.signal.aborted || this.#context.abortSignal?.aborted || abortSignalArg?.aborted) throw new Error('Hue client operation was cancelled or the client is closed.');
|
|
269
432
|
if (this.#connections.size >= 32) throw new Error('Hue concurrent request limit reached.');
|
|
270
433
|
}
|
|
271
434
|
|
|
272
|
-
private requestOptions(methodArg: string, pathArg: string, signalArg: AbortSignal, headersArg: Record<string, string> = {}): plugins.https.RequestOptions {
|
|
435
|
+
private requestOptions(methodArg: string, pathArg: string, signalArg: AbortSignal, headersArg: Record<string, string> = {}, keyArg?: string): plugins.https.RequestOptions {
|
|
273
436
|
return { hostname: this.#config.host, port: this.#config.port, path: pathArg, method: methodArg,
|
|
274
437
|
servername: this.#config.bridgeId, ca: this.#config.trustedCa, rejectUnauthorized: true,
|
|
275
438
|
checkServerIdentity: (_hostnameArg, certificateArg) => plugins.tls.checkServerIdentity(this.#config.bridgeId, certificateArg),
|
|
276
439
|
signal: signalArg, agent: this.#agent,
|
|
277
|
-
headers: { 'hue-application-key':
|
|
440
|
+
headers: { ...(keyArg === undefined ? {} : { 'hue-application-key': keyArg }), accept: 'application/json', ...headersArg } };
|
|
278
441
|
}
|
|
279
442
|
|
|
280
|
-
private async request(methodArg: string, pathArg: string, bodyArg?: unknown, abortSignalArg?: AbortSignal): Promise<unknown> {
|
|
443
|
+
private async request(methodArg: string, pathArg: string, bodyArg?: unknown, abortSignalArg?: AbortSignal, keyArg?: string, dispatchArg?: () => void): Promise<unknown> {
|
|
281
444
|
this.assertOpen(abortSignalArg);
|
|
282
445
|
const abort = new AbortController();
|
|
283
|
-
const signal = AbortSignal.any([this.#lifetime.signal, abort.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
446
|
+
const signal = AbortSignal.any([this.#lifetime.signal, this.#owner.signal, abort.signal, ...(this.#context.abortSignal ? [this.#context.abortSignal] : []), ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
284
447
|
const encoded = bodyArg === undefined ? undefined : JSON.stringify(bodyArg);
|
|
285
448
|
let request: ReturnType<typeof plugins.https.request> | undefined;
|
|
286
449
|
let resolveClosed: () => void = () => {};
|
|
@@ -292,7 +455,7 @@ export class HueClient {
|
|
|
292
455
|
return await new Promise<unknown>((resolveArg, rejectArg) => {
|
|
293
456
|
request = plugins.https.request(this.requestOptions(methodArg, pathArg, signal, encoded === undefined ? {} : {
|
|
294
457
|
'content-type': 'application/json', 'content-length': String(Buffer.byteLength(encoded)),
|
|
295
|
-
}), (responseArg) => {
|
|
458
|
+
}, keyArg), (responseArg) => {
|
|
296
459
|
if (responseArg.statusCode !== 200 || !hueContentType(responseArg.headers['content-type'], 'application/json')) {
|
|
297
460
|
responseArg.destroy();
|
|
298
461
|
rejectArg(new Error(`Hue API request failed with HTTP status ${responseArg.statusCode ?? 0}.`));
|
|
@@ -316,6 +479,7 @@ export class HueClient {
|
|
|
316
479
|
});
|
|
317
480
|
request.once('error', () => rejectArg(new Error('Hue HTTPS request failed or was cancelled.')));
|
|
318
481
|
request.once('close', () => { this.#connections.delete(connection); resolveClosed(); });
|
|
482
|
+
dispatchArg?.();
|
|
319
483
|
request.end(encoded);
|
|
320
484
|
});
|
|
321
485
|
} finally {
|
|
@@ -1,45 +1,82 @@
|
|
|
1
|
+
import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
|
|
1
2
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
|
2
|
-
import { HueClient } from './hue.classes.client.js';
|
|
3
|
-
import { hueBridgeId,
|
|
4
|
-
import type { IHueConfig } from './hue.types.js';
|
|
3
|
+
import { HueClient, HuePairingError } from './hue.classes.client.js';
|
|
4
|
+
import { captureHueOptions, hueBridgeId, hueData, hueKey, normalizeHueConfig, normalizeHueHost } from './hue.validation.js';
|
|
5
|
+
import type { IHueConfig, IHueIntegrationOptions } from './hue.types.js';
|
|
5
6
|
|
|
6
7
|
export class HueConfigFlow implements IConfigFlow<IHueConfig> {
|
|
8
|
+
readonly #options: Readonly<IHueIntegrationOptions>;
|
|
9
|
+
readonly #owner: NativeIntegrationOwner;
|
|
10
|
+
readonly #scope: NativeIntegrationScope;
|
|
11
|
+
readonly #ownsOwner: boolean;
|
|
12
|
+
#terminal = false;
|
|
13
|
+
#submitted = false;
|
|
14
|
+
|
|
15
|
+
constructor(optionsArg: IHueIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
|
|
16
|
+
this.#options = captureHueOptions(optionsArg);
|
|
17
|
+
this.#ownsOwner = !ownerArg;
|
|
18
|
+
this.#owner = ownerArg ?? new NativeIntegrationOwner('hue', { homeId: this.#options.homeId, capacity: this.#options.capacity });
|
|
19
|
+
this.#scope = this.#owner.createFlowScope();
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHueConfig>> {
|
|
8
|
-
|
|
9
|
-
|
|
23
|
+
const context = this.#scope.admit(contextArg);
|
|
24
|
+
if (this.#terminal) throw new Error('Hue config flow is closed.');
|
|
25
|
+
const candidate = hueData(candidateArg, ['id', 'source', 'host', 'port', 'name', 'integrationDomain', 'manufacturer', 'model', 'serialNumber', 'macAddress', 'metadata']);
|
|
10
26
|
if (candidate.integrationDomain !== 'hue') throw new Error('Hue flow requires a Hue candidate.');
|
|
11
27
|
const bridgeId = hueBridgeId(candidate.id) ? candidate.id : undefined;
|
|
12
28
|
const port = candidate.port;
|
|
13
|
-
if (port !== undefined && typeof port !== 'number') throw new Error('Hue discovered port is invalid.');
|
|
29
|
+
if (port !== undefined && (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('Hue discovered port is invalid.');
|
|
14
30
|
let host: string | undefined;
|
|
15
31
|
try { host = normalizeHueHost(candidate.host); } catch { host = undefined; }
|
|
32
|
+
const submit = async (valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IHueConfig>> => {
|
|
33
|
+
if (this.#terminal || this.#submitted) return { kind: 'error', title: 'Hue flow closed', error: 'Hue config flow is closed or already submitting.' };
|
|
34
|
+
this.#submitted = true;
|
|
35
|
+
let paired = false;
|
|
36
|
+
let capturedValues: Record<string, unknown> | undefined;
|
|
37
|
+
try {
|
|
38
|
+
const values = hueData(valuesArg, ['homeId', 'bridgeId', 'host', 'credentialReference', 'appKey', 'trustedCa']);
|
|
39
|
+
if (values.homeId !== undefined && values.homeId !== context.homeId) throw new Error('Hue form home does not match the host home.');
|
|
40
|
+
capturedValues = Object.freeze(values);
|
|
41
|
+
const config = normalizeHueConfig({ homeId: context.homeId, bridgeId: bridgeId ?? values.bridgeId as string,
|
|
42
|
+
host: host ?? values.host as string, port: port as number | undefined,
|
|
43
|
+
credentialReference: values.credentialReference as string, trustedCa: values.trustedCa === '' ? undefined : values.trustedCa as string | undefined });
|
|
44
|
+
const suppliedKey = values.appKey === undefined || values.appKey === '' ? undefined : hueKey(values.appKey);
|
|
45
|
+
const result = await this.#scope.run(context, config.homeId, async (lease) => {
|
|
46
|
+
const client = new HueClient(config, { ...lease.context, abortSignal: lease.signal }, this.#options, this.#owner);
|
|
47
|
+
let cleanupStarted = false;
|
|
48
|
+
lease.own(() => { if (cleanupStarted) return client.retryCleanup(); cleanupStarted = true; return client.destroy(); });
|
|
49
|
+
if (!client.canCreateCredential) throw new Error('Hue flow requires credential creation by the selected provider or encrypted store.');
|
|
50
|
+
const key = suppliedKey ?? await client.acquireAppKey(undefined, lease.signal);
|
|
51
|
+
paired = !suppliedKey;
|
|
52
|
+
await client.createCredential(key, lease.signal);
|
|
53
|
+
lease.assertOpen();
|
|
54
|
+
return { kind: 'done', title: 'Hue bridge verified', config } as IConfigFlowStep<IHueConfig>;
|
|
55
|
+
});
|
|
56
|
+
this.#terminal = true;
|
|
57
|
+
await this.#scope.close();
|
|
58
|
+
return result;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof HuePairingError && error.linkButtonRequired && !paired) {
|
|
61
|
+
return { kind: 'wait', title: 'Press the Hue link button', description: 'Press the physical link button on the selected bridge, then retry.', submit: () => submit(capturedValues!) };
|
|
62
|
+
}
|
|
63
|
+
if (paired || error instanceof HuePairingError) { this.#terminal = true; await this.#scope.close(); }
|
|
64
|
+
return { kind: 'error', title: 'Hue bridge could not be verified', error: paired || error instanceof HuePairingError ? 'Hue pairing or credential creation was dispatched; do not replay it.' : 'Check the host home, bridge ID, HTTPS endpoint, trusted CA, credential reference and permissions. The bridge must support Hue API v2.' };
|
|
65
|
+
} finally { this.#submitted = false; }
|
|
66
|
+
};
|
|
16
67
|
return {
|
|
17
68
|
kind: 'form', title: 'Connect Philips Hue Bridge',
|
|
18
|
-
description: '
|
|
69
|
+
description: 'Select a credential reference. Press the physical link button to pair, or supply an existing application key for one-time insertion into the host credential store. Setup verifies TLS identity and live resources.',
|
|
19
70
|
fields: [
|
|
20
71
|
...(!bridgeId ? [{ name: 'bridgeId', label: 'Hue bridge ID', type: 'text' as const, required: true }] : []),
|
|
21
72
|
...(!host ? [{ name: 'host', label: 'Bridge hostname or IP address', type: 'text' as const, required: true }] : []),
|
|
22
|
-
{ name: '
|
|
73
|
+
{ name: 'credentialReference', label: 'Credential reference', type: 'text', required: true },
|
|
74
|
+
{ name: 'appKey', label: 'Existing Hue app key (optional)', type: 'password' },
|
|
23
75
|
{ name: 'trustedCa', label: 'Trusted Hue CA certificate (PEM)', type: 'text' },
|
|
24
|
-
],
|
|
25
|
-
submit: async (valuesArg) => {
|
|
26
|
-
let client: HueClient | undefined;
|
|
27
|
-
try {
|
|
28
|
-
const values = hueRecord(valuesArg);
|
|
29
|
-
if (Reflect.ownKeys(values).some((keyArg) => !['bridgeId', 'host', 'appKey', 'trustedCa'].includes(String(keyArg)))) throw new Error('Hue flow values contain unsupported fields.');
|
|
30
|
-
const config = normalizeHueConfig({ bridgeId: bridgeId ?? values.bridgeId as string,
|
|
31
|
-
host: host ?? values.host as string, port: port ?? 443,
|
|
32
|
-
appKey: values.appKey as string, trustedCa: values.trustedCa === '' ? undefined : values.trustedCa as string | undefined });
|
|
33
|
-
client = new HueClient(config);
|
|
34
|
-
await client.getResources();
|
|
35
|
-
return { kind: 'done', title: 'Hue bridge verified', config };
|
|
36
|
-
} catch {
|
|
37
|
-
return { kind: 'error', title: 'Hue bridge could not be verified',
|
|
38
|
-
error: 'Check the bridge ID, HTTPS endpoint, trusted CA and application key. The bridge must support Hue API v2.' };
|
|
39
|
-
} finally {
|
|
40
|
-
await client?.destroy();
|
|
41
|
-
}
|
|
42
|
-
},
|
|
76
|
+
], submit,
|
|
43
77
|
};
|
|
44
78
|
}
|
|
79
|
+
|
|
80
|
+
public destroy(): Promise<void> { this.#terminal = true; return this.#ownsOwner ? this.#owner.close() : this.#scope.close(); }
|
|
81
|
+
public retryCleanup(): Promise<void> { return this.#ownsOwner ? this.#owner.retryCleanup() : this.#scope.retryCleanup(); }
|
|
45
82
|
}
|