@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,89 +1,57 @@
1
- import type * as shxInterfaces from '@smarthome.exchange/interfaces';
1
+ import type * as plugins from '../../plugins.js';
2
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
2
3
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
4
  import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
4
5
  import { HueClient } from './hue.classes.client.js';
5
6
  import { HueConfigFlow } from './hue.classes.configflow.js';
6
7
  import { createHueDiscoveryDescriptor } from './hue.discovery.js';
7
8
  import { HueMapper } from './hue.mapper.js';
8
- import { hueRecord } from './hue.validation.js';
9
- import type { IHueConfig, IHueLightStatePatch, IHueResourceResponse } from './hue.types.js';
9
+ import { captureHueOptions, hueRecord, normalizeHueConfig } from './hue.validation.js';
10
+ import type { IHueConfig, IHueLightStatePatch, IHueResourceResponse, IHueIntegrationOptions } from './hue.types.js';
10
11
 
11
12
  export class HueIntegration extends BaseIntegration<IHueConfig> {
12
13
  public readonly domain = 'hue';
13
14
  public readonly displayName = 'Philips Hue';
14
15
  public readonly status = 'control-runtime' as const;
15
16
  public readonly discoveryDescriptor = createHueDiscoveryDescriptor();
16
- public readonly configFlow = new HueConfigFlow();
17
- readonly #runtimes = new Set<HueIntegrationRuntime>();
18
- readonly #setups = new Set<Promise<IIntegrationRuntime>>();
19
- #closed = false;
20
- #destroyPromise?: Promise<void>;
21
- #retryPromise?: Promise<void>;
17
+ public readonly configFlow: HueConfigFlow;
18
+ readonly #owner: NativeIntegrationOwner;
19
+ readonly #options: Readonly<IHueIntegrationOptions>;
20
+
21
+ constructor(optionsArg: IHueIntegrationOptions = {}) {
22
+ super();
23
+ this.#options = captureHueOptions(optionsArg);
24
+ this.#owner = new NativeIntegrationOwner('hue', { homeId: this.#options.homeId, capacity: this.#options.capacity });
25
+ this.configFlow = new HueConfigFlow(this.#options, this.#owner);
26
+ }
22
27
 
23
28
  public async setup(configArg: IHueConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
24
- if (this.#closed) throw new Error('Hue integration is closed.');
25
- const context = hueRecord(contextArg);
26
- const signal = context.abortSignal;
27
- if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('Hue abortSignal must be an AbortSignal.');
28
- const client = new HueClient(configArg, signal);
29
- const runtime = new HueIntegrationRuntime(client, () => this.#runtimes.delete(runtime));
30
- this.#runtimes.add(runtime);
31
- const setup = Promise.resolve().then(async () => {
32
- try {
33
- await client.getResources();
34
- if (this.#closed || signal?.aborted) throw new Error('Hue setup was cancelled.');
35
- return runtime;
36
- } catch (error) {
37
- try { await runtime.destroy(); }
38
- catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Hue setup and cleanup failed.'); }
39
- throw error;
40
- }
29
+ const config = normalizeHueConfig(configArg);
30
+ return this.#owner.runSetup(contextArg, config.homeId, async (lease) => {
31
+ const client = new HueClient(config, { ...lease.context, abortSignal: lease.signal }, this.#options, this.#owner);
32
+ let cleanupStarted = false;
33
+ lease.own(() => { if (cleanupStarted) return client.retryCleanup(); cleanupStarted = true; return client.destroy(); });
34
+ await client.getResources();
35
+ lease.assertOpen();
36
+ if (!client.isHealthy()) throw new Error('Hue setup cancelled.');
37
+ lease.promoteToRuntime();
38
+ return new HueIntegrationRuntime(client, lease);
41
39
  });
42
- this.#setups.add(setup);
43
- void setup.then(() => this.#setups.delete(setup), () => this.#setups.delete(setup));
44
- return setup;
45
- }
46
-
47
- public destroy(): Promise<void> {
48
- if (!this.#destroyPromise) {
49
- this.#closed = true;
50
- this.#destroyPromise = Promise.resolve().then(() => this.drain(false));
51
- }
52
- return this.#destroyPromise;
53
- }
54
-
55
- public retryCleanup(): Promise<void> {
56
- if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal integration.');
57
- if (!this.#retryPromise) {
58
- this.#retryPromise = Promise.resolve().then(async () => {
59
- await this.#destroyPromise!.catch(() => undefined);
60
- await this.drain(true);
61
- });
62
- const settled = (): void => { this.#retryPromise = undefined; };
63
- void this.#retryPromise.then(settled, settled);
64
- }
65
- return this.#retryPromise;
66
40
  }
67
41
 
68
- private async drain(retryArg: boolean): Promise<void> {
69
- const cleanup = Promise.allSettled([...this.#runtimes].map((runtimeArg) => retryArg ? runtimeArg.retryCleanup() : runtimeArg.destroy()));
70
- const [results] = await Promise.all([cleanup, Promise.allSettled([...this.#setups])]);
71
- const errors = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected').map((resultArg) => resultArg.reason as unknown);
72
- if (errors.length) throw new AggregateError(errors, 'Hue integration cleanup failed; resources remain retained for explicit retry.');
73
- }
42
+ public destroy(): Promise<void> { return this.#owner.close(); }
43
+ public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
74
44
 
75
45
  }
76
46
 
77
47
  class HueIntegrationRuntime implements IIntegrationRuntime {
78
48
  public readonly domain = 'hue';
79
49
 
80
- #destroyPromise?: Promise<void>;
81
- #retryPromise?: Promise<void>;
82
- constructor(private readonly client: HueClient, private readonly released: () => void) {}
50
+ constructor(private readonly client: HueClient, private readonly lease: INativeSetupLease) {}
83
51
 
84
52
  public isHealthy(): boolean { return this.client.isHealthy(); }
85
53
 
86
- public async devices(abortSignalArg?: AbortSignal): Promise<shxInterfaces.data.IDeviceDefinition[]> {
54
+ public async devices(abortSignalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
87
55
  return HueMapper.toDevices(await this.client.getResources(abortSignalArg));
88
56
  }
89
57
 
@@ -119,11 +87,13 @@ class HueIntegrationRuntime implements IIntegrationRuntime {
119
87
  state.brightness = brightness;
120
88
  if (request.service === 'set_percentage') state.on = brightness > 0;
121
89
  }
122
- const snapshot = await this.client.getResources();
123
- const lightId = this.resolveLight(snapshot, target.entityId, target.deviceId);
124
- if (!lightId) return { success: false, error: 'Hue service requires one known, unambiguous light target.' };
125
- await this.client.setLightState(lightId, state);
126
- return { success: true };
90
+ return this.client.execute(async (session) => {
91
+ const snapshot = await session.getResources();
92
+ const lightId = this.resolveLight(snapshot, target.entityId, target.deviceId);
93
+ if (!lightId) return { success: false, error: 'Hue service requires one known, unambiguous light target.' };
94
+ await session.setLightState(lightId, state);
95
+ return { success: true };
96
+ });
127
97
  }
128
98
 
129
99
  public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
@@ -138,44 +108,29 @@ class HueIntegrationRuntime implements IIntegrationRuntime {
138
108
  if (selectedDevice.integrationDomain !== this.domain || typeof deviceId !== 'string' || typeof featureId !== 'string') {
139
109
  return { success: false, error: 'Hue feature requires one exact Hue device and feature.' };
140
110
  }
141
- const snapshot = await this.client.getResources(abortSignalArg);
142
- const device = HueMapper.toDevices(snapshot).find((deviceArg) => deviceArg.id === deviceId);
143
- const feature = device?.features.find((featureArg) => featureArg.id === featureId && featureArg.capability === capability && featureArg.writable);
144
- const lightId = this.resolveLight(snapshot, undefined, device?.id);
145
- if (!device || !device.online || !feature || !lightId) return { success: false, error: 'Hue feature target is unknown, unavailable or not writable.' };
146
- let state: IHueLightStatePatch;
147
- if (feature.id === 'on' && typeof value === 'boolean') state = { on: value };
148
- else if (feature.id === 'brightness' && typeof value === 'number') state = { brightness: value };
149
- else return { success: false, error: 'Hue feature value has an invalid type.' };
150
- await this.client.setLightState(lightId, state, abortSignalArg);
151
- return { success: true };
111
+ return this.client.execute(async (session) => {
112
+ const snapshot = await session.getResources(abortSignalArg);
113
+ const device = HueMapper.toDevices(snapshot).find((deviceArg) => deviceArg.id === deviceId);
114
+ const feature = device?.features.find((featureArg) => featureArg.id === featureId && featureArg.capability === capability && featureArg.writable);
115
+ const lightId = this.resolveLight(snapshot, undefined, device?.id);
116
+ if (!device || !device.online || !feature || !lightId) return { success: false, error: 'Hue feature target is unknown, unavailable or not writable.' };
117
+ let state: IHueLightStatePatch;
118
+ if (feature.id === 'on' && typeof value === 'boolean') state = { on: value };
119
+ else if (feature.id === 'brightness' && typeof value === 'number') state = { brightness: value };
120
+ else return { success: false, error: 'Hue feature value has an invalid type.' };
121
+ await session.setLightState(lightId, state, abortSignalArg);
122
+ return { success: true };
123
+ }, abortSignalArg);
152
124
  }
153
125
 
154
- public destroy(): Promise<void> {
155
- this.#destroyPromise ??= Promise.resolve().then(async () => { await this.client.destroy(); this.released(); });
156
- return this.#destroyPromise;
157
- }
158
-
159
- public retryCleanup(): Promise<void> {
160
- if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal runtime.');
161
- if (!this.#retryPromise) {
162
- this.#retryPromise = Promise.resolve().then(async () => {
163
- await this.#destroyPromise!.catch(() => undefined);
164
- await this.client.retryCleanup();
165
- this.released();
166
- });
167
- const settled = (): void => { this.#retryPromise = undefined; };
168
- void this.#retryPromise.then(settled, settled);
169
- }
170
- return this.#retryPromise;
171
- }
126
+ public destroy(): Promise<void> { return this.lease.close(); }
172
127
 
173
128
  private resolveLight(snapshotArg: IHueResourceResponse, entityIdArg: unknown, deviceIdArg: unknown): string | undefined {
174
129
  if (entityIdArg === undefined && deviceIdArg === undefined) return undefined;
175
130
  const entity = entityIdArg === undefined ? undefined : HueMapper.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
176
- const device = deviceIdArg === undefined ? undefined : snapshotArg.lights.find((lightArg) => `hue.light.${lightArg.id}` === deviceIdArg);
131
+ const device = deviceIdArg === undefined ? undefined : snapshotArg.lights.find((lightArg) => HueMapper.lightDeviceId(snapshotArg.homeId, lightArg.id) === deviceIdArg);
177
132
  if (entityIdArg !== undefined && !entity || deviceIdArg !== undefined && !device) return undefined;
178
- if (entity && device && entity.deviceId !== `hue.light.${device.id}`) return undefined;
179
- return device?.id || (entity ? snapshotArg.lights.find((lightArg) => entity.deviceId === `hue.light.${lightArg.id}`)?.id : undefined);
133
+ if (entity && device && entity.deviceId !== HueMapper.lightDeviceId(snapshotArg.homeId, device.id)) return undefined;
134
+ return device?.id || (entity ? snapshotArg.lights.find((lightArg) => entity.deviceId === HueMapper.lightDeviceId(snapshotArg.homeId, lightArg.id))?.id : undefined);
180
135
  }
181
136
  }
@@ -3,10 +3,13 @@ import type { IIntegrationEntity, IIntegrationEvent } from '../../core/types.js'
3
3
  import type { IHueEvent, IHueResourceResponse } from './hue.types.js';
4
4
 
5
5
  export class HueMapper {
6
+ public static lightDeviceId(homeIdArg: string, lightIdArg: string): string { return `hue.light.${encodeURIComponent(homeIdArg)}.${lightIdArg}`; }
7
+ public static lightEntityId(homeIdArg: string, lightIdArg: string): string { return `light.hue.${encodeURIComponent(homeIdArg)}.${lightIdArg}`; }
8
+
6
9
  public static toDevices(resourcesArg: IHueResourceResponse): plugins.shxInterfaces.data.IDeviceDefinition[] {
7
10
  const timestamp = new Date().toISOString();
8
11
  const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
9
- id: `hue.bridge.${resourcesArg.bridge.id}`,
12
+ id: `hue.bridge.${encodeURIComponent(resourcesArg.homeId)}.${resourcesArg.bridge.id}`,
10
13
  integrationDomain: 'hue', name: resourcesArg.bridge.metadata?.name || 'Hue Bridge', protocol: 'hue',
11
14
  manufacturer: resourcesArg.bridge.productData?.manufacturerName,
12
15
  model: resourcesArg.bridge.productData?.productName || resourcesArg.bridge.productData?.modelId,
@@ -17,7 +20,7 @@ export class HueMapper {
17
20
  for (const light of resourcesArg.lights) {
18
21
  const owner = resourcesArg.devices.find((deviceArg) => deviceArg.id === light.owner.rid);
19
22
  devices.push({
20
- id: `hue.light.${light.id}`, integrationDomain: 'hue',
23
+ id: HueMapper.lightDeviceId(resourcesArg.homeId, light.id), integrationDomain: 'hue',
21
24
  name: light.metadata?.name || owner?.metadata?.name || `Hue Light ${light.id}`, protocol: 'hue',
22
25
  manufacturer: owner?.productData?.manufacturerName,
23
26
  model: owner?.productData?.productName || owner?.productData?.modelId,
@@ -26,7 +29,7 @@ export class HueMapper {
26
29
  ...(light.dimming ? [{ id: 'brightness', capability: 'light' as const, name: 'Brightness', readable: true, writable: true, unit: '%' }] : [])],
27
30
  state: [{ featureId: 'on', value: light.on.on, updatedAt: timestamp },
28
31
  ...(light.dimming ? [{ featureId: 'brightness', value: light.dimming.brightness, updatedAt: timestamp }] : [])],
29
- metadata: { hueOwnerRid: light.owner.rid },
32
+ metadata: { homeId: resourcesArg.homeId, hueOwnerRid: light.owner.rid },
30
33
  });
31
34
  }
32
35
  return devices;
@@ -34,8 +37,8 @@ export class HueMapper {
34
37
 
35
38
  public static toEntities(resourcesArg: IHueResourceResponse): IIntegrationEntity[] {
36
39
  return resourcesArg.lights.map((lightArg) => ({
37
- id: `light.${lightArg.id}`, uniqueId: `hue_light_${lightArg.id}`, integrationDomain: 'hue',
38
- deviceId: `hue.light.${lightArg.id}`, platform: 'light',
40
+ id: HueMapper.lightEntityId(resourcesArg.homeId, lightArg.id), uniqueId: `hue_light_${encodeURIComponent(resourcesArg.homeId)}_${lightArg.id}`, integrationDomain: 'hue',
41
+ deviceId: HueMapper.lightDeviceId(resourcesArg.homeId, lightArg.id), platform: 'light',
39
42
  name: lightArg.metadata?.name || resourcesArg.devices.find((deviceArg) => deviceArg.id === lightArg.owner.rid)?.metadata?.name || `Hue Light ${lightArg.id}`,
40
43
  state: lightArg.on.on ? 'on' : 'off',
41
44
  attributes: lightArg.dimming ? { brightness: lightArg.dimming.brightness } : {}, available: lightArg.available,
@@ -1,13 +1,48 @@
1
+ import type * as plugins from '../../plugins.js';
2
+ import type { IConfigEncryptionKeyProvider } from '../../core/classes.configstore.js';
3
+
1
4
  export interface IHueConfig {
5
+ homeId: string;
2
6
  bridgeId: string;
3
7
  host: string;
4
- appKey: string;
8
+ credentialReference: string;
5
9
  port?: number;
6
10
  /** PEM CA supplied by the operator from a trusted source. */
7
11
  trustedCa?: string;
8
12
  requestTimeoutMs?: number;
9
13
  }
10
14
 
15
+ export interface IHueCredentialRequest {
16
+ readonly homeId: string;
17
+ readonly origin: string;
18
+ readonly bridgeId: string;
19
+ readonly credentialReference: string;
20
+ readonly signal: AbortSignal;
21
+ /** Monotonic deadline shared by provider work and HTTPS dispatch. */
22
+ readonly deadline: number;
23
+ }
24
+
25
+ export interface IHueCredentialProvider {
26
+ resolve(requestArg: IHueCredentialRequest): Promise<string>;
27
+ /** Creates a reference without replacing an existing credential. */
28
+ create?(requestArg: IHueCredentialRequest, appKeyArg: string): Promise<void>;
29
+ }
30
+
31
+ export interface IHueControlPermission {
32
+ readonly resource: 'config' | 'lights';
33
+ readonly resourceId: string;
34
+ readonly fields: readonly string[];
35
+ }
36
+
37
+ export interface IHueIntegrationOptions {
38
+ readonly homeId?: string;
39
+ readonly capacity?: number;
40
+ readonly credentialProvider?: IHueCredentialProvider;
41
+ readonly controlPermissions?: readonly IHueControlPermission[];
42
+ readonly db?: plugins.nosqldb.SmartdataDb;
43
+ readonly keyProvider?: IConfigEncryptionKeyProvider;
44
+ }
45
+
11
46
  export interface IHueLightStatePatch {
12
47
  on?: boolean;
13
48
  /** Hue v2 brightness is a percentage, from 0 through 100. */
@@ -40,6 +75,7 @@ export interface IHueLightResource {
40
75
  }
41
76
 
42
77
  export interface IHueResourceResponse {
78
+ homeId: string;
43
79
  bridge: IHueDeviceResource;
44
80
  devices: IHueDeviceResource[];
45
81
  lights: IHueLightResource[];
@@ -1,5 +1,5 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import type { IHueConfig, IHueDeviceResource, IHueEvent, IHueLightResource, IHueLightStatePatch, IHueResourceResponse } from './hue.types.js';
2
+ import type { IHueConfig, IHueDeviceResource, IHueEvent, IHueLightResource, IHueLightStatePatch, IHueResourceResponse, IHueIntegrationOptions } from './hue.types.js';
3
3
 
4
4
  export const hueContentType = (valueArg: unknown, expectedArg: string): boolean =>
5
5
  typeof valueArg === 'string' && valueArg.split(';', 1)[0].trim().toLowerCase() === expectedArg;
@@ -27,6 +27,84 @@ export const hueRecord = (valueArg: unknown): Record<string, unknown> => {
27
27
  return record;
28
28
  };
29
29
 
30
+
31
+ export const hueData = (valueArg: unknown, allowedArg: readonly string[]): Record<string, unknown> => {
32
+ const value = hueRecord(valueArg);
33
+ if (Object.keys(value).some((key) => !allowedArg.includes(key))) throw new TypeError('Hue data contains unsupported fields.');
34
+ return value;
35
+ };
36
+
37
+ export const hueText = (valueArg: unknown, nameArg: string, maximumArg = 256): string => {
38
+ if (typeof valueArg !== 'string' || !valueArg || valueArg.length > maximumArg || valueArg.trim() !== valueArg || /[\p{Cc}\p{Cs}]/u.test(valueArg)) throw new TypeError(`Invalid Hue ${nameArg}.`);
39
+ return valueArg;
40
+ };
41
+
42
+ export const hueKey = (valueArg: unknown): string => {
43
+ if (typeof valueArg !== 'string' || !/^[a-z0-9_-]{1,256}$/i.test(valueArg)) throw new TypeError('Invalid Hue application credential.');
44
+ return valueArg;
45
+ };
46
+
47
+ export const hueOrigin = (configArg: IHueConfig): string => new URL(`https://${plugins.net.isIP(configArg.host) === 6 ? `[${configArg.host}]` : configArg.host}:${configArg.port ?? 443}`).origin;
48
+
49
+ function method(valueArg: unknown, keyArg: string): ((...argsArg: never[]) => unknown) | undefined {
50
+ if (!valueArg || typeof valueArg !== 'object') throw new TypeError('Invalid Hue credential provider.');
51
+ let target: object | null = valueArg;
52
+ for (let depth = 0; target && depth < 16; depth++) {
53
+ const descriptor = Object.getOwnPropertyDescriptor(target, keyArg);
54
+ if (descriptor) {
55
+ if (!('value' in descriptor) || typeof descriptor.value !== 'function') throw new TypeError('Hue provider requires data methods.');
56
+ return Function.prototype.bind.call(descriptor.value, valueArg) as (...argsArg: never[]) => unknown;
57
+ }
58
+ target = Object.getPrototypeOf(target) as object | null;
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ export function captureHueOptions(valueArg: IHueIntegrationOptions = {}): Readonly<IHueIntegrationOptions> {
64
+ const value = hueData(valueArg, ['homeId', 'capacity', 'credentialProvider', 'controlPermissions', 'db', 'keyProvider']);
65
+ const result: IHueIntegrationOptions = {};
66
+ if (value.homeId !== undefined) Object.assign(result, { homeId: hueText(value.homeId, 'constructor home') });
67
+ if (value.capacity !== undefined) {
68
+ if (typeof value.capacity !== 'number' || !Number.isSafeInteger(value.capacity) || value.capacity < 1 || value.capacity > 16) throw new TypeError('Hue capacity must be from 1 through 16.');
69
+ Object.assign(result, { capacity: value.capacity });
70
+ }
71
+ if (value.credentialProvider !== undefined) {
72
+ const resolve = method(value.credentialProvider, 'resolve');
73
+ const create = method(value.credentialProvider, 'create');
74
+ if (!resolve) throw new TypeError('Hue credential provider must resolve references.');
75
+ Object.assign(result, { credentialProvider: Object.freeze({ resolve, ...(create ? { create } : {}) }) });
76
+ }
77
+ if (value.controlPermissions !== undefined) {
78
+ if (!Array.isArray(value.controlPermissions) || value.controlPermissions.length > 128) throw new TypeError('Invalid Hue control permissions.');
79
+ const permissions = dataArray(value.controlPermissions).map((permissionArg: unknown) => {
80
+ const permission = hueData(permissionArg, ['resource', 'resourceId', 'fields']);
81
+ if (typeof permission.resource !== 'string' || !['config', 'lights'].includes(permission.resource)) throw new TypeError('Invalid Hue permission resource.');
82
+ const resourceId = hueText(permission.resourceId, 'permission resource', 128).toLowerCase();
83
+ if (permission.resource === 'lights' ? !hueUuid(resourceId) : !hueBridgeId(resourceId)) throw new TypeError('Invalid Hue permission identity.');
84
+ if (!Array.isArray(permission.fields) || !permission.fields.length || permission.fields.length > 32) throw new TypeError('Invalid Hue permission fields.');
85
+ const fields = dataArray(permission.fields).map((fieldArg: unknown) => hueText(fieldArg, 'permission field', 64));
86
+ const supported = permission.resource === 'lights' ? ['on', 'brightness'] : ['pair', 'credentials:create'];
87
+ if (fields.some((field) => !supported.includes(field))) throw new TypeError('Invalid Hue permission field.');
88
+ return Object.freeze({ resource: permission.resource, resourceId, fields: Object.freeze(fields) });
89
+ });
90
+ Object.assign(result, { controlPermissions: Object.freeze(permissions) });
91
+ }
92
+ if (value.db !== undefined) Object.assign(result, { db: value.db });
93
+ if (value.keyProvider !== undefined) {
94
+ const currentKey = method(value.keyProvider, 'currentKey');
95
+ const keyForId = method(value.keyProvider, 'keyForId');
96
+ if (!currentKey || !keyForId) throw new TypeError('Hue encrypted storage requires data-method key providers.');
97
+ Object.assign(result, { keyProvider: Object.freeze({ currentKey, keyForId }) });
98
+ }
99
+ if (Boolean(value.db) !== Boolean(value.keyProvider)) throw new TypeError('Hue owned encrypted storage requires both database and key provider.');
100
+ return Object.freeze(result);
101
+ }
102
+
103
+ function dataArray(valueArg: unknown[]): unknown[] {
104
+ if (Reflect.ownKeys(valueArg).some((key) => key !== 'length' && (typeof key !== 'string' || !/^(0|[1-9]\d*)$/.test(key) || Number(key) >= valueArg.length))) throw new TypeError('Hue arrays require dense data fields.');
105
+ return Array.from({ length: valueArg.length }, (_, index) => { const descriptor = Object.getOwnPropertyDescriptor(valueArg, String(index)); if (!descriptor?.enumerable || !('value' in descriptor)) throw new TypeError('Hue arrays require dense data fields.'); return descriptor.value as unknown; });
106
+ }
107
+
30
108
  export const normalizeHueHost = (valueArg: unknown): string => {
31
109
  if (typeof valueArg !== 'string' || valueArg.length > 253 || valueArg !== valueArg.trim()) {
32
110
  throw new Error('Hue host must be a hostname or IP address.');
@@ -40,16 +118,17 @@ export const normalizeHueHost = (valueArg: unknown): string => {
40
118
  return host;
41
119
  };
42
120
 
43
- export const normalizeHueConfig = (configArg: IHueConfig): Required<Pick<IHueConfig, 'bridgeId' | 'host' | 'appKey' | 'port' | 'requestTimeoutMs'>> & Pick<IHueConfig, 'trustedCa'> => {
121
+ export const normalizeHueConfig = (configArg: IHueConfig): Readonly<Required<Omit<IHueConfig, 'trustedCa'>> & Pick<IHueConfig, 'trustedCa'>> => {
44
122
  const record = hueRecord(configArg);
45
- const allowed = new Set(['bridgeId', 'host', 'appKey', 'port', 'trustedCa', 'requestTimeoutMs']);
123
+ if (Object.hasOwn(record, 'appKey')) throw new Error('Hue raw appKey configuration is no longer supported. Migrate the key to a home-bound credential reference.');
124
+ const allowed = new Set(['homeId', 'bridgeId', 'host', 'credentialReference', 'port', 'trustedCa', 'requestTimeoutMs']);
46
125
  if (Reflect.ownKeys(record).some((keyArg) => typeof keyArg !== 'string' || !allowed.has(keyArg))) {
47
126
  throw new Error('Hue configuration contains unsupported fields.');
48
127
  }
49
128
  if (!hueBridgeId(record.bridgeId)) throw new Error('Hue bridgeId must contain 16 hexadecimal characters.');
50
- if (typeof record.appKey !== 'string' || !/^[a-z0-9_-]{1,256}$/i.test(record.appKey)) {
51
- throw new Error('Hue appKey must be a nonempty application key.');
52
- }
129
+ const homeId = hueText(record.homeId, 'configuration home');
130
+ const credentialReference = hueText(record.credentialReference, 'credential reference');
131
+ if (encodeURIComponent(credentialReference).length > 900) throw new Error('Hue credential reference is too long.');
53
132
  const port = record.port ?? 443;
54
133
  const requestTimeoutMs = record.requestTimeoutMs ?? 10_000;
55
134
  if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535) throw new Error('Hue port is invalid.');
@@ -58,8 +137,8 @@ export const normalizeHueConfig = (configArg: IHueConfig): Required<Pick<IHueCon
58
137
  }
59
138
  if (record.trustedCa !== undefined && (typeof record.trustedCa !== 'string' || record.trustedCa.length > 65_536
60
139
  || !record.trustedCa.includes('-----BEGIN CERTIFICATE-----'))) throw new Error('Hue trustedCa must contain a PEM certificate.');
61
- return { bridgeId: record.bridgeId.toLowerCase(), host: normalizeHueHost(record.host), appKey: record.appKey, port, requestTimeoutMs,
62
- trustedCa: typeof record.trustedCa === 'string' ? record.trustedCa : undefined };
140
+ return Object.freeze({ homeId, credentialReference, bridgeId: record.bridgeId.toLowerCase(), host: normalizeHueHost(record.host), port, requestTimeoutMs,
141
+ trustedCa: typeof record.trustedCa === 'string' ? record.trustedCa : undefined });
63
142
  };
64
143
 
65
144
  export const validateHuePatch = (patchArg: IHueLightStatePatch): void => {
@@ -96,7 +175,7 @@ const deviceResource = (resourceArg: Record<string, unknown>): IHueDeviceResourc
96
175
  productName: optionalString(product.product_name), softwareVersion: optionalString(product.software_version) } : undefined };
97
176
  };
98
177
 
99
- export const parseHueResources = (dataArg: unknown[], bridgeIdArg: string): IHueResourceResponse => {
178
+ export const parseHueResources = (dataArg: unknown[], bridgeIdArg: string, homeIdArg: string): IHueResourceResponse => {
100
179
  const devices = new Map<string, IHueDeviceResource>();
101
180
  const connectivity = new Map<string, boolean>();
102
181
  const lightResources: Record<string, unknown>[] = [];
@@ -143,7 +222,7 @@ export const parseHueResources = (dataArg: unknown[], bridgeIdArg: string): IHue
143
222
  on: { on: power.on }, dimming: dimming ? { brightness: dimming.brightness as number } : undefined,
144
223
  available: connectivity.get(owner.rid.toLowerCase()) === true };
145
224
  });
146
- return { bridge, devices: [...devices.values()], lights };
225
+ return { homeId: hueText(homeIdArg, 'resource home'), bridge, devices: [...devices.values()], lights };
147
226
  };
148
227
 
149
228
  export const parseHueEvents = (valueArg: unknown): IHueEvent[] => {
@@ -6,19 +6,27 @@ Native TypeScript integration for Philips Hue API v2 bridges. It discovers bridg
6
6
 
7
7
  For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
8
8
 
9
- ## Configuration and TLS
9
+ ## Configuration, credentials and TLS
10
10
 
11
- `IHueConfig` requires `bridgeId` (the bridge's 16-character hexadecimal ID), `host` (an IP address or hostname), and `appKey`. Optional fields are `port` (443 by default), `trustedCa` (a PEM CA certificate), and `requestTimeoutMs` (10000 by default, permitted range 50–60000).
11
+ `IHueConfig` requires `homeId`, `bridgeId` (16 hexadecimal characters), `host` and `credentialReference`. Optional fields are `port` (443), `trustedCa` (operator-supplied PEM CA) and `requestTimeoutMs` (10000, permitted range 50–60000). Setup, config flows and the public `HueClient(config, context, options)` require an independently selected `context.homeId`. Configuration and discovery cannot choose that authority. Constructor `options.homeId`, configuration home, and any supplied store must match it. The first admitted home remains bound even when readiness fails.
12
12
 
13
- Create an application key using the bridge's physical link button and the [Hue developer setup procedure](https://developers.meethue.com/develop/get-started-2/). This integration accepts that key; it does not generate or print credentials. Its config flow verifies a live resource read before returning a completed configuration. The application key travels only in the `hue-application-key` request header. Inject the host's encrypted `IConfigStore` into the normal integration setup flow when storing the resulting configuration.
13
+ Supply an `IHueCredentialProvider` in owner options or an actual `NoSqlConfigStore` scoped to that home and the `hue` domain in the context. A structural store is rejected. A provider receives a frozen request containing `homeId`, HTTPS `origin`, `bridgeId`, `credentialReference`, `signal` and a monotonic `deadline`; it must resolve the exact selected credential and honor its own non-replacing `create` contract. Provider methods and control permissions are captured at construction. Resolution is joined even if cancellation wins, and a late result cannot open HTTPS. Provider errors are redacted. Keys are resolved per operation and never retained in configuration.
14
14
 
15
- Certificate-chain verification remains enabled. The certificate must identify the configured bridge ID, even when the connection uses an IP address. Hue bridges using the Signify private CA require the operator to supply the appropriate trusted CA from the [Hue HTTPS guidance](https://developers.meethue.com/develop/application-design-guidance/using-https/). The system trust store is used when `trustedCa` is absent. No trust-on-first-use, HTTP downgrade, disabled verification or caller-supplied resource snapshots are supported. Discovery metadata does not establish a trusted bridge identity.
15
+ The encrypted-store reference key is `hue.credential.<encodeURIComponent(credentialReference)>`. Its encrypted value is `{ version: 1, homeId, origin, bridgeId, credentialReference, appKey }`; every scope field must match before the key is used. Creation uses insert-only `NoSqlConfigStore.create`, after TLS and live bridge-resource validation. `options.db` plus `options.keyProvider` can instead create an owned store wrapper at the admitted home/domain. The wrapper is closed with the client; a supplied store, database and key provider remain caller-owned. Normal persistence uses the public document-client APIs.
16
16
 
17
- Use `HueIntegration` from `@smarthome.exchange/integrations` with operator-supplied configuration:
17
+ The config flow accepts an existing key only for one-time credential insertion. Without a supplied key, it uses HTTPS `POST /api` with the physical link button, validates the resulting key against API v2 resources and passes it to the provider/store. Error type 101 offers an explicit link-button retry. An indeterminate dispatched pairing or credential creation is terminal; shutdown and cleanup never replay it. Completed config and flow output contain only the reference, and events/errors never expose the application key. Pairing requires exact `config` permission for the selected bridge ID with both `pair` and `credentials:create`; insertion of an existing key requires `credentials:create`. The application key is sent in the `hue-application-key` header for authenticated requests.
18
+
19
+ Certificate-chain verification stays enabled. The certificate must identify the configured bridge ID even when connecting by IP. Hue bridges using the Signify private CA require the trusted CA from the [Hue HTTPS guidance](https://developers.meethue.com/develop/application-design-guidance/using-https/). The system trust store is used when `trustedCa` is absent. Discovery metadata is a selection hint; setup verifies TLS identity and the live `bridge_id`.
18
20
 
19
21
  ```typescript
20
- const integration = new HueIntegration();
21
- const runtime = await integration.setup({ bridgeId, host, appKey, trustedCa }, { configStore });
22
+ import { HueIntegration } from '@smarthome.exchange/integrations';
23
+
24
+ // homeId and the actual home/domain-scoped configStore are selected by the host.
25
+ const integration = new HueIntegration({
26
+ homeId,
27
+ controlPermissions: [{ resource: 'lights', resourceId: selectedLightUuid, fields: ['on', 'brightness'] }],
28
+ });
29
+ const runtime = await integration.setup({ homeId, bridgeId, host, credentialReference, trustedCa }, { homeId, configStore });
22
30
  try {
23
31
  const lights = (await runtime.devices()).filter((device) => device.id.startsWith('hue.light.'));
24
32
  console.log(lights.map((device) => ({ id: device.id, name: device.name, online: device.online })));
@@ -27,12 +35,37 @@ try {
27
35
  }
28
36
  ```
29
37
 
38
+ ## Migration from raw appKey configurations
39
+
40
+ Raw `appKey` configurations are refused with a migration error, including when a reference is also supplied. Migrate explicitly by inserting the existing key into the selected host store and saving only the completed configuration. `configStore` must be an actual encrypted store scoped to `homeId` and `hue`:
41
+
42
+ ```typescript
43
+ const integration = new HueIntegration({
44
+ homeId,
45
+ controlPermissions: [{ resource: 'config', resourceId: bridgeId, fields: ['credentials:create'] }],
46
+ });
47
+ try {
48
+ const form = await integration.configFlow.start({
49
+ source: 'manual', integrationDomain: 'hue', id: bridgeId, host, port,
50
+ }, { homeId, configStore });
51
+ const result = await form.submit!({ credentialReference, appKey: existingAppKey, trustedCa });
52
+ if (result.kind !== 'done' || !result.config) throw new Error('Hue credential migration failed.');
53
+ // Persist result.config through the host config store; retire the old raw-key config.
54
+ } finally {
55
+ await integration.destroy();
56
+ }
57
+ ```
58
+
59
+ Use a new reference if the selected reference already exists; insertion never replaces it. Update stored entity/device targets to the home-qualified identities below. Construct a runtime owner with the desired light grants after migration; pairing permissions do not grant light controls.
60
+
30
61
  ## Reads and controls
31
62
 
32
- Setup checks both the TLS identity and the `bridge_id` returned by `/clip/v2/resource`. Device/light IDs are validated UUIDs. Entities use `light.<resource UUID>` and devices use `hue.light.<resource UUID>`; changing a display name preserves identity. This replaces the previous display-name entity IDs. The `resources` configuration fixture field has been removed.
63
+ Setup checks both the TLS identity and the `bridge_id` returned by `/clip/v2/resource`. Device/light IDs are validated UUIDs. Entities use `light.hue.<encoded home>.<resource UUID>`, light devices use `hue.light.<encoded home>.<resource UUID>`, and bridge devices use `hue.bridge.<encoded home>.<bridge-device UUID>`; changing a display name preserves identity. Entity unique IDs also include the admitted home. Two homes cannot share a routing identity. The `resources` configuration fixture field has been removed.
33
64
 
34
65
  Lights advertise brightness only when the bridge reports the `dimming` capability. Availability follows the owning device's Zigbee connectivity resource; missing or disconnected connectivity is unavailable. Manufacturer and model come from the owning device, including supported third-party Hue lights.
35
66
 
67
+ Controls default to denied. Each write requires one immutable `lights` permission matching the raw light UUID and every selected field (`on`, `brightness`). No wildcard grant is accepted. The public client enforces the same permission boundary as the runtime.
68
+
36
69
  Light services are `turn_on`, `turn_off` and `set_percentage`. `turn_on` accepts optional `brightness` from 0 to 100; `set_percentage` requires `percentage` in that same range. A target must match the current entity/device exactly; supplying both requires them to identify the same light. `writeFeature` supports boolean `on` and numeric `brightness` only for an available light's declared writable capabilities. Unsupported fields, unknown targets and values outside the protocol range are rejected. Controls capture plain-data target IDs, domain, capability and value before awaiting a fresh read; later caller mutation cannot retarget a write. Accessors, symbols, hidden fields and class instances are rejected without invoking getters. Setup cancellation and config-flow discovery endpoint hints are also captured before awaiting readiness or displaying a form.
37
70
 
38
71
  Writes use `PUT /clip/v2/resource/light/<UUID>` and require the bridge's matching resource acknowledgement. Success means the bridge accepted the write; it does not guarantee physical delivery. Accepted writes never change local read state. Each `devices()` or `entities()` call reads current authoritative resources, and writes are not automatically retried.
@@ -41,13 +74,17 @@ Writes use `PUT /clip/v2/resource/light/<UUID>` and require the bridge's matchin
41
74
 
42
75
  `subscribe()` opens authenticated HTTPS `/eventstream/clip/v2` and returns an asynchronous, idempotent unsubscribe function. Change notifications contain only relevant resource IDs/types; consumers reread authoritative state. Unknown resource payloads are omitted because they may contain credentials. Invalid frames, stream termination and transport failures produce an error event and mark the runtime unhealthy. Reads do not silently restart a failed subscription; the host may explicitly establish a new one or replace the runtime.
43
76
 
44
- Requests have whole-response deadlines and a 4 MiB body limit. Discovery uses the fixed documented `https://discovery.meethue.com/` broker with a five-second deadline and 256 KiB body limit. SSE frames are limited to 1 MiB and batches to 256 events. A client permits at most 32 outstanding connections, two event subscriptions, and three simultaneous HTTPS sockets. Caller cancellation and `destroy()` close owned requests/streams; destroy waits for them and all admitted request/subscriber continuations to settle. Subscriber callbacks may return promises. Unsubscribe has stable promise identity and joins admitted callbacks; later events are fenced. Each stream permits at most 32 pending callbacks. Synchronous throws and async rejections are observed, mark the stream unhealthy, and use a redacted subscriber error rather than being relabelled as malformed stream data. Callback failures remain visible on terminal client cleanup until explicit `HueClient.retryCleanup()` or `HueIntegration.retryCleanup()` acknowledges them. Failed transport cleanup is retained for the same retry. Retry performs cleanup only; it never replays GETs, PUTs, subscriptions, or callbacks. `destroy()` publishes a stable terminal promise before aborting and continues to return that promise after a retry. Integration and runtime destroy also have stable terminal promise identity. Integration shutdown joins pending setups and all sibling runtimes before surfacing cleanup failures, retaining failed runtime handles for explicit cleanup-only retry. Shared native home/resource admission, flow ownership, and home-qualified IDs are pending a separate breaking config/identity decision. Raw `appKey` configuration remains the current contract; home-bound credential references and existing-config migration require a separate public-contract decision.
77
+ One shared `NativeIntegrationOwner` bounds pending setups, promoted runtimes, subscriptions, finite operations and retained cleanup resources (default capacity 16). A setup lease owns the client before readiness and promotes to a runtime only after validation. Config flows use terminal child scopes on that same owner. Previously returned forms cannot submit after flow/integration shutdown; candidate endpoint and host context are captured before any await. A runtime command holds one operation lease and permits one PUT attempt. Setup/submission also occupy a lease while their client operation runs, so capacity must leave room for both.
78
+
79
+ Requests share a whole-operation deadline with credential resolution and have a 4 MiB body limit. Discovery uses the fixed documented `https://discovery.meethue.com/` broker with a five-second deadline and 256 KiB body limit. SSE frames are limited to 1 MiB and batches to 256 events. A client permits at most 32 outstanding connections, two event subscriptions, three simultaneous HTTPS sockets and 32 pending callbacks per stream. An established stream retains shared capacity until its transport and callbacks settle; its connection deadline does not expire a live subscription.
80
+
81
+ Caller cancellation and `destroy()` close owned requests/streams and join actual provider, request and subscriber continuations. Subscriber callbacks may return promises. Unsubscribe and destroy have stable promise identity; later callbacks are fenced. Synchronous throws and async rejections are observed and use redacted subscriber errors. Failed transport/store cleanup and callback failures remain retained until explicit cleanup-only retry. `HueClient.retryCleanup()`, `HueConfigFlow.retryCleanup()` and `HueIntegration.retryCleanup()` never replay GETs, PUTs, pairing, credential creation, subscriptions or callbacks. Integration shutdown drains sibling runtimes and pending submissions before reporting cleanup failure. Runtime `destroy()` closes its promoted lease.
45
82
 
46
83
  ## Verification and limits
47
84
 
48
- Tests use disposable CA-signed local TLS bridges, including untrusted issuers, a wrong bridge certificate, resource identity mismatch, exact PUT payloads, non-optimistic reads, unsupported controls, unavailable devices, malformed/oversized bodies, cancellation, plain-data/accessor validation, blocked async subscribers, sibling drainage, reentrant close, retained cleanup failures and cleanup-only retry without HTTPS replay. No physical bridge or owner's home was accessed.
85
+ Tests preserve the existing 46 cases and use disposable CA-signed local TLS bridges, including untrusted issuers, a wrong bridge certificate, resource identity mismatch, exact PUT payloads, non-optimistic reads, unsupported controls, unavailable devices, malformed/oversized bodies, cancellation, plain-data/accessor validation, blocked async subscribers, sibling drainage, reentrant close, retained cleanup failures and cleanup-only retry without HTTPS replay. Ownership regressions additionally cover home-qualified routing, default-deny exact grants, shared capacity, scoped encrypted references, late provider drainage, flow shutdown, link-button pairing and secret-free completion. No physical bridge or owner's home was accessed.
49
86
 
50
- The public [Hue API announcement](https://developers.meethue.com/new-hue-api/) establishes API v2 HTTPS, local proactive events and the supported discovery methods. Detailed Hue API and HTTPS pages require a developer login and returned login/403 here. Implemented wire resource models and event paths were cross-checked against [aiohue's upstream v2 implementation](https://github.com/home-assistant-libs/aiohue/tree/main/aiohue/v2). Aiohue's disabled-TLS and geofence keepalive workaround are not used. Physical bridge firmware compatibility, operator CA provisioning and physical command delivery remain acceptance checks. Color, color temperature, scenes, entertainment streaming, grouped lights and accessory sensors are not exposed by this runtime.
87
+ The public [Hue API announcement](https://developers.meethue.com/new-hue-api/) establishes API v2 HTTPS, local proactive events and the supported discovery methods. Detailed Hue API and HTTPS pages require a developer login and returned login/403 here. The pairing wire shape (`POST /api`, `devicetype`, `success.username`) was checked against [aiohue util.py](https://github.com/home-assistant-libs/aiohue/blob/main/aiohue/util.py). Its HTTP fallback and disabled TLS are not used. Implemented wire resource models and event paths were cross-checked against [aiohue's upstream v2 implementation](https://github.com/home-assistant-libs/aiohue/tree/main/aiohue/v2). Aiohue's disabled-TLS and geofence keepalive workaround are not used. Physical bridge firmware compatibility, operator CA provisioning and physical command delivery remain acceptance checks. Color, color temperature, scenes, entertainment streaming, grouped lights and accessory sensors are not exposed by this runtime.
51
88
 
52
89
  ## License and Legal Information
53
90