@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.
@@ -1,6 +1,10 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import type { IHueConfig, IHueEvent, IHueLightStatePatch, IHueResourceResponse } from './hue.types.js';
3
- import { hueContentType, hueRecord, hueUuid, normalizeHueConfig, parseHueEnvelope, parseHueEvents, parseHueResources, validateHuePatch } from './hue.validation.js';
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, private readonly setupSignal?: AbortSignal) {
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
- if (setupSignal?.aborted) this.#lifetime.abort();
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(abortSignalArg), abortSignalArg);
110
+ return this.run((operation) => this.readResources(operation), abortSignalArg);
74
111
  }
75
112
 
76
- private async readResources(abortSignalArg?: AbortSignal): Promise<IHueResourceResponse> {
113
+ private async readResources(operationArg: IHueOperation, keyArg?: string): Promise<IHueResourceResponse> {
77
114
  try {
78
- const data = parseHueEnvelope(await this.request('GET', '/clip/v2/resource', undefined, abortSignalArg));
79
- const resources = parseHueResources(data, this.#config.bridgeId);
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 async setLightState(lightIdArg: string, stateArg: IHueLightStatePatch, abortSignalArg?: AbortSignal): Promise<void> {
86
- if (!hueUuid(lightIdArg)) throw new Error('Hue light target must be a resource UUID.');
87
- const captured = hueRecord(stateArg);
88
- const state = { on: captured.on, brightness: captured.brightness } as IHueLightStatePatch;
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
- const lightId = lightIdArg.toLowerCase();
91
- return this.run(async () => {
92
- const snapshot = await this.readResources(abortSignalArg);
93
- const light = snapshot.lights.find((lightArg) => lightArg.id === lightId);
94
- if (!light || !light.available) throw new Error('Hue light target is unknown or unavailable.');
95
- if (state.brightness !== undefined && !light.dimming) throw new Error('Hue light does not support brightness.');
96
- const body = { ...(state.on === undefined ? {} : { on: { on: state.on } }),
97
- ...(state.brightness === undefined ? {} : { dimming: { brightness: state.brightness } }) };
98
- const data = parseHueEnvelope(await this.request('PUT', `/clip/v2/resource/light/${lightId}`, body, abortSignalArg));
99
- if (data.length !== 1) throw new Error('Hue write acknowledgement does not identify the target light.');
100
- const acknowledgement = data[0];
101
- if (!acknowledgement || typeof acknowledgement !== 'object' || Array.isArray(acknowledgement)
102
- || !('rid' in acknowledgement) || typeof acknowledgement.rid !== 'string' || acknowledgement.rid.toLowerCase() !== lightId
103
- || !('rtype' in acknowledgement) || acknowledgement.rtype !== 'light') {
104
- throw new Error('Hue write acknowledgement does not identify the target light.');
105
- }
106
- }, abortSignalArg);
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
- return this.run(() => this.subscribe(handlerArg, onErrorArg, abortSignalArg), abortSignalArg);
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.setupSignal?.removeEventListener('abort', this.abortSetup);
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
- failures.push(...this.#callbackFailures);
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
- if (this.#operations.size >= 32) throw new Error('Hue concurrent operation limit reached.');
261
- const operation = Promise.resolve().then(() => { this.assertOpen(signalArg); return workArg(); });
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': this.#config.appKey, accept: 'application/json', ...headersArg } };
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, hueRecord, normalizeHueConfig, normalizeHueHost } from './hue.validation.js';
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
- void contextArg;
9
- const candidate = hueRecord(candidateArg);
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: 'Provide a bridge application key created with the physical link button. For Hue private-CA certificates, provide the trusted Hue CA from the developer portal. Setup verifies the TLS identity and live bridge resources.',
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: 'appKey', label: 'Hue app key', type: 'password', required: true },
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
  }