@smarthome.exchange/integrations 2.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 +46 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
- package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
- package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
- package/dist_ts/core/classes.runtimemanager.js +11 -3
- package/dist_ts/core/types.d.ts +2 -0
- package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
- package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
- package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
- package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
- package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
- package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
- package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
- package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
- package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
- package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
- package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
- package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
- package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
- package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
- package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
- package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
- package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
- package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
- package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
- package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
- package/dist_ts/integrations/esphome/esphome.types.js +1 -1
- package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
- package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
- package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
- package/dist_ts/integrations/hue/hue.classes.client.d.ts +32 -4
- package/dist_ts/integrations/hue/hue.classes.client.js +331 -57
- package/dist_ts/integrations/hue/hue.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/hue/hue.classes.configflow.js +79 -27
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/hue/hue.classes.integration.js +83 -62
- 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 +9 -3
- package/dist_ts/integrations/hue/hue.validation.js +112 -11
- package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
- package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
- package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
- package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
- package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
- package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
- package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
- package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
- package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
- package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
- package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
- package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
- package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
- package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
- package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
- package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
- package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
- package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
- package/dist_ts/integrations/shelly/shelly.types.js +11 -2
- package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
- package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
- package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
- package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
- package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
- package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
- package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
- package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
- package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
- package/dist_ts/integrations/sonos/sonos.config.js +23 -2
- package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
- package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
- package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
- package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
- package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
- package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
- package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
- package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
- package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
- package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
- package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
- package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
- package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
- package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
- package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
- package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
- package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
- package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
- package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
- package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
- package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
- package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
- package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
- package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
- package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
- package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
- package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
- package/dist_ts/integrations/webostv/webostv.types.js +1 -1
- package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
- package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
- package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
- package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
- package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
- package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
- package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +10 -10
- package/readme.md +25 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/classes.nativeintegrationowner.ts +5 -0
- package/ts/core/classes.runtimemanager.ts +11 -2
- package/ts/core/types.ts +2 -0
- package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
- package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
- package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
- package/ts/integrations/deconz/deconz.mapper.ts +22 -5
- package/ts/integrations/deconz/deconz.types.ts +49 -6
- package/ts/integrations/deconz/deconz.validation.ts +177 -4
- package/ts/integrations/deconz/readme.md +15 -1
- package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
- package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
- package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
- package/ts/integrations/esphome/esphome.discovery.ts +3 -12
- package/ts/integrations/esphome/esphome.mapper.ts +9 -7
- package/ts/integrations/esphome/esphome.types.ts +35 -6
- package/ts/integrations/esphome/esphome.validation.ts +66 -0
- package/ts/integrations/esphome/readme.md +20 -6
- package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
- package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
- package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
- package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
- package/ts/integrations/homeassistant/readme.md +22 -6
- package/ts/integrations/hue/hue.classes.client.ts +288 -55
- package/ts/integrations/hue/hue.classes.configflow.ts +68 -25
- package/ts/integrations/hue/hue.classes.integration.ts +75 -54
- 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 +102 -13
- package/ts/integrations/hue/readme.md +49 -12
- package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
- package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
- package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
- package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
- package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
- package/ts/integrations/mqtt/mqtt.types.ts +52 -0
- package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
- package/ts/integrations/mqtt/readme.md +12 -3
- package/ts/integrations/samsungtv/readme.md +11 -5
- package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
- package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
- package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
- package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
- package/ts/integrations/shelly/readme.md +22 -7
- package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
- package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
- package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
- package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
- package/ts/integrations/shelly/shelly.mapper.ts +19 -20
- package/ts/integrations/shelly/shelly.rpc.ts +2 -0
- package/ts/integrations/shelly/shelly.types.ts +50 -2
- package/ts/integrations/shelly/shelly.validation.ts +76 -0
- package/ts/integrations/sonos/readme.md +117 -38
- package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
- package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
- package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
- package/ts/integrations/sonos/sonos.config.ts +24 -2
- package/ts/integrations/sonos/sonos.mapper.ts +13 -8
- package/ts/integrations/sonos/sonos.matchers.ts +2 -2
- package/ts/integrations/sonos/sonos.types.ts +30 -1
- package/ts/integrations/tasmota/readme.md +16 -5
- package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
- package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
- package/ts/integrations/tasmota/tasmota.config.ts +17 -0
- package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
- package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
- package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
- package/ts/integrations/tasmota/tasmota.types.ts +43 -2
- package/ts/integrations/tuya/readme.md +52 -10
- package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
- package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
- package/ts/integrations/tuya/tuya.mapper.ts +1 -1
- package/ts/integrations/tuya/tuya.types.ts +4 -0
- package/ts/integrations/tuya/tuya.validation.ts +5 -8
- package/ts/integrations/webostv/readme.md +115 -22
- package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
- package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
- package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
- package/ts/integrations/webostv/webostv.mapper.ts +4 -4
- package/ts/integrations/webostv/webostv.types.ts +18 -3
- package/ts/integrations/webostv/webostv.validation.ts +22 -1
- package/ts/integrations/zigbee2mqtt/readme.md +22 -27
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
- package/ts/integrations/zwave_js/readme.md +42 -9
- package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
- package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
- package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
- package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
- package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
- package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
- package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
- package/ts/plugins.ts +2 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
2
|
+
import { NoSqlConfigStore } from '../../core/classes.configstore.js';
|
|
3
|
+
import { NativeIntegrationOwner, type INativeOperationLease } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
5
|
+
import type { IIntegrationSetupContext } from '../../core/types.js';
|
|
6
|
+
import type { IHueConfig, IHueEvent, IHueLightStatePatch, IHueResourceResponse, IHueCredentialRequest, IHueIntegrationOptions } from './hue.types.js';
|
|
7
|
+
import { captureHueOptions, hueContentType, hueData, hueKey, hueOrigin, hueRecord, hueUuid, normalizeHueConfig, parseHueEnvelope, parseHueEvents, parseHueResources, validateHuePatch } from './hue.validation.js';
|
|
4
8
|
|
|
5
9
|
interface IHueConnection {
|
|
6
10
|
cancel(): void;
|
|
@@ -44,68 +48,211 @@ 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>();
|
|
78
|
+
readonly #operations = new Set<Promise<unknown>>();
|
|
79
|
+
readonly #callbackFailures = new Set<Error>();
|
|
52
80
|
#destroyPromise?: Promise<void>;
|
|
81
|
+
#retryPromise?: Promise<void>;
|
|
82
|
+
#agentClosed = false;
|
|
53
83
|
#streamCount = 0;
|
|
54
84
|
#healthy = false;
|
|
55
85
|
#eventFailure = false;
|
|
56
86
|
|
|
57
|
-
constructor(configArg: IHueConfig,
|
|
87
|
+
constructor(configArg: IHueConfig, contextArg: IIntegrationSetupContext, optionsArg: IHueIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
|
|
58
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.');
|
|
59
99
|
this.#agent = new plugins.https.Agent({ keepAlive: false, maxSockets: 3, maxTotalSockets: 3 });
|
|
60
|
-
|
|
61
|
-
else setupSignal?.addEventListener('abort', this.abortSetup, { once: true });
|
|
100
|
+
this.#context.abortSignal?.addEventListener('abort', this.abortSetup, { once: true });
|
|
62
101
|
}
|
|
63
102
|
|
|
103
|
+
public get canCreateCredential(): boolean { return Boolean(this.#options.credentialProvider?.create || !this.#options.credentialProvider && this.#store); }
|
|
104
|
+
|
|
64
105
|
public isHealthy(): boolean {
|
|
65
|
-
return !this.#lifetime.signal.aborted && this.#healthy && !this.#eventFailure;
|
|
106
|
+
return !this.#destroyPromise && !this.#lifetime.signal.aborted && !this.#owner.signal.aborted && !this.#context.abortSignal?.aborted && this.#healthy && !this.#eventFailure && this.#callbackFailures.size === 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
public getResources(abortSignalArg?: AbortSignal): Promise<IHueResourceResponse> {
|
|
110
|
+
return this.run((operation) => this.readResources(operation), abortSignalArg);
|
|
66
111
|
}
|
|
67
112
|
|
|
68
|
-
|
|
113
|
+
private async readResources(operationArg: IHueOperation, keyArg?: string): Promise<IHueResourceResponse> {
|
|
69
114
|
try {
|
|
70
|
-
const
|
|
71
|
-
const
|
|
115
|
+
const key = keyArg ?? await this.credential(operationArg);
|
|
116
|
+
const data = parseHueEnvelope(await this.request('GET', '/clip/v2/resource', undefined, operationArg.signal, key));
|
|
117
|
+
this.active(operationArg);
|
|
118
|
+
const resources = parseHueResources(data, this.#config.bridgeId, this.#context.homeId);
|
|
72
119
|
this.#healthy = true;
|
|
73
120
|
return resources;
|
|
74
121
|
} catch (error) { this.#healthy = false; throw error; }
|
|
75
122
|
}
|
|
76
123
|
|
|
77
|
-
public
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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);
|
|
153
|
+
validateHuePatch(captured as IHueLightStatePatch);
|
|
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);
|
|
83
161
|
const light = snapshot.lights.find((lightArg) => lightArg.id === lightId);
|
|
84
162
|
if (!light || !light.available) throw new Error('Hue light target is unknown or unavailable.');
|
|
85
163
|
if (state.brightness !== undefined && !light.dimming) throw new Error('Hue light does not support brightness.');
|
|
86
|
-
const body = { ...(state.on === undefined ? {} : { on: { on: state.on } }),
|
|
87
|
-
|
|
88
|
-
const data = parseHueEnvelope(await this.request('PUT', `/clip/v2/resource/light/${lightId}`, body,
|
|
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));
|
|
89
167
|
if (data.length !== 1) throw new Error('Hue write acknowledgement does not identify the target light.');
|
|
90
|
-
const acknowledgement = data[0];
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
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);
|
|
96
205
|
}
|
|
97
206
|
|
|
98
|
-
public
|
|
99
|
-
onErrorArg: (errorArg: Error) =>
|
|
207
|
+
public subscribeToEvents(handlerArg: (eventArg: IHueEvent) => unknown,
|
|
208
|
+
onErrorArg: (errorArg: Error) => unknown, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
209
|
+
if (typeof handlerArg !== 'function' || typeof onErrorArg !== 'function') throw new TypeError('Hue subscribers must be functions.');
|
|
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;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private async subscribe(operationArg: IHueOperation, keyArg: string, handlerArg: (eventArg: IHueEvent) => unknown,
|
|
233
|
+
onErrorArg: (errorArg: Error) => unknown, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
100
234
|
this.assertOpen(abortSignalArg);
|
|
101
235
|
if (this.#streamCount >= 2) throw new Error('Hue event subscription limit reached.');
|
|
102
236
|
this.#streamCount++;
|
|
103
237
|
let resolveClosed: () => void = () => {};
|
|
104
238
|
const closed = new Promise<void>((resolveArg) => { resolveClosed = resolveArg; });
|
|
239
|
+
operationArg.actual.add(closed);
|
|
240
|
+
void closed.then(() => operationArg.actual.delete(closed));
|
|
105
241
|
const abort = new AbortController();
|
|
106
|
-
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] : [])]);
|
|
107
243
|
let active = true;
|
|
108
244
|
signal.addEventListener('abort', () => { active = false; }, { once: true });
|
|
245
|
+
const callbacks = new Set<Promise<void>>();
|
|
246
|
+
const invoke = (workArg: () => unknown, reportArg: boolean): void => {
|
|
247
|
+
const callback = Promise.resolve().then(workArg).then(() => undefined, () => {
|
|
248
|
+
const error = new Error('Hue subscriber callback failed.');
|
|
249
|
+
if (!this.#callbackFailures.size) this.#callbackFailures.add(error);
|
|
250
|
+
this.#eventFailure = true;
|
|
251
|
+
if (reportArg) fail(error);
|
|
252
|
+
});
|
|
253
|
+
callbacks.add(callback);
|
|
254
|
+
void callback.then(() => callbacks.delete(callback));
|
|
255
|
+
};
|
|
109
256
|
let established = false;
|
|
110
257
|
let request: ReturnType<typeof plugins.https.request> | undefined;
|
|
111
258
|
const connection: IHueConnection = { closed, cancel: () => { active = false; abort.abort(); request?.destroy(); } };
|
|
@@ -119,7 +266,7 @@ export class HueClient {
|
|
|
119
266
|
active = false;
|
|
120
267
|
abort.abort();
|
|
121
268
|
request?.destroy();
|
|
122
|
-
if (established) onErrorArg(errorArg);
|
|
269
|
+
if (established) invoke(() => onErrorArg(errorArg), false);
|
|
123
270
|
};
|
|
124
271
|
const ready = new Promise<void>((resolveArg, rejectArg) => {
|
|
125
272
|
const deadline = setTimeout(() => {
|
|
@@ -133,7 +280,7 @@ export class HueClient {
|
|
|
133
280
|
};
|
|
134
281
|
request = plugins.https.request(this.requestOptions('GET', '/eventstream/clip/v2', signal, {
|
|
135
282
|
accept: 'text/event-stream', 'cache-control': 'no-cache',
|
|
136
|
-
}), (responseArg) => {
|
|
283
|
+
}, keyArg), (responseArg) => {
|
|
137
284
|
if (responseArg.statusCode !== 200 || !hueContentType(responseArg.headers['content-type'], 'text/event-stream')) {
|
|
138
285
|
responseArg.destroy();
|
|
139
286
|
settleFailure(new Error('Hue event stream rejected or returned an invalid content type.'));
|
|
@@ -145,12 +292,16 @@ export class HueClient {
|
|
|
145
292
|
resolveArg();
|
|
146
293
|
responseArg.on('data', (chunkArg: Buffer) => {
|
|
147
294
|
if (!active) return;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
295
|
+
let events: IHueEvent[];
|
|
296
|
+
try { events = decoder.feed(chunkArg); }
|
|
297
|
+
catch { settleFailure(new Error('Hue event stream contains invalid or oversized data.')); return; }
|
|
298
|
+
for (const event of events) {
|
|
299
|
+
if (!active) break;
|
|
300
|
+
if (callbacks.size >= 32) {
|
|
301
|
+
settleFailure(new Error('Hue subscriber callback capacity reached.'));
|
|
302
|
+
break;
|
|
151
303
|
}
|
|
152
|
-
|
|
153
|
-
settleFailure(new Error('Hue event stream contains invalid or oversized data.'));
|
|
304
|
+
invoke(() => handlerArg(event), true);
|
|
154
305
|
}
|
|
155
306
|
});
|
|
156
307
|
responseArg.once('error', () => settleFailure(new Error('Hue event stream transport failed.')));
|
|
@@ -160,58 +311,139 @@ export class HueClient {
|
|
|
160
311
|
request.once('error', () => settleFailure(new Error('Hue event stream transport failed.')));
|
|
161
312
|
request.once('close', () => {
|
|
162
313
|
clearTimeout(deadline);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
314
|
+
active = false;
|
|
315
|
+
void Promise.allSettled([...callbacks]).then(() => {
|
|
316
|
+
this.#connections.delete(connection);
|
|
317
|
+
this.#streamCount--;
|
|
318
|
+
resolveClosed();
|
|
319
|
+
});
|
|
166
320
|
if (!established) rejectArg(new Error('Hue event subscription was cancelled.'));
|
|
167
321
|
});
|
|
168
322
|
request.end();
|
|
169
323
|
});
|
|
170
324
|
try {
|
|
171
|
-
await ready;
|
|
325
|
+
await this.foreign(operationArg, () => ready);
|
|
172
326
|
} catch (error) {
|
|
173
327
|
connection.cancel();
|
|
174
328
|
if (!request) { this.#connections.delete(connection); this.#streamCount--; resolveClosed(); }
|
|
175
329
|
await closed;
|
|
176
330
|
throw error;
|
|
177
331
|
}
|
|
178
|
-
|
|
332
|
+
let closePromise: Promise<void> | undefined;
|
|
333
|
+
return () => {
|
|
334
|
+
closePromise ??= Promise.resolve().then(async () => { connection.cancel(); await closed; });
|
|
335
|
+
return closePromise;
|
|
336
|
+
};
|
|
179
337
|
}
|
|
180
338
|
|
|
181
339
|
public destroy(): Promise<void> {
|
|
182
|
-
if (this.#destroyPromise)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
340
|
+
if (!this.#destroyPromise) {
|
|
341
|
+
this.#destroyPromise = Promise.resolve().then(() => this.drain());
|
|
342
|
+
this.#context.abortSignal?.removeEventListener('abort', this.abortSetup);
|
|
343
|
+
this.#lifetime.abort();
|
|
344
|
+
}
|
|
186
345
|
return this.#destroyPromise;
|
|
187
346
|
}
|
|
188
347
|
|
|
189
|
-
|
|
348
|
+
/** Retries only failed cleanup and acknowledges settled callback failures; never replays callbacks or HTTPS work. */
|
|
349
|
+
public retryCleanup(): Promise<void> {
|
|
350
|
+
if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal client.');
|
|
351
|
+
if (!this.#retryPromise) {
|
|
352
|
+
this.#retryPromise = Promise.resolve().then(async () => {
|
|
353
|
+
await this.#destroyPromise!.catch(() => undefined);
|
|
354
|
+
this.#callbackFailures.clear();
|
|
355
|
+
await this.drain(true);
|
|
356
|
+
});
|
|
357
|
+
const settled = (): void => { this.#retryPromise = undefined; };
|
|
358
|
+
void this.#retryPromise.then(settled, settled);
|
|
359
|
+
}
|
|
360
|
+
return this.#retryPromise;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private readonly abortSetup = (): void => { void this.destroy().catch(() => undefined); };
|
|
364
|
+
|
|
365
|
+
private async drain(retryArg = false): Promise<void> {
|
|
366
|
+
const results = await Promise.allSettled([
|
|
367
|
+
...[...this.#connections].map(async (connectionArg) => { connectionArg.cancel(); await connectionArg.closed; }),
|
|
368
|
+
Promise.resolve().then(() => { if (!this.#agentClosed) { this.#agent.destroy(); this.#agentClosed = true; } }),
|
|
369
|
+
]);
|
|
370
|
+
await Promise.allSettled([...this.#operations]);
|
|
371
|
+
const failures = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected')
|
|
372
|
+
.map(() => new Error('Hue transport cleanup failed.'));
|
|
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);
|
|
378
|
+
if (failures.length) throw new AggregateError(failures, 'Hue cleanup failed; explicit cleanup retry is required.');
|
|
379
|
+
}
|
|
190
380
|
|
|
191
|
-
private
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
381
|
+
private run<TResult>(workArg: (operationArg: IHueOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
|
|
382
|
+
this.assertOpen(signalArg);
|
|
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
|
+
});
|
|
393
|
+
this.#operations.add(operation);
|
|
394
|
+
void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
|
|
395
|
+
return operation;
|
|
396
|
+
}
|
|
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.');
|
|
196
428
|
}
|
|
197
429
|
|
|
198
430
|
private assertOpen(abortSignalArg?: AbortSignal): void {
|
|
199
|
-
if (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.');
|
|
200
432
|
if (this.#connections.size >= 32) throw new Error('Hue concurrent request limit reached.');
|
|
201
433
|
}
|
|
202
434
|
|
|
203
|
-
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 {
|
|
204
436
|
return { hostname: this.#config.host, port: this.#config.port, path: pathArg, method: methodArg,
|
|
205
437
|
servername: this.#config.bridgeId, ca: this.#config.trustedCa, rejectUnauthorized: true,
|
|
206
438
|
checkServerIdentity: (_hostnameArg, certificateArg) => plugins.tls.checkServerIdentity(this.#config.bridgeId, certificateArg),
|
|
207
439
|
signal: signalArg, agent: this.#agent,
|
|
208
|
-
headers: { 'hue-application-key':
|
|
440
|
+
headers: { ...(keyArg === undefined ? {} : { 'hue-application-key': keyArg }), accept: 'application/json', ...headersArg } };
|
|
209
441
|
}
|
|
210
442
|
|
|
211
|
-
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> {
|
|
212
444
|
this.assertOpen(abortSignalArg);
|
|
213
445
|
const abort = new AbortController();
|
|
214
|
-
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] : [])]);
|
|
215
447
|
const encoded = bodyArg === undefined ? undefined : JSON.stringify(bodyArg);
|
|
216
448
|
let request: ReturnType<typeof plugins.https.request> | undefined;
|
|
217
449
|
let resolveClosed: () => void = () => {};
|
|
@@ -223,7 +455,7 @@ export class HueClient {
|
|
|
223
455
|
return await new Promise<unknown>((resolveArg, rejectArg) => {
|
|
224
456
|
request = plugins.https.request(this.requestOptions(methodArg, pathArg, signal, encoded === undefined ? {} : {
|
|
225
457
|
'content-type': 'application/json', 'content-length': String(Buffer.byteLength(encoded)),
|
|
226
|
-
}), (responseArg) => {
|
|
458
|
+
}, keyArg), (responseArg) => {
|
|
227
459
|
if (responseArg.statusCode !== 200 || !hueContentType(responseArg.headers['content-type'], 'application/json')) {
|
|
228
460
|
responseArg.destroy();
|
|
229
461
|
rejectArg(new Error(`Hue API request failed with HTTP status ${responseArg.statusCode ?? 0}.`));
|
|
@@ -247,6 +479,7 @@ export class HueClient {
|
|
|
247
479
|
});
|
|
248
480
|
request.once('error', () => rejectArg(new Error('Hue HTTPS request failed or was cancelled.')));
|
|
249
481
|
request.once('close', () => { this.#connections.delete(connection); resolveClosed(); });
|
|
482
|
+
dispatchArg?.();
|
|
250
483
|
request.end(encoded);
|
|
251
484
|
});
|
|
252
485
|
} finally {
|
|
@@ -1,39 +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, 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
|
-
|
|
9
|
-
|
|
23
|
+
const context = this.#scope.admit(contextArg);
|
|
24
|
+
if (this.#terminal) throw new Error('Hue config flow is closed.');
|
|
25
|
+
const candidate = hueData(candidateArg, ['id', 'source', 'host', 'port', 'name', 'integrationDomain', 'manufacturer', 'model', 'serialNumber', 'macAddress', 'metadata']);
|
|
26
|
+
if (candidate.integrationDomain !== 'hue') throw new Error('Hue flow requires a Hue candidate.');
|
|
27
|
+
const bridgeId = hueBridgeId(candidate.id) ? candidate.id : undefined;
|
|
28
|
+
const port = candidate.port;
|
|
29
|
+
if (port !== undefined && (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('Hue discovered port is invalid.');
|
|
10
30
|
let host: string | undefined;
|
|
11
|
-
try { host = normalizeHueHost(
|
|
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
|
+
};
|
|
12
67
|
return {
|
|
13
68
|
kind: 'form', title: 'Connect Philips Hue Bridge',
|
|
14
|
-
description: '
|
|
69
|
+
description: 'Select a credential reference. Press the physical link button to pair, or supply an existing application key for one-time insertion into the host credential store. Setup verifies TLS identity and live resources.',
|
|
15
70
|
fields: [
|
|
16
71
|
...(!bridgeId ? [{ name: 'bridgeId', label: 'Hue bridge ID', type: 'text' as const, required: true }] : []),
|
|
17
72
|
...(!host ? [{ name: 'host', label: 'Bridge hostname or IP address', type: 'text' as const, required: true }] : []),
|
|
18
|
-
{ name: '
|
|
73
|
+
{ name: 'credentialReference', label: 'Credential reference', type: 'text', required: true },
|
|
74
|
+
{ name: 'appKey', label: 'Existing Hue app key (optional)', type: 'password' },
|
|
19
75
|
{ name: 'trustedCa', label: 'Trusted Hue CA certificate (PEM)', type: 'text' },
|
|
20
|
-
],
|
|
21
|
-
submit: async (valuesArg) => {
|
|
22
|
-
let client: HueClient | undefined;
|
|
23
|
-
try {
|
|
24
|
-
const config = normalizeHueConfig({ bridgeId: bridgeId ?? valuesArg.bridgeId as string,
|
|
25
|
-
host: host ?? valuesArg.host as string, port: candidateArg.port ?? 443,
|
|
26
|
-
appKey: valuesArg.appKey as string, trustedCa: valuesArg.trustedCa === '' ? undefined : valuesArg.trustedCa as string | undefined });
|
|
27
|
-
client = new HueClient(config);
|
|
28
|
-
await client.getResources();
|
|
29
|
-
return { kind: 'done', title: 'Hue bridge verified', config };
|
|
30
|
-
} catch {
|
|
31
|
-
return { kind: 'error', title: 'Hue bridge could not be verified',
|
|
32
|
-
error: 'Check the bridge ID, HTTPS endpoint, trusted CA and application key. The bridge must support Hue API v2.' };
|
|
33
|
-
} finally {
|
|
34
|
-
await client?.destroy();
|
|
35
|
-
}
|
|
36
|
-
},
|
|
76
|
+
], submit,
|
|
37
77
|
};
|
|
38
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(); }
|
|
39
82
|
}
|