@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,5 +1,7 @@
|
|
|
1
1
|
import { validateMqttTopic } from '../mqtt/mqtt.classes.client.js';
|
|
2
|
-
import
|
|
2
|
+
import { mqttCredentials } from '../mqtt/mqtt.validation.js';
|
|
3
|
+
import type { IMqttTransportCredentials } from '../mqtt/mqtt.types.js';
|
|
4
|
+
import type { IZigbee2MqttConfig, IZigbee2MqttIntegrationOptions, TZigbee2MqttPrimitive } from './zigbee2mqtt.types.js';
|
|
3
5
|
|
|
4
6
|
export const zigbee2MqttRecord = (valueArg: unknown): Record<string, unknown> | undefined => {
|
|
5
7
|
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) return undefined;
|
|
@@ -25,9 +27,14 @@ export const zigbee2MqttInteger = (valueArg: unknown, defaultArg: number, maximu
|
|
|
25
27
|
|
|
26
28
|
export const zigbee2MqttConfig = (configArg: IZigbee2MqttConfig): IZigbee2MqttConfig => {
|
|
27
29
|
const config = zigbee2MqttRecord(configArg);
|
|
28
|
-
const allowed = new Set(['
|
|
30
|
+
const allowed = new Set(['homeId', 'authentication', 'credentialKey', 'allowUnencryptedLan', 'host', 'baseTopic', 'port', 'protocol', 'clientId',
|
|
29
31
|
'connectTimeoutMs', 'operationTimeoutMs', 'inventoryTimeoutMs', 'reconnectPeriodMs', 'maxDevices', 'maxPayloadBytes', 'maxBufferedBytes']);
|
|
30
32
|
if (!config || Object.keys(config).some((keyArg) => !allowed.has(keyArg))) throw new Error('Unsupported Zigbee2MQTT configuration field.');
|
|
33
|
+
const homeId = zigbee2MqttIdentifier(config.homeId);
|
|
34
|
+
if (typeof config.authentication !== 'string' || !['anonymous', 'provider', 'config-store'].includes(config.authentication)) throw new Error('Explicit Zigbee2MQTT authentication mode is required.');
|
|
35
|
+
const authentication = config.authentication as IZigbee2MqttConfig['authentication'];
|
|
36
|
+
const credentialKey = config.credentialKey === undefined ? undefined : zigbee2MqttIdentifier(config.credentialKey);
|
|
37
|
+
if ((authentication === 'anonymous') !== (credentialKey === undefined)) throw new Error('Zigbee2MQTT credential reference must agree with the explicit authentication mode.');
|
|
31
38
|
if (typeof config.host !== 'string' || !config.host || config.host.length > 253 || /[\s/@?#\u0000]/.test(config.host)) {
|
|
32
39
|
throw new Error('An explicit Zigbee2MQTT broker hostname or IP address is required.');
|
|
33
40
|
}
|
|
@@ -37,7 +44,9 @@ export const zigbee2MqttConfig = (configArg: IZigbee2MqttConfig): IZigbee2MqttCo
|
|
|
37
44
|
}
|
|
38
45
|
validateMqttTopic(config.baseTopic);
|
|
39
46
|
if (config.protocol !== undefined && config.protocol !== 'mqtt' && config.protocol !== 'mqtts') throw new Error('Unsupported Zigbee2MQTT broker protocol.');
|
|
40
|
-
|
|
47
|
+
if (config.allowUnencryptedLan !== undefined && typeof config.allowUnencryptedLan !== 'boolean') throw new Error('Zigbee2MQTT plaintext LAN consent must be explicit.');
|
|
48
|
+
if (config.protocol !== 'mqtts' && config.allowUnencryptedLan !== true) throw new Error('Unencrypted Zigbee2MQTT MQTT requires explicit LAN consent.');
|
|
49
|
+
for (const key of ['clientId']) {
|
|
41
50
|
if (config[key] !== undefined && (typeof config[key] !== 'string' || String(config[key]).length > 1024 || String(config[key]).includes('\u0000'))) {
|
|
42
51
|
throw new Error('Invalid Zigbee2MQTT broker credential or client identifier.');
|
|
43
52
|
}
|
|
@@ -46,13 +55,12 @@ export const zigbee2MqttConfig = (configArg: IZigbee2MqttConfig): IZigbee2MqttCo
|
|
|
46
55
|
if (typeof reconnect !== 'number' || !Number.isSafeInteger(reconnect) || reconnect < 0 || reconnect > 60000) {
|
|
47
56
|
throw new Error('Invalid Zigbee2MQTT reconnect interval.');
|
|
48
57
|
}
|
|
49
|
-
return {
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
homeId, authentication, credentialKey, allowUnencryptedLan: config.allowUnencryptedLan as boolean | undefined,
|
|
50
60
|
host: config.host,
|
|
51
61
|
baseTopic: config.baseTopic,
|
|
52
62
|
protocol: config.protocol ?? 'mqtt',
|
|
53
63
|
port: zigbee2MqttInteger(config.port, config.protocol === 'mqtts' ? 8883 : 1883, 65535),
|
|
54
|
-
username: config.username as string | undefined,
|
|
55
|
-
password: config.password as string | undefined,
|
|
56
64
|
clientId: config.clientId as string | undefined,
|
|
57
65
|
connectTimeoutMs: zigbee2MqttInteger(config.connectTimeoutMs, 7000, 60000),
|
|
58
66
|
operationTimeoutMs: zigbee2MqttInteger(config.operationTimeoutMs, 7000, 60000),
|
|
@@ -61,5 +69,43 @@ export const zigbee2MqttConfig = (configArg: IZigbee2MqttConfig): IZigbee2MqttCo
|
|
|
61
69
|
maxDevices: zigbee2MqttInteger(config.maxDevices, 2048, 10000),
|
|
62
70
|
maxPayloadBytes: zigbee2MqttInteger(config.maxPayloadBytes, 8388608, 16777216),
|
|
63
71
|
maxBufferedBytes: zigbee2MqttInteger(config.maxBufferedBytes, 33554432, 67108864),
|
|
64
|
-
};
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const zigbee2MqttIdentifier = (valueArg: unknown): string => {
|
|
76
|
+
if (typeof valueArg !== 'string' || !valueArg || valueArg.length > 256 || valueArg.trim() !== valueArg || /[\p{Cc}\p{Cs}]/u.test(valueArg)) {
|
|
77
|
+
throw new TypeError('Zigbee2MQTT identifier must be a bounded non-empty data string.');
|
|
78
|
+
}
|
|
79
|
+
return valueArg;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const zigbee2MqttControlKey = (ieeeAddressArg: string, propertyArg: string): string => JSON.stringify([ieeeAddressArg, propertyArg]);
|
|
83
|
+
|
|
84
|
+
export const zigbee2MqttCredentials = (configArg: IZigbee2MqttConfig, valueArg: unknown): IMqttTransportCredentials => {
|
|
85
|
+
const credentials = mqttCredentials(valueArg);
|
|
86
|
+
if (configArg.protocol !== 'mqtts' && ['ca', 'cert', 'key', 'servername'].some((key) => Object.hasOwn(credentials, key))) {
|
|
87
|
+
throw new TypeError('Zigbee2MQTT TLS credentials require an explicitly selected mqtts transport.');
|
|
88
|
+
}
|
|
89
|
+
return credentials;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const zigbee2MqttOptions = (optionsArg: IZigbee2MqttIntegrationOptions): { options: IZigbee2MqttIntegrationOptions; controls: ReadonlySet<string> } => {
|
|
93
|
+
const fields = zigbee2MqttRecord(optionsArg);
|
|
94
|
+
if (!fields || Object.keys(fields).some((key) => !['homeId', 'capacity', 'controls', 'resolveCredentials'].includes(key))) throw new TypeError('Unsupported Zigbee2MQTT owner options.');
|
|
95
|
+
if (fields.resolveCredentials !== undefined && typeof fields.resolveCredentials !== 'function') throw new TypeError('Zigbee2MQTT resolver must be a function.');
|
|
96
|
+
const controls = new Set<string>();
|
|
97
|
+
if (fields.controls !== undefined) {
|
|
98
|
+
if (!Array.isArray(fields.controls) || fields.controls.length > 10000 || Reflect.ownKeys(fields.controls).length !== fields.controls.length + 1) throw new TypeError('Zigbee2MQTT owner controls must be a finite data array.');
|
|
99
|
+
for (let index = 0; index < fields.controls.length; index++) {
|
|
100
|
+
const descriptor = Object.getOwnPropertyDescriptor(fields.controls, String(index));
|
|
101
|
+
const permission = descriptor && 'value' in descriptor && descriptor.enumerable ? zigbee2MqttRecord(descriptor.value) : undefined;
|
|
102
|
+
if (!permission || Object.keys(permission).length !== 2 || typeof permission.ieeeAddress !== 'string' || !/^0x[0-9a-f]{16}$/.test(permission.ieeeAddress) ||
|
|
103
|
+
typeof permission.property !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(permission.property)) throw new TypeError('Zigbee2MQTT controls require exact lowercase IEEE/property pairs.');
|
|
104
|
+
const key = zigbee2MqttControlKey(permission.ieeeAddress, permission.property);
|
|
105
|
+
if (controls.has(key)) throw new TypeError('Duplicate Zigbee2MQTT owner control permission.');
|
|
106
|
+
controls.add(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { options: Object.freeze({ homeId: fields.homeId as string | undefined, capacity: fields.capacity as number | undefined,
|
|
110
|
+
resolveCredentials: fields.resolveCredentials as IZigbee2MqttIntegrationOptions['resolveCredentials'] }), controls };
|
|
65
111
|
};
|
|
@@ -1,23 +1,56 @@
|
|
|
1
1
|
# Native Z-Wave JS
|
|
2
2
|
|
|
3
|
-
This adapter connects
|
|
3
|
+
This adapter connects to one explicitly selected [Z-Wave JS server WebSocket API](https://github.com/zwave-js/zwave-js-server#readme). It negotiates schema32, available from server1.32.0, verifies the numeric Z-Wave network ID in both greeting and authoritative initial state, and waits for that state before setup succeeds. Home Assistant and serial-controller access are outside this runtime.
|
|
4
4
|
|
|
5
5
|
```typescript
|
|
6
|
-
const
|
|
6
|
+
const homeId = 'my-home'; // Independently selected by the embedding host.
|
|
7
|
+
const integration = new ZwaveJsIntegration();
|
|
8
|
+
const runtime = await integration.setup({
|
|
9
|
+
homeId,
|
|
7
10
|
url: 'ws://zwave-js.local:3000',
|
|
8
11
|
expectedHomeId: 123456,
|
|
12
|
+
allowControls: true, // Optional; the default is read-only.
|
|
9
13
|
requestTimeoutMs: 10000,
|
|
10
|
-
}, {
|
|
14
|
+
}, { homeId });
|
|
15
|
+
try {
|
|
16
|
+
const devices = await runtime.devices();
|
|
17
|
+
} finally {
|
|
18
|
+
await integration.destroy();
|
|
19
|
+
}
|
|
11
20
|
```
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
`context.homeId` is required separately for setup, direct `ZwaveJsClient(config, context, options?)` and config-flow start. Constructor `{ homeId?, capacity? }` adds an optional consistency constraint; it never substitutes for context. Config, form, discovery and store scope cannot select host home. The first admitted host remains bound even after failed setup. Integration and flow share admission and terminal ownership. The flow captures context before returning its form, has no editable SHX home field, rejects conflicting submitted home, and returns the captured home. This is a trusted embedding-host contract, not a home string used as authentication.
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
`config.homeId` is the SHX consistency value. `expectedHomeId` is the separate decimal32-bit Z-Wave network ID shown by the server. Device/entity IDs include encoded SHX home, observed network ID, node and exact value tuple. These IDs replace the earlier network-only IDs; saved canonical targets must be refreshed. Node renaming does not change identity. Endpoint, property and optional propertyKey retain their actual number/string distinctions. No persistent authenticated server UUID is exposed by this protocol. Network ID and software versions alone do not prove server ownership or physical controller identity. The server connection record explicitly represents an adapter observation.
|
|
16
25
|
|
|
17
|
-
The
|
|
26
|
+
The native server supplies no authentication. Use `ws` on an explicitly trusted selected network. An operator-managed verified TLS proxy can enforce HTTP authorization. URLs cannot contain credentials, query strings or fragments, and redirects are disabled. Certificate and hostname verification remain enabled; optional `ca` adds a PEM trust anchor. Discovery is an endpoint/setup hint and never grants authorization.
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
Proxy configuration contains only `credentialRef` and explicit `credentialSource`. The two sources have no fallback:
|
|
20
29
|
|
|
21
|
-
|
|
30
|
+
- `provider`: constructor options supply caller-owned asynchronous `resolveAuthorization({ homeId, credentialRef, signal, deadline })`. The request is immutable; `deadline` uses monotonic `performance.now()` milliseconds and covers resolution plus setup. The host stores/retrieves the header outside the integration.
|
|
31
|
+
- `config-store`: explicit `context.configStore` is an actual encrypted `NoSqlConfigStore` scoped to the same SHX home and `zwave_js`; its public `get<string>(credentialRef)` returns the externally stored header.
|
|
22
32
|
|
|
23
|
-
|
|
33
|
+
Both require `wss`. The adapter never returns, logs or persists the resolved header. The reference is not a credential and never selects a home. No source is required without proxy authorization. An optional genuine store is checked for exact home/domain even on store-free paths, remains borrowed, and is never closed along with its database/key provider. There is no filesystem persistence, raw-driver access, dummy store or implicit source replacement.
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
const integration = new ZwaveJsIntegration({
|
|
37
|
+
resolveAuthorization: async ({ homeId, credentialRef, signal, deadline }) =>
|
|
38
|
+
hostCredentials.resolve({ homeId, credentialRef, signal, deadline }),
|
|
39
|
+
});
|
|
40
|
+
const runtime = await integration.setup({
|
|
41
|
+
homeId: 'my-home', url: 'wss://zwave-js.example/', expectedHomeId: 123456,
|
|
42
|
+
credentialRef: 'zwave-proxy', credentialSource: 'provider',
|
|
43
|
+
}, { homeId: 'my-home' });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Read observations are server-reported nodes, readiness/status and non-secret values; no default nodes/capabilities are inferred. Controls require explicit `allowControls: true`, a healthy runtime, an unambiguous canonical target, and a fresh authoritative membership/metadata snapshot before each one-shot dispatch. Supported writes remain advertised writable `targetValue` entries for Binary Switch(CC37, boolean) and Multilevel Switch(CC38, integer0..99 further restricted by actual metadata). Offline/stale/read-only features and entities advertise no writability. Supplied feature flags cannot grant access.
|
|
47
|
+
|
|
48
|
+
Use `writeFeature` with exact current device/feature, or `switch`/`light`/`zwave_js` `turn_on`, `turn_off`, `set_value` with exact entity/device target. Device targets with multiple supported values require `data.featureId`. Missing, ambiguous, foreign, unadvertised, secret, unavailable and incompatible targets fail. Only the documented absolute value is sent; no toggles, relative commands, inclusion, management, configuration, locks, thermostat, raw command-class APIs or controller operations exist.
|
|
49
|
+
|
|
50
|
+
Each request has a unique messageId. Correlated [SetValueResult](https://github.com/zwave-js/node-zwave-js/blob/master/packages/cc/src/lib/SetValueResult.ts) status255 retains legacy `outcome: 'confirmed'` and reports supervised device success. Status1 is working,254 is unsupervised/unknown execution, and other supported statuses are rejection/unsupported/invalid. Every result includes `causalityConfirmed: false`: a device-reported result is not independent physical acceptance or causality proof. Commands never invent reported state. Value-update events and authoritative reconciliation supply separate state observations.
|
|
51
|
+
|
|
52
|
+
Shared native ownership defaults to16 pending admissions, live runtimes and retained failed cleanup handles combined. The client bounds pending requests, admitted controls/subscriber work and subscribers to64 each, with a2MiB frame limit,128 fragments/chunks and bounded JSON depth/work. Request/setup deadline is50..60000ms(default10000). Cancellation before dispatch sends no effect; cancellation after dispatch cannot undo a command and remains indeterminate. Protocol failure, disconnect and deadline expiry are terminal; retained detached observations become unavailable and never reconnect or replay.
|
|
53
|
+
|
|
54
|
+
`destroy()` installs its stable memo before abort or callbacks, stops admission, joins actual provider/setup/reconciliation/control/subscriber work and socket closure with allSettled, and then reports cleanup errors. A late provider result after cancellation cannot allocate a socket. An unrelated late provider/subscriber rejection is sanitized and retained; cooperative rejection with the admitted signal reason remains cancellation. Providers own retrieval resources and must eventually settle their admitted promises; close intentionally waits for that actual work after request timeout. `retryCleanup()` explicitly retries cleanup/acknowledges settled failures only. It never invokes the resolver, reconnects or repeats a command, and the original destroy promise remains stable. Closing a flow stops its submissions and leaves sibling runtimes usable; integration close joins all scopes.
|
|
55
|
+
|
|
56
|
+
Local WebSocket/TLS fixtures cover schema/network admission, events and reconciliation, exact controls/correlation, read-only defaults, home/store/provider boundaries, deadlines, late provider work, capacity, callback failures and terminal cleanup. Physical acceptance on the owner's controller and representative nodes remains unverified; no real controller was accessed.
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
|
-
import
|
|
2
|
+
import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
|
|
3
|
+
import type { IIntegrationSetupContext } from '../../core/types.js';
|
|
4
|
+
import type { IZwaveJsConfig, IZwaveJsIntegrationOptions, IZwaveJsServerCommand, IZwaveJsServerEvent, IZwaveJsSnapshot, IZwaveJsState, IZwaveJsVersion, IZwaveJsWriteResult } from './zwave_js.types.js';
|
|
3
5
|
import { ZwaveJsMapper } from './zwave_js.mapper.js';
|
|
4
|
-
import { parseZwaveJsState, parseZwaveJsValueId, parseZwaveJsVersion, validateZwaveJsConfig, validateZwaveJsJson, validateZwaveJsValueData, zwaveJsRecord, zwaveJsSchemaVersion, zwaveJsValueKey } from './zwave_js.validation.js';
|
|
6
|
+
import { normalizeZwaveJsOptions, parseZwaveJsState, parseZwaveJsValueId, parseZwaveJsVersion, validateZwaveJsConfig, validateZwaveJsJson, validateZwaveJsValueData, zwaveJsRecord, zwaveJsSchemaVersion, zwaveJsValueKey } from './zwave_js.validation.js';
|
|
5
7
|
|
|
6
|
-
type TEventHandler = (eventArg: IZwaveJsServerEvent) => void
|
|
8
|
+
type TEventHandler = (eventArg: IZwaveJsServerEvent) => void | Promise<void>;
|
|
9
|
+
interface ISubscription {
|
|
10
|
+
handler: TEventHandler;
|
|
11
|
+
active: boolean;
|
|
12
|
+
pending: Set<Promise<void>>;
|
|
13
|
+
failures: Set<Error>;
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
interface IPendingRequest {
|
|
9
17
|
resolve(valueArg: unknown): void;
|
|
@@ -14,10 +22,16 @@ interface IPendingRequest {
|
|
|
14
22
|
|
|
15
23
|
export class ZwaveJsClient {
|
|
16
24
|
#config: IZwaveJsConfig;
|
|
25
|
+
readonly #context: INativeAdmissionContext;
|
|
26
|
+
readonly #options: Readonly<IZwaveJsIntegrationOptions>;
|
|
27
|
+
readonly #lifetime = new AbortController();
|
|
28
|
+
readonly #operations = new Set<Promise<unknown>>();
|
|
29
|
+
readonly #cleanupFailures = new Set<Error>();
|
|
17
30
|
#socket?: plugins.ws.WebSocket;
|
|
18
31
|
#socketClosed?: Promise<void>;
|
|
19
32
|
#startPromise?: Promise<void>;
|
|
20
33
|
#closePromise?: Promise<void>;
|
|
34
|
+
#retryPromise?: Promise<void>;
|
|
21
35
|
#version?: IZwaveJsVersion;
|
|
22
36
|
#state?: IZwaveJsState;
|
|
23
37
|
#ready = false;
|
|
@@ -25,7 +39,7 @@ export class ZwaveJsClient {
|
|
|
25
39
|
#failure?: Error;
|
|
26
40
|
#greeting?: { resolve(): void; reject(errorArg: Error): void };
|
|
27
41
|
#pending = new Map<string, IPendingRequest>();
|
|
28
|
-
#handlers = new Set<
|
|
42
|
+
#handlers = new Set<ISubscription>();
|
|
29
43
|
#events: IZwaveJsServerEvent[] = [];
|
|
30
44
|
#refreshNeeded = false;
|
|
31
45
|
#snapshotStale = false;
|
|
@@ -33,9 +47,18 @@ export class ZwaveJsClient {
|
|
|
33
47
|
#lifetimeSignal?: AbortSignal;
|
|
34
48
|
#lifetimeAbort?: () => void;
|
|
35
49
|
|
|
36
|
-
constructor(configArg: IZwaveJsConfig,
|
|
50
|
+
constructor(configArg: IZwaveJsConfig, contextArg: IIntegrationSetupContext, optionsArg: IZwaveJsIntegrationOptions = {}) {
|
|
37
51
|
this.#config = validateZwaveJsConfig(configArg);
|
|
38
|
-
this.#
|
|
52
|
+
this.#options = normalizeZwaveJsOptions(optionsArg);
|
|
53
|
+
this.#context = new NativeHomeAdmission('zwave_js', { homeId: this.#options.homeId }).admit(contextArg, this.#config.homeId);
|
|
54
|
+
if (this.#config.credentialSource === 'provider' && !this.#options.resolveAuthorization ||
|
|
55
|
+
this.#config.credentialSource === 'config-store' && !this.#context.configStore) {
|
|
56
|
+
throw new Error('Z-Wave JS selected credential source is unavailable.');
|
|
57
|
+
}
|
|
58
|
+
this.#lifetimeSignal = AbortSignal.any([this.#lifetime.signal,
|
|
59
|
+
...(this.#context.abortSignal ? [this.#context.abortSignal] : [])]);
|
|
60
|
+
this.#lifetimeAbort = () => this.fail(new Error('Z-Wave JS lifetime was aborted.'));
|
|
61
|
+
this.#lifetimeSignal.addEventListener('abort', this.#lifetimeAbort, { once: true });
|
|
39
62
|
}
|
|
40
63
|
|
|
41
64
|
public isHealthy(): boolean {
|
|
@@ -45,7 +68,7 @@ export class ZwaveJsClient {
|
|
|
45
68
|
public start(abortSignalArg?: AbortSignal): Promise<void> {
|
|
46
69
|
if (this.#destroyed || this.#failure) return Promise.reject(this.#failure ?? new Error('Z-Wave JS client is destroyed.'));
|
|
47
70
|
if (abortSignalArg?.aborted || this.#lifetimeSignal?.aborted) return Promise.reject(new Error('Z-Wave JS setup was aborted.'));
|
|
48
|
-
if (!this.#startPromise) this.#startPromise = this.connect();
|
|
71
|
+
if (!this.#startPromise) this.#startPromise = Promise.resolve().then(() => this.connect());
|
|
49
72
|
return this.withAbort(this.#startPromise, abortSignalArg);
|
|
50
73
|
}
|
|
51
74
|
|
|
@@ -56,9 +79,22 @@ export class ZwaveJsClient {
|
|
|
56
79
|
return this.snapshot();
|
|
57
80
|
}
|
|
58
81
|
|
|
59
|
-
public
|
|
82
|
+
public sendCommand(commandArg: IZwaveJsServerCommand, abortSignalArg?: AbortSignal): Promise<IZwaveJsWriteResult> {
|
|
83
|
+
if (!this.isHealthy() || !this.#config.allowControls) return Promise.reject(new Error('Z-Wave JS controls require an authorized healthy server.'));
|
|
84
|
+
if (this.#operations.size >= 64) return Promise.reject(new Error('Z-Wave JS operation capacity reached.'));
|
|
85
|
+
// Capture the exact identity/value before deferred work or foreign callbacks.
|
|
86
|
+
let command: IZwaveJsServerCommand;
|
|
87
|
+
try { command = ZwaveJsMapper.validateCommand(this.snapshot(), commandArg); }
|
|
88
|
+
catch (errorArg) { return Promise.reject(errorArg); }
|
|
89
|
+
return this.track(() => this.executeCommand(command, abortSignalArg));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async executeCommand(commandArg: IZwaveJsServerCommand, abortSignalArg?: AbortSignal): Promise<IZwaveJsWriteResult> {
|
|
60
93
|
if (abortSignalArg?.aborted) throw new Error('Z-Wave JS control was aborted before dispatch.');
|
|
61
94
|
if (!this.isHealthy()) throw new Error('Z-Wave JS control requires a healthy verified server.');
|
|
95
|
+
// Fresh authoritative advertised membership/metadata is required for every effect.
|
|
96
|
+
await this.requestState(abortSignalArg);
|
|
97
|
+
if (!this.isHealthy()) throw new Error('Z-Wave JS control metadata is unavailable or stale.');
|
|
62
98
|
const command = ZwaveJsMapper.validateCommand(this.snapshot(), commandArg);
|
|
63
99
|
const response = await this.request('node.set_value', {
|
|
64
100
|
nodeId: command.nodeId, valueId: command.valueId, value: command.value,
|
|
@@ -70,44 +106,94 @@ export class ZwaveJsClient {
|
|
|
70
106
|
throw error;
|
|
71
107
|
}
|
|
72
108
|
const status = response.result.status;
|
|
73
|
-
return { status, outcome: status === 255 ? 'confirmed' : status === 1 ? 'working' : status === 254 ? 'unsupervised' : 'rejected' };
|
|
109
|
+
return { status, outcome: status === 255 ? 'confirmed' : status === 1 ? 'working' : status === 254 ? 'unsupervised' : 'rejected', causalityConfirmed: false };
|
|
74
110
|
}
|
|
75
111
|
|
|
76
|
-
public onEvent(handlerArg: TEventHandler): () => void {
|
|
112
|
+
public onEvent(handlerArg: TEventHandler): () => Promise<void> {
|
|
77
113
|
if (this.#destroyed) throw new Error('Z-Wave JS client is destroyed.');
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
if (typeof handlerArg !== 'function') throw new TypeError('Z-Wave JS subscriber must be a function.');
|
|
115
|
+
if (this.#handlers.size >= 64) throw new Error('Z-Wave JS subscriber capacity reached.');
|
|
116
|
+
const subscription: ISubscription = { handler: handlerArg, active: true, pending: new Set(), failures: new Set() };
|
|
117
|
+
this.#handlers.add(subscription);
|
|
118
|
+
return async () => {
|
|
119
|
+
subscription.active = false;
|
|
120
|
+
await Promise.allSettled([...subscription.pending]);
|
|
121
|
+
if (subscription.failures.size) throw new AggregateError([...subscription.failures], 'Z-Wave JS subscriber cleanup failed.');
|
|
122
|
+
this.#handlers.delete(subscription);
|
|
123
|
+
};
|
|
80
124
|
}
|
|
81
125
|
|
|
82
126
|
public destroy(): Promise<void> {
|
|
83
127
|
if (!this.#closePromise) {
|
|
84
128
|
this.#destroyed = true;
|
|
129
|
+
// Publish the stable memo before abort/failure emits any foreign callback.
|
|
130
|
+
this.#closePromise = Promise.resolve().then(() => this.closeOwnedResources());
|
|
85
131
|
this.fail(new Error('Z-Wave JS client is destroyed.'));
|
|
86
|
-
this.#handlers.
|
|
87
|
-
this.#closePromise = this.closeOwnedResources();
|
|
132
|
+
for (const subscription of this.#handlers) subscription.active = false;
|
|
88
133
|
}
|
|
89
134
|
return this.#closePromise;
|
|
90
135
|
}
|
|
91
136
|
|
|
137
|
+
/** Retry retained cleanup only. Never reconnect, resolve credentials or repeat commands. */
|
|
138
|
+
public retryCleanup(): Promise<void> {
|
|
139
|
+
if (!this.#destroyed) return this.destroy();
|
|
140
|
+
if (!this.#retryPromise) {
|
|
141
|
+
this.#retryPromise = Promise.resolve().then(async () => {
|
|
142
|
+
await Promise.allSettled([this.#closePromise]);
|
|
143
|
+
this.#cleanupFailures.clear();
|
|
144
|
+
for (const subscription of this.#handlers) subscription.failures.clear();
|
|
145
|
+
await this.closeOwnedResources();
|
|
146
|
+
});
|
|
147
|
+
void this.#retryPromise.then(() => { this.#retryPromise = undefined; }, () => { this.#retryPromise = undefined; });
|
|
148
|
+
}
|
|
149
|
+
return this.#retryPromise;
|
|
150
|
+
}
|
|
151
|
+
|
|
92
152
|
private async connect(): Promise<void> {
|
|
153
|
+
if (this.#failure || this.#destroyed || this.#lifetimeSignal?.aborted) throw new Error('Z-Wave JS setup was stopped.');
|
|
154
|
+
const deadlineAt = performance.now() + this.#config.requestTimeoutMs!;
|
|
155
|
+
let authorization: string | undefined;
|
|
156
|
+
if (this.#config.credentialRef) {
|
|
157
|
+
const signal = this.#lifetimeSignal!;
|
|
158
|
+
const request = Object.freeze({ homeId: this.#context.homeId, credentialRef: this.#config.credentialRef, signal, deadline: deadlineAt });
|
|
159
|
+
// The actual promise is owned before entering the borrowed provider/store callback.
|
|
160
|
+
const resolution = this.track(async () => {
|
|
161
|
+
try {
|
|
162
|
+
return this.#config.credentialSource === 'provider' ? await this.#options.resolveAuthorization!(request) :
|
|
163
|
+
await this.#context.configStore!.get<string>(request.credentialRef);
|
|
164
|
+
} catch (errorArg) {
|
|
165
|
+
const error = new Error('Z-Wave JS credential resolution failed.');
|
|
166
|
+
if (signal.aborted && errorArg !== signal.reason) this.#cleanupFailures.add(error);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
try { authorization = await this.waitForCredential(resolution, signal, deadlineAt); }
|
|
171
|
+
catch { this.fail(new Error('Z-Wave JS credential admission failed.')); throw this.#failure; }
|
|
172
|
+
if (typeof authorization !== 'string' || !authorization.trim() || authorization.length > 4096 || /[\x00-\x1f\x7f]/.test(authorization)) {
|
|
173
|
+
this.fail(new Error('Z-Wave JS credential provider returned an invalid authorization header.'));
|
|
174
|
+
throw this.#failure;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (this.#destroyed || this.#failure || this.#lifetimeSignal?.aborted || performance.now() >= deadlineAt) {
|
|
178
|
+
this.fail(new Error('Z-Wave JS setup was stopped before connection.'));
|
|
179
|
+
throw this.#failure;
|
|
180
|
+
}
|
|
93
181
|
const greeting = new Promise<void>((resolveArg, rejectArg) => {
|
|
94
182
|
this.#greeting = { resolve: resolveArg, reject: rejectArg };
|
|
95
183
|
});
|
|
96
184
|
const greetingOutcome = greeting.then(() => ({ accepted: true as const }),
|
|
97
185
|
(errorArg: unknown) => ({ accepted: false as const, error: errorArg }));
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
this.#lifetimeAbort = () => this.fail(new Error('Z-Wave JS lifetime was aborted.'));
|
|
101
|
-
this.#lifetimeSignal.addEventListener('abort', this.#lifetimeAbort, { once: true });
|
|
102
|
-
}
|
|
186
|
+
const remainingMs = Math.max(1, deadlineAt - performance.now());
|
|
187
|
+
const deadline = setTimeout(() => this.fail(new Error('Z-Wave JS setup deadline expired.')), remainingMs);
|
|
103
188
|
try {
|
|
104
189
|
const socket = new plugins.ws.WebSocket(this.#config.url, {
|
|
105
|
-
headers:
|
|
190
|
+
headers: authorization ? { Authorization: authorization } : undefined,
|
|
106
191
|
ca: this.#config.ca, rejectUnauthorized: true, followRedirects: false,
|
|
107
|
-
handshakeTimeout:
|
|
192
|
+
handshakeTimeout: remainingMs,
|
|
108
193
|
maxPayload: 2 * 1024 * 1024, maxFragments: 128, maxBufferedChunks: 128, perMessageDeflate: false,
|
|
109
194
|
});
|
|
110
195
|
this.#socket = socket;
|
|
196
|
+
authorization = undefined;
|
|
111
197
|
this.#socketClosed = new Promise<void>((resolveArg) => socket.once('close', () => resolveArg()));
|
|
112
198
|
socket.on('error', () => this.fail(new Error('Z-Wave JS WebSocket connection failed.')));
|
|
113
199
|
socket.on('close', () => this.fail(new Error('Z-Wave JS WebSocket connection closed.')));
|
|
@@ -155,8 +241,8 @@ export class ZwaveJsClient {
|
|
|
155
241
|
});
|
|
156
242
|
}
|
|
157
243
|
|
|
158
|
-
private requestState(): Promise<unknown> {
|
|
159
|
-
return this.request('start_listening', {},
|
|
244
|
+
private requestState(abortSignalArg?: AbortSignal): Promise<unknown> {
|
|
245
|
+
return this.request('start_listening', {}, abortSignalArg, (resultArg) => {
|
|
160
246
|
if (!zwaveJsRecord(resultArg)) throw new Error('Malformed Z-Wave JS state result.');
|
|
161
247
|
this.#state = parseZwaveJsState(resultArg.state, this.#config.expectedHomeId);
|
|
162
248
|
this.#snapshotStale = false;
|
|
@@ -243,7 +329,7 @@ export class ZwaveJsClient {
|
|
|
243
329
|
this.#refreshNeeded = true;
|
|
244
330
|
this.#snapshotStale = true;
|
|
245
331
|
if (this.#refreshPromise) return;
|
|
246
|
-
this.#refreshPromise = (async () => {
|
|
332
|
+
this.#refreshPromise = Promise.resolve().then(async () => {
|
|
247
333
|
try {
|
|
248
334
|
while (this.#refreshNeeded && !this.#failure && !this.#destroyed) {
|
|
249
335
|
this.#refreshNeeded = false;
|
|
@@ -253,22 +339,38 @@ export class ZwaveJsClient {
|
|
|
253
339
|
} catch (errorArg) {
|
|
254
340
|
this.fail(errorArg instanceof Error ? errorArg : new Error('Z-Wave JS state reconciliation failed.'));
|
|
255
341
|
} finally { this.#refreshPromise = undefined; }
|
|
256
|
-
})
|
|
342
|
+
});
|
|
257
343
|
}
|
|
258
344
|
|
|
259
345
|
private emit(eventArg: IZwaveJsServerEvent): void {
|
|
260
346
|
this.#events.push(structuredClone(eventArg));
|
|
261
347
|
if (this.#events.length > 64) this.#events.shift();
|
|
262
|
-
for (const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
this.fail(new Error('Z-Wave JS subscriber
|
|
348
|
+
for (const subscription of this.#handlers) {
|
|
349
|
+
if (!subscription.active) continue;
|
|
350
|
+
if (this.#operations.size >= 64) {
|
|
351
|
+
this.fail(new Error('Z-Wave JS subscriber operation capacity reached.'));
|
|
352
|
+
break;
|
|
266
353
|
}
|
|
354
|
+
const event = structuredClone(eventArg);
|
|
355
|
+
const notification = this.track(async () => {
|
|
356
|
+
if (!subscription.active) return;
|
|
357
|
+
try { await subscription.handler(event); }
|
|
358
|
+
catch {
|
|
359
|
+
const error = new Error('Z-Wave JS subscriber failed; the runtime was stopped.');
|
|
360
|
+
subscription.failures.add(error);
|
|
361
|
+
this.#cleanupFailures.add(error);
|
|
362
|
+
subscription.active = false;
|
|
363
|
+
this.fail(error);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
subscription.pending.add(notification);
|
|
367
|
+
void notification.then(() => subscription.pending.delete(notification), () => subscription.pending.delete(notification));
|
|
267
368
|
}
|
|
268
369
|
}
|
|
269
370
|
|
|
270
371
|
private snapshot(): IZwaveJsSnapshot {
|
|
271
372
|
return structuredClone({
|
|
373
|
+
homeId: this.#context.homeId, allowControls: this.#config.allowControls === true,
|
|
272
374
|
version: this.#version, controller: this.#state?.controller, nodes: this.#state?.nodes ?? [],
|
|
273
375
|
connected: this.isHealthy(), events: this.#events,
|
|
274
376
|
});
|
|
@@ -282,15 +384,36 @@ export class ZwaveJsClient {
|
|
|
282
384
|
this.#greeting?.reject(errorArg);
|
|
283
385
|
for (const pending of this.#pending.values()) { pending.cleanup(); pending.reject(errorArg); }
|
|
284
386
|
this.#pending.clear();
|
|
387
|
+
this.#lifetime.abort(errorArg);
|
|
285
388
|
this.#socket?.terminate();
|
|
286
389
|
this.emit({ source: 'connection', event: 'closed' });
|
|
287
390
|
}
|
|
288
391
|
|
|
289
392
|
private async closeOwnedResources(): Promise<void> {
|
|
290
393
|
if (this.#lifetimeAbort) this.#lifetimeSignal?.removeEventListener('abort', this.#lifetimeAbort);
|
|
291
|
-
await Promise.allSettled([this.#startPromise, this.#refreshPromise, this.#socketClosed].filter((promiseArg) => promiseArg !== undefined));
|
|
394
|
+
await Promise.allSettled([this.#startPromise, this.#refreshPromise, this.#socketClosed, ...this.#operations].filter((promiseArg) => promiseArg !== undefined));
|
|
292
395
|
this.#socket?.removeAllListeners();
|
|
293
396
|
this.#socket = undefined;
|
|
397
|
+
if (this.#cleanupFailures.size) throw new AggregateError([...this.#cleanupFailures], 'Z-Wave JS cleanup failed; settled failures remain retained for explicit cleanup retry.');
|
|
398
|
+
this.#handlers.clear();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private track<TResult>(workArg: () => Promise<TResult>): Promise<TResult> {
|
|
402
|
+
const operation = Promise.resolve().then(workArg);
|
|
403
|
+
this.#operations.add(operation);
|
|
404
|
+
void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
|
|
405
|
+
return operation;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private waitForCredential(promiseArg: Promise<string | undefined>, signalArg: AbortSignal, deadlineArg: number): Promise<string | undefined> {
|
|
409
|
+
return new Promise((resolveArg, rejectArg) => {
|
|
410
|
+
const abort = () => { cleanup(); rejectArg(new Error('Z-Wave JS credential admission was aborted.')); };
|
|
411
|
+
const timer = setTimeout(() => { cleanup(); rejectArg(new Error('Z-Wave JS credential deadline expired.')); }, Math.max(1, deadlineArg - performance.now()));
|
|
412
|
+
const cleanup = () => { clearTimeout(timer); signalArg.removeEventListener('abort', abort); };
|
|
413
|
+
signalArg.addEventListener('abort', abort, { once: true });
|
|
414
|
+
if (signalArg.aborted) abort();
|
|
415
|
+
void promiseArg.then((valueArg) => { cleanup(); resolveArg(valueArg); }, () => { cleanup(); rejectArg(new Error('Z-Wave JS credential resolution failed.')); });
|
|
416
|
+
});
|
|
294
417
|
}
|
|
295
418
|
|
|
296
419
|
private withAbort(promiseArg: Promise<void>, abortSignalArg?: AbortSignal): Promise<void> {
|
|
@@ -302,7 +425,8 @@ export class ZwaveJsClient {
|
|
|
302
425
|
rejectArg(error);
|
|
303
426
|
};
|
|
304
427
|
abortSignalArg.addEventListener('abort', abort, { once: true });
|
|
305
|
-
promiseArg.then(
|
|
428
|
+
void promiseArg.then(() => { abortSignalArg.removeEventListener('abort', abort); resolveArg(); },
|
|
429
|
+
(errorArg: unknown) => { abortSignalArg.removeEventListener('abort', abort); rejectArg(errorArg); });
|
|
306
430
|
});
|
|
307
431
|
}
|
|
308
432
|
}
|
|
@@ -1,38 +1,62 @@
|
|
|
1
1
|
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
|
2
|
+
import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
|
|
2
3
|
import { ZwaveJsClient } from './zwave_js.classes.client.js';
|
|
3
|
-
import type { IZwaveJsConfig } from './zwave_js.types.js';
|
|
4
|
-
import { validateZwaveJsConfig } from './zwave_js.validation.js';
|
|
4
|
+
import type { IZwaveJsConfig, IZwaveJsIntegrationOptions } from './zwave_js.types.js';
|
|
5
|
+
import { captureZwaveJsData, normalizeZwaveJsOptions, validateZwaveJsConfig } from './zwave_js.validation.js';
|
|
5
6
|
|
|
6
7
|
export class ZwaveJsConfigFlow implements IConfigFlow<IZwaveJsConfig> {
|
|
8
|
+
readonly #options: Readonly<IZwaveJsIntegrationOptions>;
|
|
9
|
+
readonly #scope: NativeIntegrationScope;
|
|
10
|
+
|
|
11
|
+
constructor(optionsArg: IZwaveJsIntegrationOptions = {}, scopeArg?: NativeIntegrationScope) {
|
|
12
|
+
this.#options = normalizeZwaveJsOptions(optionsArg);
|
|
13
|
+
this.#scope = scopeArg ?? new NativeIntegrationOwner('zwave_js', {
|
|
14
|
+
homeId: this.#options.homeId, capacity: this.#options.capacity,
|
|
15
|
+
}).createFlowScope();
|
|
16
|
+
}
|
|
17
|
+
|
|
7
18
|
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IZwaveJsConfig>> {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
19
|
+
try {
|
|
20
|
+
const candidate = captureZwaveJsData(candidateArg);
|
|
21
|
+
if (candidate.integrationDomain !== undefined && candidate.integrationDomain !== 'zwave_js') throw new Error('Foreign Z-Wave JS candidate.');
|
|
22
|
+
const admitted = this.#scope.admit(contextArg);
|
|
23
|
+
return {
|
|
24
|
+
kind: 'form', title: 'Connect selected Z-Wave JS server',
|
|
25
|
+
description: 'Select the server and its numeric Z-Wave network ID for the host-selected home. Optional proxy credentials use an explicit reference source and verified TLS. Controls default to read-only.',
|
|
26
|
+
fields: [
|
|
27
|
+
{ name: 'url', label: 'Server WebSocket URL', type: 'text', required: true },
|
|
28
|
+
{ name: 'expectedHomeId', label: 'Z-Wave network home ID (decimal)', type: 'number', required: true },
|
|
29
|
+
{ name: 'allowControls', label: 'Authorize supported absolute Binary/Multilevel Switch writes', type: 'boolean' },
|
|
30
|
+
{ name: 'credentialRef', label: 'Optional proxy credential reference', type: 'text' },
|
|
31
|
+
{ name: 'credentialSource', label: 'Explicit credential source', type: 'select', options: [
|
|
32
|
+
{ label: 'External provider', value: 'provider' }, { label: 'Borrowed encrypted config store', value: 'config-store' },
|
|
33
|
+
] },
|
|
34
|
+
{ name: 'ca', label: 'Additional TLS CA certificate (PEM)', type: 'text' },
|
|
35
|
+
],
|
|
36
|
+
submit: async (inputArg) => {
|
|
37
|
+
try {
|
|
38
|
+
const values = captureZwaveJsData(inputArg, ['homeId', 'url', 'expectedHomeId', 'allowControls', 'credentialRef', 'credentialSource', 'ca', 'requestTimeoutMs']);
|
|
39
|
+
if (values.homeId !== undefined && values.homeId !== admitted.homeId) throw new Error('Submitted home conflicts with selected host home.');
|
|
40
|
+
const config = validateZwaveJsConfig({ ...values, homeId: admitted.homeId });
|
|
41
|
+
return await this.#scope.run(admitted, config.homeId, async (lease) => {
|
|
42
|
+
const client = new ZwaveJsClient(config, { ...lease.context, abortSignal: lease.signal }, this.#options);
|
|
43
|
+
lease.own(() => client.retryCleanup());
|
|
44
|
+
await client.start();
|
|
45
|
+
lease.assertOpen();
|
|
46
|
+
return { kind: 'done', title: 'Verified selected Z-Wave JS network', config };
|
|
47
|
+
});
|
|
48
|
+
} catch (errorArg) {
|
|
49
|
+
if (errorArg instanceof AggregateError) throw errorArg;
|
|
50
|
+
return { kind: 'error', title: 'Z-Wave JS verification failed',
|
|
51
|
+
description: 'Check host home, selected endpoint, network ID, schema, TLS and explicit credential source.' };
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return { kind: 'error', title: 'Z-Wave JS admission failed', description: 'Supply the independent host-selected home and matching optional store.' };
|
|
57
|
+
}
|
|
37
58
|
}
|
|
59
|
+
|
|
60
|
+
public destroy(): Promise<void> { return this.#scope.close(); }
|
|
61
|
+
public retryCleanup(): Promise<void> { return this.#scope.retryCleanup(); }
|
|
38
62
|
}
|