@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,89 +1,57 @@
|
|
|
1
|
-
import type * as
|
|
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
|
|
17
|
-
readonly #
|
|
18
|
-
readonly #
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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) =>
|
|
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 !==
|
|
179
|
-
return device?.id || (entity ? snapshotArg.lights.find((lightArg) => entity.deviceId ===
|
|
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:
|
|
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:
|
|
38
|
-
deviceId:
|
|
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
|
-
|
|
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<
|
|
121
|
+
export const normalizeHueConfig = (configArg: IHueConfig): Readonly<Required<Omit<IHueConfig, 'trustedCa'>> & Pick<IHueConfig, 'trustedCa'>> => {
|
|
44
122
|
const record = hueRecord(configArg);
|
|
45
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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),
|
|
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` (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|