@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
|
@@ -15,32 +15,70 @@ export class HueIntegration extends BaseIntegration<IHueConfig> {
|
|
|
15
15
|
public readonly discoveryDescriptor = createHueDiscoveryDescriptor();
|
|
16
16
|
public readonly configFlow = new HueConfigFlow();
|
|
17
17
|
readonly #runtimes = new Set<HueIntegrationRuntime>();
|
|
18
|
+
readonly #setups = new Set<Promise<IIntegrationRuntime>>();
|
|
18
19
|
#closed = false;
|
|
20
|
+
#destroyPromise?: Promise<void>;
|
|
21
|
+
#retryPromise?: Promise<void>;
|
|
19
22
|
|
|
20
23
|
public async setup(configArg: IHueConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
|
21
24
|
if (this.#closed) throw new Error('Hue integration is closed.');
|
|
22
|
-
const
|
|
25
|
+
const context = hueRecord(contextArg);
|
|
26
|
+
const signal = context.abortSignal;
|
|
27
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('Hue abortSignal must be an AbortSignal.');
|
|
28
|
+
const client = new HueClient(configArg, signal);
|
|
23
29
|
const runtime = new HueIntegrationRuntime(client, () => this.#runtimes.delete(runtime));
|
|
24
30
|
this.#runtimes.add(runtime);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
const setup = Promise.resolve().then(async () => {
|
|
32
|
+
try {
|
|
33
|
+
await client.getResources();
|
|
34
|
+
if (this.#closed || signal?.aborted) throw new Error('Hue setup was cancelled.');
|
|
35
|
+
return runtime;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
try { await runtime.destroy(); }
|
|
38
|
+
catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Hue setup and cleanup failed.'); }
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
this.#setups.add(setup);
|
|
43
|
+
void setup.then(() => this.#setups.delete(setup), () => this.#setups.delete(setup));
|
|
44
|
+
return setup;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public destroy(): Promise<void> {
|
|
48
|
+
if (!this.#destroyPromise) {
|
|
49
|
+
this.#closed = true;
|
|
50
|
+
this.#destroyPromise = Promise.resolve().then(() => this.drain(false));
|
|
51
|
+
}
|
|
52
|
+
return this.#destroyPromise;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public retryCleanup(): Promise<void> {
|
|
56
|
+
if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal integration.');
|
|
57
|
+
if (!this.#retryPromise) {
|
|
58
|
+
this.#retryPromise = Promise.resolve().then(async () => {
|
|
59
|
+
await this.#destroyPromise!.catch(() => undefined);
|
|
60
|
+
await this.drain(true);
|
|
61
|
+
});
|
|
62
|
+
const settled = (): void => { this.#retryPromise = undefined; };
|
|
63
|
+
void this.#retryPromise.then(settled, settled);
|
|
32
64
|
}
|
|
65
|
+
return this.#retryPromise;
|
|
33
66
|
}
|
|
34
67
|
|
|
35
|
-
|
|
36
|
-
this.#
|
|
37
|
-
await Promise.all([...this.#
|
|
68
|
+
private async drain(retryArg: boolean): Promise<void> {
|
|
69
|
+
const cleanup = Promise.allSettled([...this.#runtimes].map((runtimeArg) => retryArg ? runtimeArg.retryCleanup() : runtimeArg.destroy()));
|
|
70
|
+
const [results] = await Promise.all([cleanup, Promise.allSettled([...this.#setups])]);
|
|
71
|
+
const errors = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected').map((resultArg) => resultArg.reason as unknown);
|
|
72
|
+
if (errors.length) throw new AggregateError(errors, 'Hue integration cleanup failed; resources remain retained for explicit retry.');
|
|
38
73
|
}
|
|
74
|
+
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
class HueIntegrationRuntime implements IIntegrationRuntime {
|
|
42
78
|
public readonly domain = 'hue';
|
|
43
79
|
|
|
80
|
+
#destroyPromise?: Promise<void>;
|
|
81
|
+
#retryPromise?: Promise<void>;
|
|
44
82
|
constructor(private readonly client: HueClient, private readonly released: () => void) {}
|
|
45
83
|
|
|
46
84
|
public isHealthy(): boolean { return this.client.isHealthy(); }
|
|
@@ -59,24 +97,27 @@ class HueIntegrationRuntime implements IIntegrationRuntime {
|
|
|
59
97
|
}
|
|
60
98
|
|
|
61
99
|
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
|
62
|
-
|
|
100
|
+
const request = hueRecord(requestArg);
|
|
101
|
+
if (Reflect.ownKeys(request).some((keyArg) => !['domain', 'service', 'target', 'data'].includes(String(keyArg)))) return { success: false, error: 'Hue service contains unsupported fields.' };
|
|
102
|
+
if (request.domain !== 'light' || typeof request.service !== 'string' || !['turn_on', 'turn_off', 'set_percentage'].includes(request.service)) {
|
|
63
103
|
return { success: false, error: 'Hue supports only light turn_on, turn_off and set_percentage.' };
|
|
64
104
|
}
|
|
65
|
-
const target = hueRecord(
|
|
105
|
+
const target = hueRecord(request.target);
|
|
66
106
|
if (Reflect.ownKeys(target).some((keyArg) => keyArg !== 'entityId' && keyArg !== 'deviceId')) {
|
|
67
107
|
return { success: false, error: 'Hue service target contains unsupported fields.' };
|
|
68
108
|
}
|
|
69
|
-
|
|
70
|
-
const
|
|
109
|
+
if (Object.values(target).some((valueArg) => typeof valueArg !== 'string' || !valueArg)) return { success: false, error: 'Hue service targets must be identifiers.' };
|
|
110
|
+
const data = request.data === undefined ? {} : hueRecord(request.data);
|
|
111
|
+
const allowedKey = request.service === 'set_percentage' ? 'percentage' : request.service === 'turn_on' ? 'brightness' : undefined;
|
|
71
112
|
if (Reflect.ownKeys(data).some((keyArg) => keyArg !== allowedKey)) return { success: false, error: 'Hue service data contains unsupported fields.' };
|
|
72
|
-
const state: IHueLightStatePatch = { on:
|
|
73
|
-
if (
|
|
74
|
-
const brightness = data[
|
|
113
|
+
const state: IHueLightStatePatch = { on: request.service !== 'turn_off' };
|
|
114
|
+
if (request.service === 'set_percentage' || data.brightness !== undefined) {
|
|
115
|
+
const brightness = data[request.service === 'set_percentage' ? 'percentage' : 'brightness'];
|
|
75
116
|
if (typeof brightness !== 'number' || !Number.isFinite(brightness) || brightness < 0 || brightness > 100) {
|
|
76
117
|
return { success: false, error: 'Hue brightness must be between 0 and 100 percent.' };
|
|
77
118
|
}
|
|
78
119
|
state.brightness = brightness;
|
|
79
|
-
if (
|
|
120
|
+
if (request.service === 'set_percentage') state.on = brightness > 0;
|
|
80
121
|
}
|
|
81
122
|
const snapshot = await this.client.getResources();
|
|
82
123
|
const lightId = this.resolveLight(snapshot, target.entityId, target.deviceId);
|
|
@@ -86,22 +127,47 @@ class HueIntegrationRuntime implements IIntegrationRuntime {
|
|
|
86
127
|
}
|
|
87
128
|
|
|
88
129
|
public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
|
|
130
|
+
const request = hueRecord(requestArg);
|
|
131
|
+
if (Reflect.ownKeys(request).some((keyArg) => !['device', 'feature', 'value'].includes(String(keyArg)))) return { success: false, error: 'Hue feature request contains unsupported fields.' };
|
|
132
|
+
const selectedDevice = hueRecord(request.device);
|
|
133
|
+
const selectedFeature = hueRecord(request.feature);
|
|
134
|
+
const deviceId = selectedDevice.id;
|
|
135
|
+
const featureId = selectedFeature.id;
|
|
136
|
+
const capability = selectedFeature.capability;
|
|
137
|
+
const value = request.value;
|
|
138
|
+
if (selectedDevice.integrationDomain !== this.domain || typeof deviceId !== 'string' || typeof featureId !== 'string') {
|
|
139
|
+
return { success: false, error: 'Hue feature requires one exact Hue device and feature.' };
|
|
140
|
+
}
|
|
89
141
|
const snapshot = await this.client.getResources(abortSignalArg);
|
|
90
|
-
const device = HueMapper.toDevices(snapshot).find((deviceArg) => deviceArg.id ===
|
|
91
|
-
const feature = device?.features.find((featureArg) => featureArg.id ===
|
|
142
|
+
const device = HueMapper.toDevices(snapshot).find((deviceArg) => deviceArg.id === deviceId);
|
|
143
|
+
const feature = device?.features.find((featureArg) => featureArg.id === featureId && featureArg.capability === capability && featureArg.writable);
|
|
92
144
|
const lightId = this.resolveLight(snapshot, undefined, device?.id);
|
|
93
145
|
if (!device || !device.online || !feature || !lightId) return { success: false, error: 'Hue feature target is unknown, unavailable or not writable.' };
|
|
94
146
|
let state: IHueLightStatePatch;
|
|
95
|
-
if (feature.id === 'on' && typeof
|
|
96
|
-
else if (feature.id === 'brightness' && typeof
|
|
147
|
+
if (feature.id === 'on' && typeof value === 'boolean') state = { on: value };
|
|
148
|
+
else if (feature.id === 'brightness' && typeof value === 'number') state = { brightness: value };
|
|
97
149
|
else return { success: false, error: 'Hue feature value has an invalid type.' };
|
|
98
150
|
await this.client.setLightState(lightId, state, abortSignalArg);
|
|
99
151
|
return { success: true };
|
|
100
152
|
}
|
|
101
153
|
|
|
102
|
-
public
|
|
103
|
-
await this.client.destroy();
|
|
104
|
-
this
|
|
154
|
+
public destroy(): Promise<void> {
|
|
155
|
+
this.#destroyPromise ??= Promise.resolve().then(async () => { await this.client.destroy(); this.released(); });
|
|
156
|
+
return this.#destroyPromise;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
public retryCleanup(): Promise<void> {
|
|
160
|
+
if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal runtime.');
|
|
161
|
+
if (!this.#retryPromise) {
|
|
162
|
+
this.#retryPromise = Promise.resolve().then(async () => {
|
|
163
|
+
await this.#destroyPromise!.catch(() => undefined);
|
|
164
|
+
await this.client.retryCleanup();
|
|
165
|
+
this.released();
|
|
166
|
+
});
|
|
167
|
+
const settled = (): void => { this.#retryPromise = undefined; };
|
|
168
|
+
void this.#retryPromise.then(settled, settled);
|
|
169
|
+
}
|
|
170
|
+
return this.#retryPromise;
|
|
105
171
|
}
|
|
106
172
|
|
|
107
173
|
private resolveLight(snapshotArg: IHueResourceResponse, entityIdArg: unknown, deviceIdArg: unknown): string | undefined {
|
|
@@ -10,11 +10,21 @@ export const hueUuid = (valueArg: unknown): valueArg is string =>
|
|
|
10
10
|
export const hueBridgeId = (valueArg: unknown): valueArg is string =>
|
|
11
11
|
typeof valueArg === 'string' && /^[0-9a-f]{16}$/i.test(valueArg);
|
|
12
12
|
|
|
13
|
+
/** Capture plain data fields without invoking caller accessors or retaining mutable objects. */
|
|
13
14
|
export const hueRecord = (valueArg: unknown): Record<string, unknown> => {
|
|
14
|
-
if (!valueArg || typeof valueArg !== 'object'
|
|
15
|
-
|
|
15
|
+
if (!valueArg || typeof valueArg !== 'object'
|
|
16
|
+
|| (Object.getPrototypeOf(valueArg) !== Object.prototype && Object.getPrototypeOf(valueArg) !== null)) {
|
|
17
|
+
throw new Error('Hue requires a plain data object.');
|
|
16
18
|
}
|
|
17
|
-
|
|
19
|
+
const record: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
20
|
+
for (const key of Reflect.ownKeys(valueArg)) {
|
|
21
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
|
|
22
|
+
if (typeof key !== 'string' || !descriptor || !descriptor.enumerable || !('value' in descriptor)) {
|
|
23
|
+
throw new Error('Hue data contains an unsupported field or accessor.');
|
|
24
|
+
}
|
|
25
|
+
record[key] = descriptor.value as unknown;
|
|
26
|
+
}
|
|
27
|
+
return record;
|
|
18
28
|
};
|
|
19
29
|
|
|
20
30
|
export const normalizeHueHost = (valueArg: unknown): string => {
|
|
@@ -33,7 +33,7 @@ Setup checks both the TLS identity and the `bridge_id` returned by `/clip/v2/res
|
|
|
33
33
|
|
|
34
34
|
Lights advertise brightness only when the bridge reports the `dimming` capability. Availability follows the owning device's Zigbee connectivity resource; missing or disconnected connectivity is unavailable. Manufacturer and model come from the owning device, including supported third-party Hue lights.
|
|
35
35
|
|
|
36
|
-
Light services are `turn_on`, `turn_off` and `set_percentage`. `turn_on` accepts optional `brightness` from 0 to 100; `set_percentage` requires `percentage` in that same range. A target must match the current entity/device exactly; supplying both requires them to identify the same light. `writeFeature` supports boolean `on` and numeric `brightness` only for an available light's declared writable capabilities. Unsupported fields, unknown targets and values outside the protocol range are rejected.
|
|
36
|
+
Light services are `turn_on`, `turn_off` and `set_percentage`. `turn_on` accepts optional `brightness` from 0 to 100; `set_percentage` requires `percentage` in that same range. A target must match the current entity/device exactly; supplying both requires them to identify the same light. `writeFeature` supports boolean `on` and numeric `brightness` only for an available light's declared writable capabilities. Unsupported fields, unknown targets and values outside the protocol range are rejected. Controls capture plain-data target IDs, domain, capability and value before awaiting a fresh read; later caller mutation cannot retarget a write. Accessors, symbols, hidden fields and class instances are rejected without invoking getters. Setup cancellation and config-flow discovery endpoint hints are also captured before awaiting readiness or displaying a form.
|
|
37
37
|
|
|
38
38
|
Writes use `PUT /clip/v2/resource/light/<UUID>` and require the bridge's matching resource acknowledgement. Success means the bridge accepted the write; it does not guarantee physical delivery. Accepted writes never change local read state. Each `devices()` or `entities()` call reads current authoritative resources, and writes are not automatically retried.
|
|
39
39
|
|
|
@@ -41,11 +41,11 @@ Writes use `PUT /clip/v2/resource/light/<UUID>` and require the bridge's matchin
|
|
|
41
41
|
|
|
42
42
|
`subscribe()` opens authenticated HTTPS `/eventstream/clip/v2` and returns an asynchronous, idempotent unsubscribe function. Change notifications contain only relevant resource IDs/types; consumers reread authoritative state. Unknown resource payloads are omitted because they may contain credentials. Invalid frames, stream termination and transport failures produce an error event and mark the runtime unhealthy. Reads do not silently restart a failed subscription; the host may explicitly establish a new one or replace the runtime.
|
|
43
43
|
|
|
44
|
-
Requests have whole-response deadlines and a 4 MiB body limit. Discovery uses the fixed documented `https://discovery.meethue.com/` broker with a five-second deadline and 256 KiB body limit. SSE frames are limited to 1 MiB and batches to 256 events. A client permits at most 32 outstanding connections, two event subscriptions, and three simultaneous HTTPS sockets. Caller cancellation and `destroy()` close owned requests/streams; destroy waits for them to
|
|
44
|
+
Requests have whole-response deadlines and a 4 MiB body limit. Discovery uses the fixed documented `https://discovery.meethue.com/` broker with a five-second deadline and 256 KiB body limit. SSE frames are limited to 1 MiB and batches to 256 events. A client permits at most 32 outstanding connections, two event subscriptions, and three simultaneous HTTPS sockets. Caller cancellation and `destroy()` close owned requests/streams; destroy waits for them and all admitted request/subscriber continuations to settle. Subscriber callbacks may return promises. Unsubscribe has stable promise identity and joins admitted callbacks; later events are fenced. Each stream permits at most 32 pending callbacks. Synchronous throws and async rejections are observed, mark the stream unhealthy, and use a redacted subscriber error rather than being relabelled as malformed stream data. Callback failures remain visible on terminal client cleanup until explicit `HueClient.retryCleanup()` or `HueIntegration.retryCleanup()` acknowledges them. Failed transport cleanup is retained for the same retry. Retry performs cleanup only; it never replays GETs, PUTs, subscriptions, or callbacks. `destroy()` publishes a stable terminal promise before aborting and continues to return that promise after a retry. Integration and runtime destroy also have stable terminal promise identity. Integration shutdown joins pending setups and all sibling runtimes before surfacing cleanup failures, retaining failed runtime handles for explicit cleanup-only retry. Shared native home/resource admission, flow ownership, and home-qualified IDs are pending a separate breaking config/identity decision. Raw `appKey` configuration remains the current contract; home-bound credential references and existing-config migration require a separate public-contract decision.
|
|
45
45
|
|
|
46
46
|
## Verification and limits
|
|
47
47
|
|
|
48
|
-
Tests use disposable CA-signed local TLS bridges, including untrusted issuers, a wrong bridge certificate, resource identity mismatch, exact PUT payloads, non-optimistic reads, unsupported controls, unavailable devices, malformed/oversized bodies, cancellation and
|
|
48
|
+
Tests use disposable CA-signed local TLS bridges, including untrusted issuers, a wrong bridge certificate, resource identity mismatch, exact PUT payloads, non-optimistic reads, unsupported controls, unavailable devices, malformed/oversized bodies, cancellation, plain-data/accessor validation, blocked async subscribers, sibling drainage, reentrant close, retained cleanup failures and cleanup-only retry without HTTPS replay. No physical bridge or owner's home was accessed.
|
|
49
49
|
|
|
50
50
|
The public [Hue API announcement](https://developers.meethue.com/new-hue-api/) establishes API v2 HTTPS, local proactive events and the supported discovery methods. Detailed Hue API and HTTPS pages require a developer login and returned login/403 here. Implemented wire resource models and event paths were cross-checked against [aiohue's upstream v2 implementation](https://github.com/home-assistant-libs/aiohue/tree/main/aiohue/v2). Aiohue's disabled-TLS and geofence keepalive workaround are not used. Physical bridge firmware compatibility, operator CA provisioning and physical command delivery remain acceptance checks. Color, color temperature, scenes, entertainment streaming, grouped lights and accessory sensors are not exposed by this runtime.
|
|
51
51
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { MqttMapper } from './mqtt.mapper.js';
|
|
3
|
-
import type { IMqttConfig, IMqttConnectionState, IMqttMessage, IMqttPublishOptions, IMqttSnapshot } from './mqtt.types.js';
|
|
3
|
+
import type { IMqttConfig, IMqttConnectionState, IMqttMessage, IMqttPublishOptions, IMqttSnapshot, IMqttTransportCredentials } from './mqtt.types.js';
|
|
4
4
|
|
|
5
5
|
type TMqttMessageHandler = (messageArg: IMqttMessage) => void;
|
|
6
6
|
type TMqttStateHandler = (stateArg: IMqttConnectionState) => void;
|
|
@@ -10,6 +10,11 @@ interface IMqttSubscription {
|
|
|
10
10
|
handler?: TMqttMessageHandler;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
interface IMqttTransportCleanup {
|
|
14
|
+
promise?: Promise<void>;
|
|
15
|
+
failed: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
export const mqttTopicMatches = (filterArg: string, topicArg: string): boolean => {
|
|
14
19
|
if (topicArg.startsWith('$') && !filterArg.startsWith('$')) return false;
|
|
15
20
|
const filter = filterArg.split('/');
|
|
@@ -37,17 +42,23 @@ export class MqttClient {
|
|
|
37
42
|
private startPromise?: Promise<void>;
|
|
38
43
|
private destroyPromise?: Promise<void>;
|
|
39
44
|
private cleanupPromise = Promise.resolve();
|
|
45
|
+
private retryPromise?: Promise<void>;
|
|
46
|
+
private readonly transportCleanups = new Map<plugins.mqtt.MqttClient, IMqttTransportCleanup>();
|
|
40
47
|
private destroyed = false;
|
|
41
48
|
private ready = false;
|
|
42
49
|
private hasStarted = false;
|
|
43
50
|
private generation = 0;
|
|
51
|
+
private currentGeneration?: number;
|
|
52
|
+
private currentStream?: plugins.mqtt.MqttClient['stream'];
|
|
44
53
|
private readonly subscriptions = new Set<IMqttSubscription>();
|
|
45
54
|
private readonly messageHandlers = new Set<TMqttMessageHandler>();
|
|
46
55
|
private readonly stateHandlers = new Set<TMqttStateHandler>();
|
|
47
56
|
private readonly messages = new Map<string, IMqttMessage>();
|
|
57
|
+
private readonly currentMessageTopics = new Set<string>();
|
|
48
58
|
private readonly persistentTopics = new Set<string>();
|
|
49
59
|
private readonly brokerTopics = new Set<string>();
|
|
50
60
|
private readonly pendingOperations = new Set<(errorArg: Error) => void>();
|
|
61
|
+
private readonly publications = new Map<plugins.mqtt.MqttClient, number>();
|
|
51
62
|
private topicSync?: Promise<void>;
|
|
52
63
|
private topicSyncRequested = false;
|
|
53
64
|
private bufferedBytes = 0;
|
|
@@ -57,8 +68,9 @@ export class MqttClient {
|
|
|
57
68
|
private readonly maxPayloadBytes: number;
|
|
58
69
|
private readonly maxBufferedBytes: number;
|
|
59
70
|
|
|
60
|
-
constructor(private readonly config: IMqttConfig) {
|
|
71
|
+
constructor(private readonly config: IMqttConfig, private readonly credentials: IMqttTransportCredentials = {}) {
|
|
61
72
|
this.config = structuredClone(config);
|
|
73
|
+
this.credentials = structuredClone(credentials);
|
|
62
74
|
this.connectTimeoutMs = this.integerOption(config.connectTimeoutMs, 7000, 1, 2147483647);
|
|
63
75
|
this.operationTimeoutMs = this.integerOption(config.operationTimeoutMs, 7000, 1, 2147483647);
|
|
64
76
|
this.maxMessageTopics = this.integerOption(config.maxMessageTopics, 4096, 1, 100000);
|
|
@@ -73,6 +85,13 @@ export class MqttClient {
|
|
|
73
85
|
validateMqttTopic(topic, true);
|
|
74
86
|
this.persistentTopics.add(topic);
|
|
75
87
|
}
|
|
88
|
+
if (config.mode === 'selected-topics') {
|
|
89
|
+
for (const mapping of config.mappings ?? []) {
|
|
90
|
+
for (const topic of [mapping.stateTopic, ...(mapping.availabilityTopic ? [mapping.availabilityTopic] : [])]) {
|
|
91
|
+
validateMqttTopic(topic); this.persistentTopics.add(topic);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
76
95
|
if (config.discovery !== false) {
|
|
77
96
|
const prefix = config.discoveryPrefix || 'homeassistant';
|
|
78
97
|
validateMqttTopic(prefix);
|
|
@@ -106,6 +125,14 @@ export class MqttClient {
|
|
|
106
125
|
return MqttMapper.toSnapshot({ ...this.config, retainedMessages: [], discoveryMessages: [] }, this.connected, [...this.messages.values()]);
|
|
107
126
|
}
|
|
108
127
|
|
|
128
|
+
/** Live broker observations only, including arrivals before readiness; never starts a transport. */
|
|
129
|
+
public getCurrentSnapshot(): IMqttMessage[] {
|
|
130
|
+
if (this.destroyed || this.currentGeneration !== this.generation || !this.client?.connected ||
|
|
131
|
+
this.client.stream !== this.currentStream || this.currentStream?.destroyed) return [];
|
|
132
|
+
return [...this.messages.values()].filter((messageArg) => this.currentMessageTopics.has(messageArg.topic))
|
|
133
|
+
.map((messageArg) => structuredClone(messageArg));
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
public async start(abortSignalArg?: AbortSignal): Promise<void> {
|
|
110
137
|
this.throwIfAborted(abortSignalArg);
|
|
111
138
|
if (this.destroyed) throw new Error('MQTT client has been destroyed.');
|
|
@@ -144,13 +171,18 @@ export class MqttClient {
|
|
|
144
171
|
if (packetArg.cmd === 'publish' && packetArg.payload === payload) packetId = packetArg.messageId;
|
|
145
172
|
};
|
|
146
173
|
client.on('packetsend', observePacket);
|
|
174
|
+
this.publications.set(client, (this.publications.get(client) ?? 0) + 1);
|
|
147
175
|
try {
|
|
148
176
|
await this.bounded(client.publishAsync(optionsArg.topic, payload, {
|
|
149
177
|
qos: optionsArg.qos ?? 0, retain: optionsArg.retain ?? false,
|
|
150
178
|
}), this.operationTimeoutMs, abortSignalArg, () => {
|
|
151
179
|
if (packetId !== undefined && optionsArg.qos) client.removeOutgoingMessage(packetId);
|
|
152
180
|
});
|
|
153
|
-
} finally {
|
|
181
|
+
} finally {
|
|
182
|
+
client.removeListener('packetsend', observePacket);
|
|
183
|
+
const remaining = (this.publications.get(client) ?? 1) - 1;
|
|
184
|
+
if (remaining) this.publications.set(client, remaining); else this.publications.delete(client);
|
|
185
|
+
}
|
|
154
186
|
}
|
|
155
187
|
|
|
156
188
|
public async subscribe(topicArg: string, handlerArg?: TMqttMessageHandler, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
@@ -175,10 +207,35 @@ export class MqttClient {
|
|
|
175
207
|
}
|
|
176
208
|
|
|
177
209
|
public destroy(): Promise<void> {
|
|
178
|
-
if (!this.destroyPromise)
|
|
210
|
+
if (!this.destroyPromise) {
|
|
211
|
+
this.destroyed = true;
|
|
212
|
+
this.clearCurrentGeneration();
|
|
213
|
+
this.destroyPromise = Promise.resolve().then(() => this.destroyConnection());
|
|
214
|
+
}
|
|
179
215
|
return this.destroyPromise;
|
|
180
216
|
}
|
|
181
217
|
|
|
218
|
+
/** Retry retained close handles only; this never admits a connection or replays application effects. */
|
|
219
|
+
public retryCleanup(): Promise<void> {
|
|
220
|
+
if (!this.destroyPromise) return this.destroy();
|
|
221
|
+
if (!this.retryPromise) {
|
|
222
|
+
const retry = Promise.resolve().then(async () => {
|
|
223
|
+
await this.destroyPromise?.then(() => undefined, () => undefined);
|
|
224
|
+
const cleanup = [...this.transportCleanups].filter(([, recordArg]) => recordArg.failed).map(([clientArg, recordArg]) => {
|
|
225
|
+
recordArg.promise = undefined;
|
|
226
|
+
recordArg.failed = false;
|
|
227
|
+
return this.closeClient(clientArg);
|
|
228
|
+
});
|
|
229
|
+
const results = await Promise.allSettled(cleanup);
|
|
230
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
231
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT retained transport cleanup failed.');
|
|
232
|
+
});
|
|
233
|
+
this.retryPromise = retry;
|
|
234
|
+
void retry.then(() => { this.retryPromise = undefined; }, () => { this.retryPromise = undefined; });
|
|
235
|
+
}
|
|
236
|
+
return this.retryPromise;
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
private async startConnection(): Promise<void> {
|
|
183
240
|
let client = this.client;
|
|
184
241
|
if (!client) {
|
|
@@ -191,6 +248,7 @@ export class MqttClient {
|
|
|
191
248
|
protocolVersion: 4,
|
|
192
249
|
clientId: this.config.clientId || 'shx-' + plugins.crypto.randomBytes(8).toString('hex'),
|
|
193
250
|
username: this.config.username, password: this.config.password,
|
|
251
|
+
...this.credentials,
|
|
194
252
|
clean: this.config.clean ?? true,
|
|
195
253
|
keepalive: this.config.keepalive ?? 60,
|
|
196
254
|
rejectUnauthorized: this.config.rejectUnauthorized !== false,
|
|
@@ -202,9 +260,21 @@ export class MqttClient {
|
|
|
202
260
|
});
|
|
203
261
|
this.client = client;
|
|
204
262
|
const ownedClient = client;
|
|
263
|
+
const receivedGenerations = new WeakMap<plugins.mqtt.IPublishPacket, number>();
|
|
264
|
+
client.on('packetreceive', (packetArg) => {
|
|
265
|
+
if (packetArg.cmd !== 'publish' || this.client !== ownedClient || this.destroyed ||
|
|
266
|
+
this.currentGeneration !== this.generation || !ownedClient.connected ||
|
|
267
|
+
ownedClient.stream !== this.currentStream || this.currentStream?.destroyed) return;
|
|
268
|
+
// MQTT.js delivers the same packet through message, including its default QoS 2 store.
|
|
269
|
+
if (!receivedGenerations.has(packetArg)) receivedGenerations.set(packetArg, this.generation);
|
|
270
|
+
});
|
|
205
271
|
client.on('connect', () => {
|
|
272
|
+
if (this.client !== ownedClient || this.destroyed || !ownedClient.connected || ownedClient.stream.destroyed) return;
|
|
273
|
+
this.clearCurrentGeneration();
|
|
206
274
|
this.brokerTopics.clear();
|
|
207
275
|
const generation = ++this.generation;
|
|
276
|
+
this.currentGeneration = generation;
|
|
277
|
+
this.currentStream = ownedClient.stream;
|
|
208
278
|
void this.bounded(this.initializeConnection(ownedClient), this.operationTimeoutMs).then(() => {
|
|
209
279
|
if (this.client !== ownedClient || generation !== this.generation || !ownedClient.connected) return;
|
|
210
280
|
this.ready = true;
|
|
@@ -214,7 +284,9 @@ export class MqttClient {
|
|
|
214
284
|
});
|
|
215
285
|
});
|
|
216
286
|
client.on('message', (topicArg, payloadArg, packetArg) => {
|
|
217
|
-
if (this.client !== ownedClient
|
|
287
|
+
if (this.client !== ownedClient || this.destroyed || this.currentGeneration !== this.generation ||
|
|
288
|
+
receivedGenerations.get(packetArg) !== this.currentGeneration || !ownedClient.connected ||
|
|
289
|
+
ownedClient.stream !== this.currentStream || this.currentStream?.destroyed) return;
|
|
218
290
|
try {
|
|
219
291
|
this.handleMessage({ topic: topicArg, payload: payloadArg.toString('utf8'), qos: packetArg.qos, retain: packetArg.retain, timestamp: Date.now() });
|
|
220
292
|
} catch (error) { void this.failConnection(this.asError(error), ownedClient); }
|
|
@@ -222,15 +294,22 @@ export class MqttClient {
|
|
|
222
294
|
client.on('error', (errorArg) => {
|
|
223
295
|
if (this.client === ownedClient) {
|
|
224
296
|
this.ready = false;
|
|
225
|
-
this.
|
|
297
|
+
this.generation++;
|
|
298
|
+
this.clearCurrentGeneration();
|
|
299
|
+
try { this.emitState({ connected: false, error: errorArg }); }
|
|
300
|
+
catch (error) { void this.failConnection(this.asError(error), ownedClient); }
|
|
226
301
|
}
|
|
227
302
|
});
|
|
228
303
|
client.on('close', () => {
|
|
229
304
|
if (this.client !== ownedClient) return;
|
|
230
305
|
this.ready = false;
|
|
231
306
|
this.generation++;
|
|
307
|
+
this.clearCurrentGeneration();
|
|
232
308
|
this.brokerTopics.clear();
|
|
233
|
-
this.emitState({ connected: false });
|
|
309
|
+
try { this.emitState({ connected: false }); }
|
|
310
|
+
catch (error) { void this.failConnection(this.asError(error), ownedClient); }
|
|
311
|
+
// A new connection must never replay an unacknowledged application effect.
|
|
312
|
+
if (this.publications.has(ownedClient)) void this.failConnection(new Error('MQTT publication lost its connection before acknowledgement.'), ownedClient);
|
|
234
313
|
});
|
|
235
314
|
}
|
|
236
315
|
if (this.connected) return;
|
|
@@ -313,14 +392,20 @@ export class MqttClient {
|
|
|
313
392
|
}
|
|
314
393
|
|
|
315
394
|
private handleMessage(messageArg: IMqttMessage): void {
|
|
395
|
+
if (this.config.mode === 'selected-topics' && !this.persistentTopics.has(messageArg.topic)) return;
|
|
316
396
|
this.storeMessage(messageArg);
|
|
397
|
+
this.currentMessageTopics.add(messageArg.topic);
|
|
317
398
|
if (this.config.discovery !== false && messageArg.topic.endsWith('/config') && mqttTopicMatches((this.config.discoveryPrefix || 'homeassistant') + '/#', messageArg.topic)) {
|
|
318
399
|
void this.synchronizeTopics();
|
|
319
400
|
}
|
|
401
|
+
const failures: unknown[] = [];
|
|
320
402
|
for (const subscription of this.subscriptions) {
|
|
321
|
-
if (subscription.handler && mqttTopicMatches(subscription.topic, messageArg.topic))
|
|
403
|
+
if (subscription.handler && mqttTopicMatches(subscription.topic, messageArg.topic)) {
|
|
404
|
+
try { subscription.handler(messageArg); } catch (error) { failures.push(error); }
|
|
405
|
+
}
|
|
322
406
|
}
|
|
323
|
-
for (const handler of this.messageHandlers) handler(messageArg);
|
|
407
|
+
for (const handler of this.messageHandlers) { try { handler(messageArg); } catch (error) { failures.push(error); } }
|
|
408
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT message callbacks failed.');
|
|
324
409
|
}
|
|
325
410
|
|
|
326
411
|
private storeMessage(messageArg: IMqttMessage): void {
|
|
@@ -337,6 +422,7 @@ export class MqttClient {
|
|
|
337
422
|
}
|
|
338
423
|
|
|
339
424
|
private removeMessage(topicArg: string): void {
|
|
425
|
+
this.currentMessageTopics.delete(topicArg);
|
|
340
426
|
const message = this.messages.get(topicArg);
|
|
341
427
|
if (message) {
|
|
342
428
|
this.bufferedBytes -= this.payloadBuffer(message.payload).length + Buffer.byteLength(topicArg);
|
|
@@ -344,31 +430,47 @@ export class MqttClient {
|
|
|
344
430
|
}
|
|
345
431
|
}
|
|
346
432
|
|
|
433
|
+
private clearCurrentGeneration(): void {
|
|
434
|
+
this.currentGeneration = undefined;
|
|
435
|
+
this.currentStream = undefined;
|
|
436
|
+
this.currentMessageTopics.clear();
|
|
437
|
+
}
|
|
438
|
+
|
|
347
439
|
private async destroyConnection(): Promise<void> {
|
|
348
440
|
this.destroyed = true;
|
|
349
441
|
this.ready = false;
|
|
442
|
+
this.clearCurrentGeneration();
|
|
350
443
|
const client = this.client;
|
|
351
444
|
this.client = undefined;
|
|
352
445
|
this.rejectOperations(new Error('MQTT client has been destroyed.'));
|
|
353
|
-
|
|
446
|
+
const stateHandlers = [...this.stateHandlers];
|
|
447
|
+
const notify = Promise.resolve().then(() => this.emitState({ connected: false }, stateHandlers));
|
|
354
448
|
this.subscriptions.clear();
|
|
355
449
|
this.messageHandlers.clear();
|
|
356
450
|
this.stateHandlers.clear();
|
|
357
451
|
this.messages.clear();
|
|
358
452
|
this.bufferedBytes = 0;
|
|
359
453
|
this.brokerTopics.clear();
|
|
360
|
-
|
|
361
|
-
|
|
454
|
+
const results = await Promise.allSettled([notify, ...(client ? [this.closeClient(client)] : []), this.cleanupPromise,
|
|
455
|
+
...(this.startPromise ? [this.startPromise.catch(() => undefined)] : []), ...(this.topicSync ? [this.topicSync.catch(() => undefined)] : [])]);
|
|
456
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
457
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT client cleanup failed.');
|
|
362
458
|
}
|
|
363
459
|
|
|
364
460
|
private failConnection(errorArg: Error, clientArg: plugins.mqtt.MqttClient): Promise<void> {
|
|
365
461
|
if (this.client !== clientArg) return this.cleanupPromise;
|
|
366
462
|
this.ready = false;
|
|
367
463
|
this.generation++;
|
|
464
|
+
this.clearCurrentGeneration();
|
|
368
465
|
this.client = undefined;
|
|
369
466
|
this.brokerTopics.clear();
|
|
370
|
-
|
|
371
|
-
|
|
467
|
+
const cleanup = Promise.resolve().then(async () => {
|
|
468
|
+
const results = await Promise.allSettled([
|
|
469
|
+
Promise.resolve().then(() => this.emitState({ connected: false, error: errorArg })), this.closeClient(clientArg),
|
|
470
|
+
]);
|
|
471
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
472
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT cancellation cleanup failed.');
|
|
473
|
+
});
|
|
372
474
|
this.cleanupPromise = cleanup;
|
|
373
475
|
void cleanup.then(() => this.rejectOperations(errorArg), (error: unknown) => {
|
|
374
476
|
this.rejectOperations(new AggregateError([errorArg, error], 'MQTT cancellation cleanup failed.'));
|
|
@@ -376,10 +478,23 @@ export class MqttClient {
|
|
|
376
478
|
return cleanup;
|
|
377
479
|
}
|
|
378
480
|
|
|
379
|
-
private
|
|
481
|
+
private closeClient(clientArg: plugins.mqtt.MqttClient): Promise<void> {
|
|
482
|
+
let record = this.transportCleanups.get(clientArg);
|
|
483
|
+
if (!record) { record = { failed: false }; this.transportCleanups.set(clientArg, record); }
|
|
484
|
+
if (!record.promise) {
|
|
485
|
+
const captured = record;
|
|
486
|
+
record.promise = Promise.resolve().then(() => this.performClientClose(clientArg)).then(() => {
|
|
487
|
+
this.transportCleanups.delete(clientArg);
|
|
488
|
+
}, (error: unknown) => { captured.failed = true; throw error; });
|
|
489
|
+
}
|
|
490
|
+
return record.promise;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private async performClientClose(clientArg: plugins.mqtt.MqttClient): Promise<void> {
|
|
380
494
|
const closed = clientArg.stream.closed ? Promise.resolve() : new Promise<void>((resolve) => clientArg.once('close', resolve));
|
|
381
|
-
await clientArg.endAsync(true);
|
|
382
|
-
|
|
495
|
+
const results = await Promise.allSettled([clientArg.endAsync(true), closed]);
|
|
496
|
+
const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
|
|
497
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT transport cleanup failed.');
|
|
383
498
|
}
|
|
384
499
|
|
|
385
500
|
private bounded<TValue>(promiseArg: Promise<TValue>, timeoutMsArg: number, abortSignalArg?: AbortSignal, cancelArg?: () => void): Promise<TValue> {
|
|
@@ -419,8 +534,10 @@ export class MqttClient {
|
|
|
419
534
|
this.pendingOperations.clear();
|
|
420
535
|
}
|
|
421
536
|
|
|
422
|
-
private emitState(stateArg: IMqttConnectionState): void {
|
|
423
|
-
|
|
537
|
+
private emitState(stateArg: IMqttConnectionState, handlersArg: Iterable<TMqttStateHandler> = this.stateHandlers): void {
|
|
538
|
+
const failures: unknown[] = [];
|
|
539
|
+
for (const handler of handlersArg) { try { handler(stateArg); } catch (error) { failures.push(error); } }
|
|
540
|
+
if (failures.length) throw new AggregateError(failures, 'MQTT connection callbacks failed.');
|
|
424
541
|
}
|
|
425
542
|
|
|
426
543
|
private requireClient(): plugins.mqtt.MqttClient {
|