@smarthome.exchange/integrations 2.0.0 → 3.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 +35 -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 +6 -1
- package/dist_ts/integrations/hue/hue.classes.client.js +120 -45
- package/dist_ts/integrations/hue/hue.classes.configflow.js +16 -7
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +2 -0
- package/dist_ts/integrations/hue/hue.classes.integration.js +103 -30
- package/dist_ts/integrations/hue/hue.validation.d.ts +1 -0
- package/dist_ts/integrations/hue/hue.validation.js +14 -4
- 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 +8 -8
- 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 +110 -41
- package/ts/integrations/hue/hue.classes.configflow.ts +12 -6
- package/ts/integrations/hue/hue.classes.integration.ts +92 -26
- package/ts/integrations/hue/hue.validation.ts +13 -3
- package/ts/integrations/hue/readme.md +3 -3
- 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,291 +1,387 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import { NoSqlConfigStore } from '../../core/classes.configstore.js';
|
|
3
|
+
import { NativeIntegrationOwner, type INativeOperationLease } from '../../core/classes.nativeintegrationowner.js';
|
|
4
|
+
import type { INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
5
|
+
import type { IIntegrationSetupContext } from '../../core/types.js';
|
|
6
|
+
import type { IDeconzConfig, IDeconzCredentialRequest, IDeconzGatewayConfig, IDeconzGroupActionPatch, IDeconzIntegrationOptions, IDeconzLightStatePatch, IDeconzSensorConfigPatch, IDeconzSensorStatePatch, IDeconzSnapshot, IDeconzWebsocketEvent, IDeconzWriteResult } from './deconz.types.js';
|
|
7
|
+
import { captureDeconzOptions, deconzCurrentPatch, deconzData, deconzEndpoint, deconzGatewayConfig, deconzGroupActionPatch, deconzLightStatePatch, deconzPort, deconzSensorConfigPatch, deconzSensorStatePatch, deconzSnapshot, deconzText, deconzWebsocketEvent, isDeconzRecord, normalizeDeconzConfig } from './deconz.validation.js';
|
|
3
8
|
|
|
4
9
|
export class DeconzRequestError extends Error {
|
|
5
10
|
constructor(messageArg: string, public readonly apiErrorType?: number) { super(messageArg); }
|
|
6
11
|
}
|
|
7
12
|
|
|
8
|
-
export class
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
private readonly lifetime = new AbortController();
|
|
13
|
-
private readonly operations = new Set<Promise<unknown>>();
|
|
14
|
-
private readonly subscriptions = new Set<() => Promise<void>>();
|
|
15
|
-
private streamHealthy = true;
|
|
16
|
-
private gatewayHealthy = false;
|
|
17
|
-
private gatewayId?: string;
|
|
13
|
+
export class DeconzWriteError extends Error {
|
|
14
|
+
public readonly effectOutcome = 'indeterminate' as const;
|
|
15
|
+
constructor() { super('deCONZ command was dispatched; its outcome is indeterminate.'); }
|
|
16
|
+
}
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
throw new Error('deCONZ requestTimeoutMs must be a positive timer-safe integer.');
|
|
26
|
-
}
|
|
27
|
-
if (config.host !== undefined) this.endpoint = deconzEndpoint(config);
|
|
28
|
-
if (!config.snapshot && !this.endpoint) throw new Error('deCONZ host is required.');
|
|
29
|
-
if (config.apiKey !== undefined && (typeof config.apiKey !== 'string' || !config.apiKey.trim() || config.apiKey.trim() !== config.apiKey)) {
|
|
30
|
-
throw new Error('deCONZ API key must be a non-empty string.');
|
|
31
|
-
}
|
|
32
|
-
if (config.websocketPort !== undefined) deconzPort(config.websocketPort);
|
|
33
|
-
if (config.bridgeId !== undefined && (typeof config.bridgeId !== 'string' || !config.bridgeId)) throw new Error('Invalid deCONZ configured gateway identity.');
|
|
34
|
-
if (config.enableWebSocket !== undefined && typeof config.enableWebSocket !== 'boolean') throw new Error('Invalid deCONZ WebSocket setting.');
|
|
35
|
-
}
|
|
18
|
+
interface IDeconzOperation {
|
|
19
|
+
readonly lease: INativeOperationLease;
|
|
20
|
+
readonly signal: AbortSignal;
|
|
21
|
+
readonly deadline: number;
|
|
22
|
+
readonly actual: Set<Promise<unknown>>;
|
|
23
|
+
}
|
|
36
24
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
25
|
+
export interface IDeconzClientOperation {
|
|
26
|
+
readonly isHealthy: boolean;
|
|
27
|
+
getSnapshot(signalArg?: AbortSignal): Promise<IDeconzSnapshot>;
|
|
28
|
+
setLightState(idArg: string, patchArg: IDeconzLightStatePatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult>;
|
|
29
|
+
setGroupState(idArg: string, patchArg: IDeconzGroupActionPatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult>;
|
|
30
|
+
setSensorConfig(idArg: string, patchArg: IDeconzSensorConfigPatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult>;
|
|
31
|
+
setSensorState(idArg: string, patchArg: IDeconzSensorStatePatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult>;
|
|
32
|
+
}
|
|
40
33
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const snapshot = deconzSnapshot(this.config.snapshot ? structuredClone(this.config.snapshot) : await this.requestJson('GET', '', undefined, abortSignalArg));
|
|
45
|
-
this.assertGatewayIdentity(snapshot.config!);
|
|
46
|
-
this.gatewayHealthy = true;
|
|
47
|
-
return snapshot;
|
|
48
|
-
} catch (error) { this.gatewayHealthy = false; throw error; }
|
|
49
|
-
}
|
|
34
|
+
interface IDeconzSubscription {
|
|
35
|
+
close(retryArg?: boolean): Promise<void>;
|
|
36
|
+
}
|
|
50
37
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
38
|
+
/** Credentials and persistence resources are supplied by the independently selected host. */
|
|
39
|
+
export class DeconzClient {
|
|
40
|
+
readonly #config: Readonly<IDeconzConfig>;
|
|
41
|
+
readonly #options: Readonly<IDeconzIntegrationOptions>;
|
|
42
|
+
readonly #context: INativeAdmissionContext;
|
|
43
|
+
readonly #owner: NativeIntegrationOwner;
|
|
44
|
+
readonly #ownsOwner: boolean;
|
|
45
|
+
readonly #endpoint: URL;
|
|
46
|
+
readonly #lifetime: AbortController;
|
|
47
|
+
readonly #operations = new Set<Promise<unknown>>();
|
|
48
|
+
readonly #subscriptions = new Set<IDeconzSubscription>();
|
|
49
|
+
readonly #callbackFailures = new Set<Error>();
|
|
50
|
+
readonly #store?: NoSqlConfigStore;
|
|
51
|
+
readonly #ownsStore: boolean;
|
|
52
|
+
#closePromise?: Promise<void>;
|
|
53
|
+
#retryPromise?: Promise<void>;
|
|
54
|
+
#streamHealthy = true;
|
|
55
|
+
#gatewayHealthy = false;
|
|
56
|
+
#onAbort?: () => void;
|
|
60
57
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
constructor(configArg: IDeconzConfig, contextArg: IIntegrationSetupContext, optionsArg: IDeconzIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
|
|
59
|
+
this.#config = normalizeDeconzConfig(configArg);
|
|
60
|
+
this.#options = captureDeconzOptions(optionsArg);
|
|
61
|
+
this.#ownsOwner = !ownerArg;
|
|
62
|
+
this.#owner = ownerArg ?? new NativeIntegrationOwner('deconz', { homeId: this.#options.homeId, capacity: this.#options.capacity });
|
|
63
|
+
this.#context = this.#owner.admission.admit(contextArg, this.#config.homeId);
|
|
64
|
+
if (this.#options.homeId !== undefined && this.#options.homeId !== this.#context.homeId) throw new Error('deCONZ constructor home does not match the host home.');
|
|
65
|
+
this.#endpoint = deconzEndpoint(this.#config);
|
|
66
|
+
if (this.#context.configStore && this.#options.db) throw new Error('deCONZ requires one selected credential store.');
|
|
67
|
+
this.#ownsStore = !this.#context.configStore && Boolean(this.#options.db);
|
|
68
|
+
if (this.#options.db && !(this.#options.db instanceof plugins.nosqldb.SmartdataDb)) throw new TypeError('deCONZ requires an actual caller-owned database.');
|
|
69
|
+
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: 'deconz', timeoutMs: this.#config.requestTimeoutMs }) : undefined);
|
|
70
|
+
if (!this.#options.credentialProvider && !this.#store) throw new Error('deCONZ requires an explicit credential provider or an actual encrypted config store.');
|
|
71
|
+
this.#lifetime = new AbortController();
|
|
72
|
+
if (this.#context.abortSignal) {
|
|
73
|
+
this.#onAbort = () => { void this.destroy().catch(() => undefined); };
|
|
74
|
+
this.#context.abortSignal.addEventListener('abort', this.#onAbort, { once: true });
|
|
66
75
|
}
|
|
67
|
-
return result[0].success.username;
|
|
68
76
|
}
|
|
69
77
|
|
|
70
|
-
public
|
|
71
|
-
|
|
72
|
-
await this.put(`/lights/${this.resourceId(idArg)}/state`, patch, signalArg);
|
|
73
|
-
}
|
|
78
|
+
public get isHealthy(): boolean { return !this.#closePromise && !this.#lifetime.signal.aborted && !this.#context.abortSignal?.aborted && !this.#owner.signal.aborted && this.#streamHealthy && this.#gatewayHealthy; }
|
|
79
|
+
public get canCreateCredential(): boolean { return Boolean(this.#options.credentialProvider?.create || !this.#options.credentialProvider && this.#store); }
|
|
74
80
|
|
|
75
|
-
public async
|
|
76
|
-
|
|
77
|
-
await this.put(`/groups/${this.resourceId(idArg)}/action`, patch, signalArg);
|
|
78
|
-
}
|
|
81
|
+
public async getSnapshot(signalArg?: AbortSignal): Promise<IDeconzSnapshot> { return this.#run((operation) => this.#snapshot(operation), signalArg); }
|
|
82
|
+
public async getGatewayConfig(signalArg?: AbortSignal): Promise<IDeconzGatewayConfig> { return this.#run(async (operation) => (await this.#snapshot(operation)).config!, signalArg); }
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
/** Keeps a runtime command, its actual callbacks, and its readback on one shared operation lease. */
|
|
85
|
+
public execute<TResult>(workArg: (clientArg: IDeconzClientOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<{ result: TResult; observation?: IDeconzWriteResult }> {
|
|
86
|
+
return this.#run(async (operation) => {
|
|
87
|
+
let open = true;
|
|
88
|
+
let written = false;
|
|
89
|
+
let selectedSnapshot: IDeconzSnapshot | undefined;
|
|
90
|
+
let observation: IDeconzWriteResult | undefined;
|
|
91
|
+
const check = (signal?: AbortSignal): void => { if (!open) throw new Error('deCONZ command session is closed.'); signal?.throwIfAborted(); this.#active(operation); };
|
|
92
|
+
const write = async (path: string, patch: Record<string, unknown>, signal?: AbortSignal): Promise<IDeconzWriteResult> => { check(signal); if (written) throw new Error('deCONZ command permits one write attempt.'); written = true; observation = await this.#write(operation, path, patch, selectedSnapshot); return observation; };
|
|
93
|
+
const session: IDeconzClientOperation = Object.freeze({
|
|
94
|
+
isHealthy: this.isHealthy,
|
|
95
|
+
getSnapshot: async (signal?: AbortSignal) => { check(signal); selectedSnapshot = await this.#snapshot(operation); return structuredClone(selectedSnapshot); },
|
|
96
|
+
setLightState: async (id: string, patch: IDeconzLightStatePatch, signal?: AbortSignal) => write(`/lights/${this.#resourceId(id)}/state`, deconzLightStatePatch(patch), signal),
|
|
97
|
+
setGroupState: async (id: string, patch: IDeconzGroupActionPatch, signal?: AbortSignal) => write(`/groups/${this.#resourceId(id)}/action`, deconzGroupActionPatch(patch), signal),
|
|
98
|
+
setSensorConfig: async (id: string, patch: IDeconzSensorConfigPatch, signal?: AbortSignal) => write(`/sensors/${this.#resourceId(id)}/config`, deconzSensorConfigPatch(patch), signal),
|
|
99
|
+
setSensorState: async (id: string, patch: IDeconzSensorStatePatch, signal?: AbortSignal) => write(`/sensors/${this.#resourceId(id)}/state`, this.#sensorPatch(patch), signal),
|
|
100
|
+
});
|
|
101
|
+
try { const result = await this.#foreign(operation, () => workArg(session)); return { result, observation }; }
|
|
102
|
+
finally { open = false; }
|
|
103
|
+
}, signalArg);
|
|
83
104
|
}
|
|
84
105
|
|
|
85
|
-
public
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
public acquireApiKey(deviceTypeArg = 'smarthome.exchange', signalArg?: AbortSignal): Promise<string> {
|
|
107
|
+
deconzText(deviceTypeArg, 'client name', 40);
|
|
108
|
+
return this.#run(async (operation) => {
|
|
109
|
+
this.#permission('config', this.#config.bridgeId, ['pair', 'credentials:create']);
|
|
110
|
+
if (!this.canCreateCredential) throw new Error('deCONZ pairing requires credential creation by the selected provider or encrypted store.');
|
|
111
|
+
await this.#basic(operation);
|
|
112
|
+
let dispatched = false;
|
|
113
|
+
try {
|
|
114
|
+
const result = await this.#request(operation, 'POST', '/api', { devicetype: deviceTypeArg }, () => { dispatched = true; });
|
|
115
|
+
if (!Array.isArray(result) || result.length !== 1 || !isDeconzRecord(result[0]) || !isDeconzRecord(result[0].success)) throw new Error('Invalid deCONZ pairing acknowledgement.');
|
|
116
|
+
return this.#key(result[0].success.username);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error instanceof DeconzRequestError && error.apiErrorType === 101) throw error;
|
|
119
|
+
if (dispatched) throw new DeconzWriteError();
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}, signalArg);
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
public
|
|
93
|
-
|
|
125
|
+
public createCredential(apiKeyArg: string, signalArg?: AbortSignal): Promise<void> {
|
|
126
|
+
const apiKey = this.#key(apiKeyArg);
|
|
127
|
+
return this.#run(async (operation) => {
|
|
128
|
+
this.#permission('config', this.#config.bridgeId, ['credentials:create']);
|
|
129
|
+
if (!this.canCreateCredential) throw new Error('deCONZ credential creation is unavailable.');
|
|
130
|
+
await this.#snapshot(operation, apiKey);
|
|
131
|
+
const request = this.#credentialRequest(operation);
|
|
132
|
+
let dispatched = false;
|
|
133
|
+
try {
|
|
134
|
+
if (this.#options.credentialProvider?.create) await this.#foreign(operation, () => { dispatched = true; return this.#options.credentialProvider!.create!(request, apiKey); });
|
|
135
|
+
else {
|
|
136
|
+
await this.#foreign(operation, () => this.#store!.initialize({ signal: operation.signal }));
|
|
137
|
+
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, apiKey }); });
|
|
138
|
+
}
|
|
139
|
+
this.#active(operation);
|
|
140
|
+
} catch (error) { if (dispatched) throw new DeconzWriteError(); throw error; }
|
|
141
|
+
}, signalArg);
|
|
94
142
|
}
|
|
95
143
|
|
|
96
|
-
public async
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
144
|
+
public async setLightState(idArg: string, patchArg: IDeconzLightStatePatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult> { return this.put(`/lights/${this.#resourceId(idArg)}/state`, deconzLightStatePatch(patchArg), signalArg); }
|
|
145
|
+
public async setGroupState(idArg: string, patchArg: IDeconzGroupActionPatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult> { return this.put(`/groups/${this.#resourceId(idArg)}/action`, deconzGroupActionPatch(patchArg), signalArg); }
|
|
146
|
+
public async setSensorConfig(idArg: string, patchArg: IDeconzSensorConfigPatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult> { return this.put(`/sensors/${this.#resourceId(idArg)}/config`, deconzSensorConfigPatch(patchArg), signalArg); }
|
|
147
|
+
public async setSensorState(idArg: string, patchArg: IDeconzSensorStatePatch, signalArg?: AbortSignal): Promise<IDeconzWriteResult> { return this.put(`/sensors/${this.#resourceId(idArg)}/state`, this.#sensorPatch(patchArg), signalArg); }
|
|
148
|
+
public async recallScene(groupIdArg: string, sceneIdArg: string, signalArg?: AbortSignal): Promise<IDeconzWriteResult> { return this.put(`/groups/${this.#resourceId(groupIdArg)}/scenes/${this.#resourceId(sceneIdArg)}/recall`, {}, signalArg); }
|
|
149
|
+
public async put(pathArg: string, dataArg: Record<string, unknown>, signalArg?: AbortSignal): Promise<IDeconzWriteResult> {
|
|
150
|
+
const match = /^\/(lights|groups|sensors)\/([a-z0-9_-]+)\/(state|action|config)$/i.exec(pathArg);
|
|
151
|
+
const scene = /^\/groups\/([a-z0-9_-]+)\/scenes\/([a-z0-9_-]+)\/recall$/i.exec(pathArg);
|
|
152
|
+
if (!match && !scene) throw new Error('Unsupported deCONZ command path.');
|
|
153
|
+
const patch = scene ? deconzData(dataArg, []) : match![1] === 'lights' && match![3] === 'state' ? deconzLightStatePatch(dataArg) : match![1] === 'groups' && match![3] === 'action' ? deconzGroupActionPatch(dataArg) : match![1] === 'sensors' && match![3] === 'config' ? deconzSensorConfigPatch(dataArg) : match![1] === 'sensors' && match![3] === 'state' ? this.#sensorPatch(dataArg) : undefined;
|
|
154
|
+
if (!patch) throw new Error('Unsupported deCONZ command endpoint.');
|
|
155
|
+
return this.#run((operation) => this.#write(operation, pathArg, patch), signalArg);
|
|
100
156
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
this.assertWriteAcknowledgement(result);
|
|
105
|
-
return result as TResult;
|
|
157
|
+
public async post(pathArg: string, dataArg: Record<string, unknown>, signalArg?: AbortSignal): Promise<never> {
|
|
158
|
+
void pathArg; void dataArg; void signalArg;
|
|
159
|
+
throw new Error('deCONZ arbitrary creation requires a supported current control contract.');
|
|
106
160
|
}
|
|
107
161
|
|
|
108
|
-
public
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
162
|
+
public subscribeToEvents(handlerArg: (eventArg: IDeconzWebsocketEvent) => unknown, signalArg?: AbortSignal, errorHandlerArg?: (errorArg: Error) => unknown): Promise<() => Promise<void>> {
|
|
163
|
+
if (typeof handlerArg !== 'function' || errorHandlerArg !== undefined && typeof errorHandlerArg !== 'function') throw new TypeError('deCONZ subscriber requires data functions.');
|
|
164
|
+
let resolveSubscription: (closeArg: () => Promise<void>) => void = () => {};
|
|
165
|
+
let rejectSubscription: (errorArg: unknown) => void = () => {};
|
|
166
|
+
let issued = false;
|
|
167
|
+
const subscribed = new Promise<() => Promise<void>>((resolve, reject) => { resolveSubscription = resolve; rejectSubscription = reject; });
|
|
168
|
+
let admitted: Promise<void>;
|
|
169
|
+
try { admitted = this.#run(async (operation) => {
|
|
170
|
+
if (this.#config.enableWebSocket === false) { issued = true; resolveSubscription(async () => {}); return; }
|
|
171
|
+
const config = (await this.#snapshot(operation)).config!;
|
|
172
|
+
const port = config.websocketport;
|
|
173
|
+
if (port === undefined) throw new Error('deCONZ gateway did not advertise a WebSocket port.');
|
|
174
|
+
this.#active(operation);
|
|
175
|
+
if (typeof globalThis.WebSocket !== 'function') throw new Error('deCONZ requires native WebSocket support.');
|
|
176
|
+
const url = new URL(this.#endpoint); url.protocol = 'ws:'; url.port = String(deconzPort(port));
|
|
177
|
+
const signal = AbortSignal.any([this.#lifetime.signal, this.#owner.signal, ...(this.#context.abortSignal ? [this.#context.abortSignal] : []), ...(signalArg ? [signalArg] : [])]);
|
|
178
|
+
const callbacks = new Set<Promise<unknown>>();
|
|
179
|
+
let closing = false; let opened = false; let closePromise: Promise<void> | undefined; let closeFailed = false;
|
|
180
|
+
let resolveOpen: () => void = () => {}; let rejectOpen: (errorArg: Error) => void = () => {};
|
|
181
|
+
const ready = new Promise<void>((resolve, reject) => { resolveOpen = resolve; rejectOpen = reject; });
|
|
182
|
+
// Keep the subscription's actual lifetime on the shared lease after the caller receives its close handle.
|
|
183
|
+
let releaseLifetime: () => void = () => {};
|
|
184
|
+
const lifetime = new Promise<void>((resolve) => { releaseLifetime = resolve; });
|
|
185
|
+
operation.actual.add(lifetime);
|
|
186
|
+
void lifetime.then(() => operation.actual.delete(lifetime));
|
|
187
|
+
let socket: WebSocket;
|
|
188
|
+
try { socket = new WebSocket(url); } catch { releaseLifetime(); throw new Error('deCONZ WebSocket allocation failed.'); }
|
|
189
|
+
const cleanup = (): void => {
|
|
190
|
+
socket.removeEventListener('open', onOpen); socket.removeEventListener('message', message); socket.removeEventListener('error', onError); socket.removeEventListener('close', onClose); signal.removeEventListener('abort', onAbort);
|
|
191
|
+
};
|
|
192
|
+
const subscription: IDeconzSubscription = { close: (retryArg = false) => {
|
|
193
|
+
if (retryArg && closeFailed) { closePromise = undefined; closeFailed = false; }
|
|
194
|
+
if (!closePromise) {
|
|
195
|
+
closing = true;
|
|
196
|
+
closePromise = Promise.resolve().then(async () => {
|
|
197
|
+
const closed = socket.readyState === WebSocket.CLOSED ? Promise.resolve() : new Promise<void>((resolve, reject) => {
|
|
198
|
+
const done = (): void => { clearTimeout(timer); socket.removeEventListener('close', done); resolve(); };
|
|
199
|
+
const timer = setTimeout(() => { socket.removeEventListener('close', done); reject(new Error('deCONZ WebSocket close timed out.')); }, this.#config.requestTimeoutMs);
|
|
200
|
+
socket.addEventListener('close', done, { once: true });
|
|
201
|
+
try { socket.close(); } catch { clearTimeout(timer); socket.removeEventListener('close', done); reject(new Error('deCONZ WebSocket close failed.')); }
|
|
202
|
+
});
|
|
203
|
+
const [closedResult] = await Promise.allSettled([closed, ...callbacks]);
|
|
204
|
+
const errors = closedResult.status === 'rejected' ? [closedResult] : [];
|
|
205
|
+
if (errors.length) throw new AggregateError(errors.map(() => new Error('deCONZ subscription cleanup failed.')), 'deCONZ subscription cleanup failed.');
|
|
206
|
+
cleanup(); this.#subscriptions.delete(subscription);
|
|
207
|
+
});
|
|
208
|
+
void closePromise.then(releaseLifetime, () => { closeFailed = true; releaseLifetime(); });
|
|
209
|
+
}
|
|
210
|
+
return closePromise;
|
|
211
|
+
} };
|
|
212
|
+
this.#subscriptions.add(subscription);
|
|
213
|
+
const invoke = (work: () => unknown): void => {
|
|
214
|
+
if (closing || signal.aborted) return;
|
|
215
|
+
let callback: Promise<unknown>;
|
|
216
|
+
try { callback = this.#run((leaseOperation) => this.#foreign(leaseOperation, async () => {
|
|
217
|
+
try { await work(); } catch { this.#callbackFailures.add(new Error('deCONZ subscriber failed.')); this.#streamHealthy = false; throw new Error('deCONZ subscriber failed.'); }
|
|
218
|
+
})); }
|
|
219
|
+
catch { this.#callbackFailures.add(new Error('deCONZ subscriber admission failed.')); this.#streamHealthy = false; return; }
|
|
220
|
+
callbacks.add(callback);
|
|
221
|
+
void callback.then(() => callbacks.delete(callback), () => { callbacks.delete(callback); if (!closing && !signal.aborted) { this.#callbackFailures.add(new Error('deCONZ subscriber failed.')); this.#streamHealthy = false; } });
|
|
222
|
+
};
|
|
223
|
+
const report = (messageArg: string): void => { this.#streamHealthy = false; const error = new Error(messageArg); if (!opened) rejectOpen(error); else if (errorHandlerArg) invoke(() => errorHandlerArg(error)); };
|
|
224
|
+
const message = (eventArg: MessageEvent<unknown>): void => {
|
|
225
|
+
if (closing || signal.aborted) return;
|
|
226
|
+
try {
|
|
227
|
+
if (typeof eventArg.data !== 'string' || eventArg.data.length > 1048576) throw new Error('Invalid deCONZ event.');
|
|
228
|
+
const event = deconzWebsocketEvent(JSON.parse(eventArg.data) as unknown);
|
|
229
|
+
invoke(() => handlerArg(event));
|
|
230
|
+
} catch { report('deCONZ WebSocket received an invalid event.'); }
|
|
231
|
+
};
|
|
232
|
+
const onOpen = (): void => { opened = true; this.#streamHealthy = true; resolveOpen(); };
|
|
233
|
+
const onError = (): void => report('deCONZ WebSocket connection failed.');
|
|
234
|
+
const onClose = (): void => { if (!closing) { report('deCONZ WebSocket connection closed.'); void subscription.close().catch(() => { this.#streamHealthy = false; }); } };
|
|
235
|
+
const onAbort = (): void => { rejectOpen(new Error('deCONZ subscription cancelled.')); void subscription.close().catch(() => { this.#streamHealthy = false; }); };
|
|
236
|
+
const onDeadline = (): void => rejectOpen(this.#cancelled(operation));
|
|
237
|
+
socket.addEventListener('open', onOpen, { once: true }); socket.addEventListener('message', message); socket.addEventListener('error', onError); socket.addEventListener('close', onClose); signal.addEventListener('abort', onAbort, { once: true });
|
|
238
|
+
operation.signal.addEventListener('abort', onDeadline, { once: true });
|
|
136
239
|
try {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
};
|
|
148
|
-
const onAbort = () => {
|
|
149
|
-
rejectOpen(new Error('deCONZ subscription cancelled.'));
|
|
150
|
-
void close().catch(() => {
|
|
151
|
-
this.streamHealthy = false;
|
|
152
|
-
errorHandlerArg?.(new Error('deCONZ WebSocket cleanup failed.'));
|
|
153
|
-
});
|
|
154
|
-
};
|
|
155
|
-
const cleanup = () => {
|
|
156
|
-
socket.removeEventListener('open', onOpen);
|
|
157
|
-
socket.removeEventListener('message', message);
|
|
158
|
-
socket.removeEventListener('error', onError);
|
|
159
|
-
socket.removeEventListener('close', onClose);
|
|
160
|
-
signal.removeEventListener('abort', onAbort);
|
|
161
|
-
this.subscriptions.delete(close);
|
|
162
|
-
};
|
|
163
|
-
const close = (): Promise<void> => {
|
|
164
|
-
if (closePromise) return closePromise;
|
|
165
|
-
closing = true;
|
|
166
|
-
socket.removeEventListener('message', message);
|
|
167
|
-
if (socket.readyState === WebSocket.CLOSED) { cleanup(); return Promise.resolve(); }
|
|
168
|
-
closePromise = new Promise<void>((resolveArg, rejectArg) => {
|
|
169
|
-
const closed = () => { clearTimeout(timer); socket.removeEventListener('close', closed); cleanup(); resolveArg(); };
|
|
170
|
-
const timer = setTimeout(() => {
|
|
171
|
-
socket.removeEventListener('close', closed);
|
|
172
|
-
closePromise = undefined;
|
|
173
|
-
rejectArg(new Error('deCONZ WebSocket close timed out.'));
|
|
174
|
-
}, this.timeoutMs);
|
|
175
|
-
socket.addEventListener('close', closed, { once: true });
|
|
176
|
-
socket.close();
|
|
177
|
-
});
|
|
178
|
-
return closePromise;
|
|
179
|
-
};
|
|
180
|
-
this.subscriptions.add(close);
|
|
181
|
-
socket.addEventListener('open', onOpen, { once: true });
|
|
182
|
-
socket.addEventListener('message', message);
|
|
183
|
-
socket.addEventListener('error', onError);
|
|
184
|
-
socket.addEventListener('close', onClose, { once: true });
|
|
185
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
186
|
-
const timer = setTimeout(() => rejectOpen(new Error('deCONZ WebSocket open timed out.')), this.timeoutMs);
|
|
187
|
-
try {
|
|
188
|
-
if (signal.aborted) onAbort();
|
|
189
|
-
await ready;
|
|
190
|
-
this.assertActive(abortSignalArg);
|
|
191
|
-
return close;
|
|
192
|
-
} catch (error) {
|
|
193
|
-
await close();
|
|
194
|
-
throw error;
|
|
195
|
-
} finally { clearTimeout(timer); }
|
|
240
|
+
await this.#foreign(operation, () => ready);
|
|
241
|
+
this.#active(operation);
|
|
242
|
+
if (signal.aborted) throw new Error('deCONZ subscription cancelled.');
|
|
243
|
+
issued = true;
|
|
244
|
+
resolveSubscription(async () => { await subscription.close(); await admitted; });
|
|
245
|
+
} catch (error) { await subscription.close(); throw error; }
|
|
246
|
+
finally { operation.signal.removeEventListener('abort', onDeadline); }
|
|
247
|
+
}, signalArg); } catch (error) { rejectSubscription(error); return subscribed; }
|
|
248
|
+
void admitted.then(() => { if (!issued) rejectSubscription(new Error('deCONZ subscription failed.')); }, (error: unknown) => { if (!issued) rejectSubscription(error); else { this.#callbackFailures.add(new Error('deCONZ subscription lifecycle failed.')); this.#streamHealthy = false; } });
|
|
249
|
+
return subscribed;
|
|
196
250
|
}
|
|
197
251
|
|
|
198
|
-
public
|
|
199
|
-
this
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
252
|
+
public destroy(): Promise<void> {
|
|
253
|
+
if (!this.#closePromise) {
|
|
254
|
+
this.#closePromise = Promise.resolve().then(() => this.#drain(false));
|
|
255
|
+
this.#lifetime.abort();
|
|
256
|
+
if (this.#onAbort) this.#context.abortSignal!.removeEventListener('abort', this.#onAbort);
|
|
257
|
+
}
|
|
258
|
+
return this.#closePromise;
|
|
259
|
+
}
|
|
260
|
+
public retryCleanup(): Promise<void> {
|
|
261
|
+
if (!this.#closePromise) throw new Error('deCONZ cleanup retry requires a terminal client.');
|
|
262
|
+
if (!this.#retryPromise) {
|
|
263
|
+
this.#retryPromise = Promise.resolve().then(async () => { await this.#closePromise!.catch(() => undefined); await this.#drain(true); });
|
|
264
|
+
const settled = (): void => { this.#retryPromise = undefined; };
|
|
265
|
+
void this.#retryPromise.then(settled, settled);
|
|
266
|
+
}
|
|
267
|
+
return this.#retryPromise;
|
|
268
|
+
}
|
|
269
|
+
async #drain(retryArg: boolean): Promise<void> {
|
|
270
|
+
if (retryArg) this.#callbackFailures.clear();
|
|
271
|
+
const results = await Promise.allSettled([...this.#subscriptions].map((subscription) => subscription.close(retryArg)));
|
|
272
|
+
await Promise.allSettled([...this.#operations]);
|
|
273
|
+
const other = await Promise.allSettled([...(this.#ownsStore && this.#store ? [retryArg ? this.#store.retryCleanup() : this.#store.close()] : []), ...(this.#ownsOwner ? [retryArg ? this.#owner.retryCleanup() : this.#owner.close()] : [])]);
|
|
274
|
+
const errors = [...results, ...other].filter((result): result is PromiseRejectedResult => result.status === 'rejected').map(() => new Error('deCONZ cleanup failed.'));
|
|
275
|
+
errors.push(...this.#callbackFailures);
|
|
276
|
+
if (errors.length) throw new AggregateError(errors, 'deCONZ cleanup failed; handles remain retained for explicit retry.');
|
|
204
277
|
}
|
|
205
278
|
|
|
206
|
-
|
|
207
|
-
this.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
279
|
+
#run<TResult>(workArg: (operationArg: IDeconzOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
|
|
280
|
+
if (this.#closePromise || this.#lifetime.signal.aborted) throw new DeconzRequestError('deCONZ request cancelled.');
|
|
281
|
+
const operation = this.#owner.runOperation(this.#context, this.#config.homeId, async (lease) => {
|
|
282
|
+
const deadlineAbort = new AbortController();
|
|
283
|
+
const deadline = performance.now() + this.#config.requestTimeoutMs!;
|
|
284
|
+
const signal = AbortSignal.any([lease.signal, this.#lifetime.signal, deadlineAbort.signal, ...(signalArg ? [signalArg] : [])]);
|
|
285
|
+
const actual = new Set<Promise<unknown>>();
|
|
286
|
+
lease.own(async () => { while (actual.size) await Promise.allSettled([...actual]); });
|
|
287
|
+
const timer = setTimeout(() => deadlineAbort.abort(new DeconzRequestError('deCONZ request timed out.')), this.#config.requestTimeoutMs);
|
|
288
|
+
try { return await workArg({ lease, signal, deadline, actual }); }
|
|
289
|
+
finally { clearTimeout(timer); }
|
|
290
|
+
});
|
|
291
|
+
this.#operations.add(operation);
|
|
292
|
+
void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
|
|
215
293
|
return operation;
|
|
216
294
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
295
|
+
async #foreign<TResult>(operationArg: IDeconzOperation, workArg: () => Promise<TResult>, transportArg = false): Promise<TResult> {
|
|
296
|
+
this.#active(operationArg);
|
|
297
|
+
if (operationArg.actual.size >= 16) throw new Error('deCONZ actual callback capacity reached.');
|
|
298
|
+
const actual = Promise.resolve().then(() => { this.#active(operationArg); return workArg(); });
|
|
299
|
+
operationArg.actual.add(actual);
|
|
300
|
+
void actual.then(() => operationArg.actual.delete(actual), () => operationArg.actual.delete(actual));
|
|
301
|
+
let abort: () => void = () => {};
|
|
302
|
+
const cancelled = new Promise<never>((resolve, reject) => { void resolve; abort = () => reject(this.#cancelled(operationArg)); operationArg.signal.addEventListener('abort', abort, { once: true }); if (operationArg.signal.aborted) abort(); });
|
|
303
|
+
try { const result = await Promise.race([actual, cancelled]); this.#active(operationArg); return result; }
|
|
304
|
+
catch (error) { if (operationArg.signal.aborted) throw this.#cancelled(operationArg); if (error instanceof DeconzWriteError) throw new DeconzWriteError(); if (transportArg && error instanceof DeconzRequestError) throw error; throw new Error('deCONZ operation failed.'); }
|
|
305
|
+
finally { operationArg.signal.removeEventListener('abort', abort); }
|
|
306
|
+
}
|
|
307
|
+
#active(operationArg: IDeconzOperation): void { if (operationArg.signal.aborted || performance.now() >= operationArg.deadline || this.#closePromise) throw this.#cancelled(operationArg); operationArg.lease.assertOpen(); }
|
|
308
|
+
#cancelled(operationArg: IDeconzOperation): DeconzRequestError { return new DeconzRequestError(performance.now() >= operationArg.deadline || operationArg.signal.reason instanceof DeconzRequestError && operationArg.signal.reason.message === 'deCONZ request timed out.' ? 'deCONZ request timed out.' : 'deCONZ request cancelled.'); }
|
|
309
|
+
#credentialRequest(operationArg: IDeconzOperation): IDeconzCredentialRequest { return Object.freeze({ homeId: this.#context.homeId, origin: this.#endpoint.origin, bridgeId: this.#config.bridgeId, credentialReference: this.#config.credentialReference, signal: operationArg.signal, deadline: operationArg.deadline }); }
|
|
310
|
+
#storeKey(): string { return `deconz.credential.${encodeURIComponent(this.#config.credentialReference)}`; }
|
|
311
|
+
async #credential(operationArg: IDeconzOperation): Promise<string> {
|
|
312
|
+
const request = this.#credentialRequest(operationArg);
|
|
313
|
+
if (this.#options.credentialProvider) return this.#key(await this.#foreign(operationArg, () => this.#options.credentialProvider!.resolve(request)));
|
|
314
|
+
await this.#foreign(operationArg, () => this.#store!.initialize({ signal: operationArg.signal }));
|
|
315
|
+
const envelope = deconzData(await this.#foreign(operationArg, () => this.#store!.get(this.#storeKey())), ['version', 'homeId', 'origin', 'bridgeId', 'credentialReference', 'apiKey']);
|
|
316
|
+
if (envelope.version !== 1 || envelope.homeId !== request.homeId || envelope.origin !== request.origin || envelope.bridgeId !== request.bridgeId || envelope.credentialReference !== request.credentialReference) throw new Error('deCONZ credential reference does not match the selected home and bridge.');
|
|
317
|
+
return this.#key(envelope.apiKey);
|
|
318
|
+
}
|
|
319
|
+
#key(valueArg: unknown): string { return deconzText(valueArg, 'resolved credential', 256); }
|
|
320
|
+
async #basic(operationArg: IDeconzOperation): Promise<IDeconzGatewayConfig> { const config = deconzGatewayConfig(await this.#request(operationArg, 'GET', '/api/config')); this.#identity(config); return config; }
|
|
321
|
+
async #snapshot(operationArg: IDeconzOperation, keyArg?: string): Promise<IDeconzSnapshot> {
|
|
222
322
|
try {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (!reader) throw new DeconzRequestError('Empty deCONZ response.');
|
|
230
|
-
const chunks: Uint8Array[] = [];
|
|
231
|
-
let length = 0;
|
|
232
|
-
try {
|
|
233
|
-
while (true) {
|
|
234
|
-
const chunk = await reader.read();
|
|
235
|
-
if (chunk.done) break;
|
|
236
|
-
length += chunk.value.length;
|
|
237
|
-
if (length > 8388608) { await reader.cancel(); throw new DeconzRequestError('deCONZ response exceeds the size limit.'); }
|
|
238
|
-
chunks.push(chunk.value);
|
|
239
|
-
}
|
|
240
|
-
} finally { reader.releaseLock(); }
|
|
241
|
-
const bytes = new Uint8Array(length);
|
|
242
|
-
let offset = 0;
|
|
243
|
-
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
244
|
-
let result: unknown;
|
|
245
|
-
try { result = JSON.parse(new TextDecoder().decode(bytes)) as unknown; }
|
|
246
|
-
catch { throw new DeconzRequestError('Invalid deCONZ JSON response.'); }
|
|
247
|
-
if (Array.isArray(result)) {
|
|
248
|
-
for (const item of result) {
|
|
249
|
-
if (isDeconzRecord(item) && isDeconzRecord(item.error)) {
|
|
250
|
-
const type = typeof item.error.type === 'number' && Number.isInteger(item.error.type) ? item.error.type : undefined;
|
|
251
|
-
throw new DeconzRequestError('deCONZ rejected the request.', type);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
return result;
|
|
256
|
-
} catch (error) {
|
|
257
|
-
if (signal.aborted) throw new DeconzRequestError(deadline.signal.aborted ? 'deCONZ request timed out.' : 'deCONZ request cancelled.');
|
|
258
|
-
if (error instanceof DeconzRequestError) throw error;
|
|
259
|
-
throw new DeconzRequestError('deCONZ network request failed.');
|
|
260
|
-
} finally { clearTimeout(timer); }
|
|
323
|
+
await this.#basic(operationArg);
|
|
324
|
+
const key = keyArg ?? await this.#credential(operationArg);
|
|
325
|
+
const snapshot = deconzSnapshot(await this.#request(operationArg, 'GET', `/api/${encodeURIComponent(key)}`));
|
|
326
|
+
this.#identity(snapshot.config!); this.#active(operationArg); this.#gatewayHealthy = true;
|
|
327
|
+
return { ...snapshot, homeId: this.#context.homeId };
|
|
328
|
+
} catch (error) { this.#gatewayHealthy = false; throw error; }
|
|
261
329
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (!entries.length || entries.some((entryArg) => !isDeconzRecord(entryArg) || !isDeconzRecord(entryArg.success) || !Object.keys(entryArg.success).length)) {
|
|
266
|
-
throw new Error('Invalid deCONZ write acknowledgement.');
|
|
267
|
-
}
|
|
268
|
-
if (pathArg && entries.some((entryArg) => isDeconzRecord(entryArg) && isDeconzRecord(entryArg.success) && Object.keys(entryArg.success).some((addressArg) => addressArg !== pathArg && !addressArg.startsWith(`${pathArg}/`)))) {
|
|
269
|
-
throw new Error('deCONZ write acknowledgement addresses a different resource.');
|
|
270
|
-
}
|
|
330
|
+
#identity(configArg: IDeconzGatewayConfig): void { if (configArg.bridgeid?.toLowerCase() !== this.#config.bridgeId && configArg.uuid?.toLowerCase() !== this.#config.bridgeId) { this.#streamHealthy = false; throw new Error('deCONZ gateway identity does not match configuration.'); } }
|
|
331
|
+
#permission(resourceArg: string, idArg: string, fieldsArg: readonly string[]): void {
|
|
332
|
+
if (!this.#options.controlPermissions?.some((permission) => permission.resource === resourceArg && permission.resourceId === idArg && fieldsArg.every((field) => permission.fields.includes(field)))) throw new Error('deCONZ control requires exact owner authorization.');
|
|
271
333
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (this.lifetime.signal.aborted || this.abortSignal?.aborted || signalArg?.aborted) throw new Error('deCONZ operation cancelled.');
|
|
334
|
+
#sensorPatch(valueArg: unknown): Record<string, unknown> {
|
|
335
|
+
return deconzData(valueArg, ['alarm', 'battery', 'carbonmonoxide', 'consumption', 'current', 'dark', 'daylight', 'fire', 'flag', 'humidity', 'lightlevel', 'lux', 'open', 'power', 'presence', 'duration', 'pressure', 'status', 'temperature', 'vibration', 'voltage', 'water', 'buttonevent']);
|
|
275
336
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
337
|
+
async #write(operationArg: IDeconzOperation, pathArg: string, patchArg: Record<string, unknown>, selectedArg?: IDeconzSnapshot): Promise<IDeconzWriteResult> {
|
|
338
|
+
await this.#basic(operationArg);
|
|
339
|
+
const key = await this.#credential(operationArg);
|
|
340
|
+
const snapshot = await this.#snapshot(operationArg, key);
|
|
341
|
+
const parts = pathArg.split('/');
|
|
342
|
+
const resource = parts[1]; const id = parts[2]; const section = parts[3];
|
|
343
|
+
if (selectedArg && (resource === 'lights' || resource === 'sensors')) {
|
|
344
|
+
const current = snapshot[resource][id]; const selected = selectedArg[resource][id];
|
|
345
|
+
if (!selected || !current || selected.uniqueid !== current.uniqueid || selected.type !== current.type) throw new Error('deCONZ selected resource changed before dispatch.');
|
|
283
346
|
}
|
|
284
|
-
|
|
347
|
+
if (selectedArg && resource === 'groups' && JSON.stringify(selectedArg.groups[id]?.lights) !== JSON.stringify(snapshot.groups[id]?.lights)) throw new Error('deCONZ selected group membership changed before dispatch.');
|
|
348
|
+
if (section === 'scenes') {
|
|
349
|
+
this.#permission('scenes', `${id}/${parts[4]}`, ['recall']);
|
|
350
|
+
if (snapshot.config?.rfconnected === false || !snapshot.groups[id]?.lights?.length || !Object.values(snapshot.groups[id].scenes ?? {}).some((scene) => scene.id === parts[4])) throw new Error('deCONZ scene is not currently available.');
|
|
351
|
+
} else {
|
|
352
|
+
this.#permission(resource, id, Object.keys(patchArg));
|
|
353
|
+
if (resource === 'sensors' && section === 'state') deconzSensorStatePatch(snapshot.sensors[id]?.type, patchArg);
|
|
354
|
+
deconzCurrentPatch(snapshot, resource as 'lights' | 'groups' | 'sensors', id, section as 'state' | 'action' | 'config', patchArg);
|
|
355
|
+
}
|
|
356
|
+
let dispatched = false;
|
|
357
|
+
try {
|
|
358
|
+
const result = await this.#request(operationArg, 'PUT', `/api/${encodeURIComponent(key)}${pathArg}`, patchArg, () => { dispatched = true; });
|
|
359
|
+
this.#acknowledgement(result, pathArg);
|
|
360
|
+
const observedState = await this.#snapshot(operationArg);
|
|
361
|
+
return { effectOutcome: 'accepted', observedState, causalityConfirmed: false, physicalCompletion: 'unconfirmed' };
|
|
362
|
+
} catch (error) { this.#gatewayHealthy = false; if (dispatched) throw new DeconzWriteError(); throw error; }
|
|
285
363
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
364
|
+
#resourceId(valueArg: string): string { if (typeof valueArg !== 'string' || !/^[a-z0-9_-]+$/i.test(valueArg)) throw new Error('Invalid deCONZ resource identity.'); return valueArg; }
|
|
365
|
+
#acknowledgement(valueArg: unknown, pathArg: string): void {
|
|
366
|
+
const entries = Array.isArray(valueArg) ? valueArg : [valueArg];
|
|
367
|
+
if (!entries.length || entries.some((entry) => !isDeconzRecord(entry) || !isDeconzRecord(entry.success) || !Object.keys(entry.success).length || Object.keys(entry.success).some((address) => address !== pathArg && !address.startsWith(`${pathArg}/`)))) throw new Error('Invalid deCONZ write acknowledgement.');
|
|
368
|
+
}
|
|
369
|
+
async #request(operationArg: IDeconzOperation, methodArg: string, pathArg: string, dataArg?: Record<string, unknown>, dispatchArg?: () => void): Promise<unknown> {
|
|
370
|
+
const url = new URL(this.#endpoint); url.pathname = pathArg;
|
|
371
|
+
return this.#foreign(operationArg, async () => {
|
|
372
|
+
try {
|
|
373
|
+
dispatchArg?.();
|
|
374
|
+
const response = await fetch(url, { method: methodArg, redirect: 'error', signal: operationArg.signal, headers: dataArg ? { 'content-type': 'application/json' } : undefined, body: dataArg ? JSON.stringify(dataArg) : undefined });
|
|
375
|
+
if (!response.ok) { await response.body?.cancel(); throw new DeconzRequestError(`deCONZ request failed with HTTP ${response.status}.`); }
|
|
376
|
+
const reader = response.body?.getReader(); if (!reader) throw new DeconzRequestError('Empty deCONZ response.');
|
|
377
|
+
const chunks: Uint8Array[] = []; let length = 0;
|
|
378
|
+
try { while (true) { const chunk = await reader.read(); if (chunk.done) break; length += chunk.value.length; if (length > 8388608) { await reader.cancel(); throw new DeconzRequestError('deCONZ response exceeds the size limit.'); } chunks.push(chunk.value); } }
|
|
379
|
+
finally { reader.releaseLock(); }
|
|
380
|
+
const bytes = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
|
|
381
|
+
let result: unknown; try { result = JSON.parse(new TextDecoder().decode(bytes)) as unknown; } catch { throw new DeconzRequestError('Invalid deCONZ JSON response.'); }
|
|
382
|
+
if (Array.isArray(result)) for (const item of result) if (isDeconzRecord(item) && isDeconzRecord(item.error)) throw new DeconzRequestError('deCONZ rejected the request.', typeof item.error.type === 'number' && Number.isInteger(item.error.type) ? item.error.type : undefined);
|
|
383
|
+
return result;
|
|
384
|
+
} catch (error) { if (operationArg.signal.aborted) throw this.#cancelled(operationArg); if (error instanceof DeconzRequestError) throw error; throw new DeconzRequestError('deCONZ network request failed.'); }
|
|
385
|
+
}, true);
|
|
290
386
|
}
|
|
291
387
|
}
|