@smarthome.exchange/integrations 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.md +46 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
- package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
- package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
- package/dist_ts/core/classes.runtimemanager.js +11 -3
- package/dist_ts/core/types.d.ts +2 -0
- package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
- package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
- package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
- package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
- package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
- package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
- package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
- package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
- package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
- package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
- package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
- package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
- package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
- package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
- package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
- package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
- package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
- package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
- package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
- package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
- package/dist_ts/integrations/esphome/esphome.types.js +1 -1
- package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
- package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
- package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
- package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
- package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
- package/dist_ts/integrations/hue/hue.classes.client.d.ts +32 -4
- package/dist_ts/integrations/hue/hue.classes.client.js +331 -57
- package/dist_ts/integrations/hue/hue.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/hue/hue.classes.configflow.js +79 -27
- package/dist_ts/integrations/hue/hue.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/hue/hue.classes.integration.js +83 -62
- package/dist_ts/integrations/hue/hue.mapper.d.ts +2 -0
- package/dist_ts/integrations/hue/hue.mapper.js +8 -6
- package/dist_ts/integrations/hue/hue.types.d.ts +32 -1
- package/dist_ts/integrations/hue/hue.validation.d.ts +9 -3
- package/dist_ts/integrations/hue/hue.validation.js +112 -11
- package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
- package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
- package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
- package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
- package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
- package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
- package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
- package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
- package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
- package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
- package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
- package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
- package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
- package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
- package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
- package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
- package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
- package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
- package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
- package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
- package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
- package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
- package/dist_ts/integrations/shelly/shelly.types.js +11 -2
- package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
- package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
- package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
- package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
- package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
- package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
- package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
- package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
- package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
- package/dist_ts/integrations/sonos/sonos.config.js +23 -2
- package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
- package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
- package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
- package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
- package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
- package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
- package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
- package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
- package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
- package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
- package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
- package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
- package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
- package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
- package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
- package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
- package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
- package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
- package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
- package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
- package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
- package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
- package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
- package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
- package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
- package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
- package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
- package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
- package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
- package/dist_ts/integrations/webostv/webostv.types.js +1 -1
- package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
- package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
- package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
- package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
- package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
- package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
- package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
- package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
- package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
- package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/package.json +10 -10
- package/readme.md +25 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/classes.nativeintegrationowner.ts +5 -0
- package/ts/core/classes.runtimemanager.ts +11 -2
- package/ts/core/types.ts +2 -0
- package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
- package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
- package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
- package/ts/integrations/deconz/deconz.mapper.ts +22 -5
- package/ts/integrations/deconz/deconz.types.ts +49 -6
- package/ts/integrations/deconz/deconz.validation.ts +177 -4
- package/ts/integrations/deconz/readme.md +15 -1
- package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
- package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
- package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
- package/ts/integrations/esphome/esphome.discovery.ts +3 -12
- package/ts/integrations/esphome/esphome.mapper.ts +9 -7
- package/ts/integrations/esphome/esphome.types.ts +35 -6
- package/ts/integrations/esphome/esphome.validation.ts +66 -0
- package/ts/integrations/esphome/readme.md +20 -6
- package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
- package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
- package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
- package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
- package/ts/integrations/homeassistant/readme.md +22 -6
- package/ts/integrations/hue/hue.classes.client.ts +288 -55
- package/ts/integrations/hue/hue.classes.configflow.ts +68 -25
- package/ts/integrations/hue/hue.classes.integration.ts +75 -54
- package/ts/integrations/hue/hue.mapper.ts +8 -5
- package/ts/integrations/hue/hue.types.ts +37 -1
- package/ts/integrations/hue/hue.validation.ts +102 -13
- package/ts/integrations/hue/readme.md +49 -12
- package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
- package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
- package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
- package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
- package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
- package/ts/integrations/mqtt/mqtt.types.ts +52 -0
- package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
- package/ts/integrations/mqtt/readme.md +12 -3
- package/ts/integrations/samsungtv/readme.md +11 -5
- package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
- package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
- package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
- package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
- package/ts/integrations/shelly/readme.md +22 -7
- package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
- package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
- package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
- package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
- package/ts/integrations/shelly/shelly.mapper.ts +19 -20
- package/ts/integrations/shelly/shelly.rpc.ts +2 -0
- package/ts/integrations/shelly/shelly.types.ts +50 -2
- package/ts/integrations/shelly/shelly.validation.ts +76 -0
- package/ts/integrations/sonos/readme.md +117 -38
- package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
- package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
- package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
- package/ts/integrations/sonos/sonos.config.ts +24 -2
- package/ts/integrations/sonos/sonos.mapper.ts +13 -8
- package/ts/integrations/sonos/sonos.matchers.ts +2 -2
- package/ts/integrations/sonos/sonos.types.ts +30 -1
- package/ts/integrations/tasmota/readme.md +16 -5
- package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
- package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
- package/ts/integrations/tasmota/tasmota.config.ts +17 -0
- package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
- package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
- package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
- package/ts/integrations/tasmota/tasmota.types.ts +43 -2
- package/ts/integrations/tuya/readme.md +52 -10
- package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
- package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
- package/ts/integrations/tuya/tuya.mapper.ts +1 -1
- package/ts/integrations/tuya/tuya.types.ts +4 -0
- package/ts/integrations/tuya/tuya.validation.ts +5 -8
- package/ts/integrations/webostv/readme.md +115 -22
- package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
- package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
- package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
- package/ts/integrations/webostv/webostv.mapper.ts +4 -4
- package/ts/integrations/webostv/webostv.types.ts +18 -3
- package/ts/integrations/webostv/webostv.validation.ts +22 -1
- package/ts/integrations/zigbee2mqtt/readme.md +22 -27
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
- package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
- package/ts/integrations/zwave_js/readme.md +42 -9
- package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
- package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
- package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
- package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
- package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
- package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
- package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
- package/ts/plugins.ts +2 -1
|
@@ -8,9 +8,10 @@ export class SonosMapper {
|
|
|
8
8
|
const deviceId = this.deviceId(snapshotArg);
|
|
9
9
|
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
|
10
10
|
{ id: 'playback', capability: 'media', name: 'Playback', readable: true,
|
|
11
|
-
writable: snapshotArg.group.coordinatorUid === snapshotArg.speakerInfo.uid
|
|
12
|
-
|
|
13
|
-
{ id: '
|
|
11
|
+
writable: snapshotArg.controlAllowed && snapshotArg.group.coordinatorUid === snapshotArg.speakerInfo.uid
|
|
12
|
+
&& snapshotArg.capabilities.transportActions.some((action) => ['Play', 'Pause', 'Stop'].includes(action) && snapshotArg.capabilities.declaredTransportActions.includes(action)) },
|
|
13
|
+
{ id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: snapshotArg.controlAllowed && snapshotArg.capabilities.volumeWritable, unit: '%' },
|
|
14
|
+
{ id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: snapshotArg.controlAllowed && snapshotArg.capabilities.muteWritable },
|
|
14
15
|
];
|
|
15
16
|
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
|
16
17
|
{ featureId: 'playback', value: this.playbackState(snapshotArg), updatedAt },
|
|
@@ -33,6 +34,7 @@ export class SonosMapper {
|
|
|
33
34
|
features,
|
|
34
35
|
state,
|
|
35
36
|
metadata: {
|
|
37
|
+
homeId: snapshotArg.homeId,
|
|
36
38
|
uid: snapshotArg.speakerInfo.uid,
|
|
37
39
|
serialNumber: snapshotArg.speakerInfo.serialNumber,
|
|
38
40
|
softwareVersion: snapshotArg.speakerInfo.softwareVersion,
|
|
@@ -41,20 +43,23 @@ export class SonosMapper {
|
|
|
41
43
|
coordinatorUid: snapshotArg.group.coordinatorUid,
|
|
42
44
|
groupMemberUids: [...snapshotArg.group.memberUids],
|
|
43
45
|
stateSource: 'local_poll',
|
|
46
|
+
physicalEffectConfirmed: false,
|
|
47
|
+
capabilities: structuredClone(snapshotArg.capabilities),
|
|
44
48
|
},
|
|
45
49
|
}];
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
public static toEntities(snapshotArg: ISonosSnapshot): IIntegrationEntity[] {
|
|
49
53
|
return [{
|
|
50
|
-
id: this.entityId(snapshotArg.speakerInfo.uid),
|
|
51
|
-
uniqueId:
|
|
54
|
+
id: this.entityId(snapshotArg.homeId, snapshotArg.speakerInfo.uid),
|
|
55
|
+
uniqueId: this.deviceId(snapshotArg),
|
|
52
56
|
integrationDomain: 'sonos',
|
|
53
57
|
deviceId: this.deviceId(snapshotArg),
|
|
54
58
|
platform: 'media_player',
|
|
55
59
|
name: snapshotArg.speakerInfo.zoneName,
|
|
56
60
|
state: this.playbackState(snapshotArg),
|
|
57
61
|
attributes: {
|
|
62
|
+
homeId: snapshotArg.homeId,
|
|
58
63
|
volumeLevel: snapshotArg.volume / 100,
|
|
59
64
|
muted: snapshotArg.muted,
|
|
60
65
|
mediaTitle: snapshotArg.positionInfo.title,
|
|
@@ -91,11 +96,11 @@ export class SonosMapper {
|
|
|
91
96
|
}
|
|
92
97
|
|
|
93
98
|
public static deviceId(snapshotArg: ISonosSnapshot): string {
|
|
94
|
-
return `sonos.player.${this.slug(snapshotArg.speakerInfo.uid)}`;
|
|
99
|
+
return `sonos.player.${encodeURIComponent(snapshotArg.homeId)}.${this.slug(snapshotArg.speakerInfo.uid)}`;
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
public static entityId(uid: string): string {
|
|
98
|
-
return `media_player.
|
|
102
|
+
public static entityId(homeId: string, uid: string): string {
|
|
103
|
+
return `media_player.sonos.${encodeURIComponent(homeId)}.${this.slug(uid)}`;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
private static slug(valueArg: string): string {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
|
2
|
-
import { isSonosDataRecord,
|
|
2
|
+
import { isSonosDataRecord, normalizeSonosEndpoint, normalizeSonosUid } from './sonos.config.js';
|
|
3
3
|
import type { ISonosManualEntry, ISonosMdnsRecord, ISonosSsdpRecord } from './sonos.types.js';
|
|
4
4
|
|
|
5
5
|
const sonosUpnpSt = 'urn:schemas-upnp-org:device:ZonePlayer:1';
|
|
@@ -8,7 +8,7 @@ const noMatch = (reason: string): IDiscoveryMatch => ({ matched: false, confiden
|
|
|
8
8
|
function candidateMatch(source: IDiscoveryCandidate['source'], host: unknown, port: unknown, uid?: unknown): IDiscoveryMatch {
|
|
9
9
|
try {
|
|
10
10
|
if (typeof host !== 'string' || typeof port !== 'number' || (uid !== undefined && typeof uid !== 'string')) throw new Error('Sonos candidate fields are invalid.');
|
|
11
|
-
const config =
|
|
11
|
+
const config = normalizeSonosEndpoint({ host, port, ...(uid ? { expectedUid: uid } : {}) });
|
|
12
12
|
return {
|
|
13
13
|
matched: true, confidence: uid ? 'high' : 'medium', reason: 'Candidate identifies a local Sonos endpoint; setup verifies the renderer.',
|
|
14
14
|
normalizedDeviceId: config.expectedUid,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface ISonosEndpointConfig {
|
|
2
2
|
host: string;
|
|
3
3
|
port?: number;
|
|
4
4
|
expectedUid?: string;
|
|
@@ -7,6 +7,16 @@ export interface ISonosConfig {
|
|
|
7
7
|
pollIntervalMs?: number;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export interface ISonosConfig extends ISonosEndpointConfig {
|
|
11
|
+
homeId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Host-owned permission; never accepted from stored config, a form or discovery. */
|
|
15
|
+
export interface ISonosOwnerOptions {
|
|
16
|
+
readonly homeId?: string;
|
|
17
|
+
readonly allowControl?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export interface ISonosSpeakerInfo {
|
|
11
21
|
uid: string;
|
|
12
22
|
zoneName: string;
|
|
@@ -37,6 +47,8 @@ export interface ISonosPositionInfo {
|
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
export interface ISonosSnapshot {
|
|
50
|
+
homeId: string;
|
|
51
|
+
controlAllowed: boolean;
|
|
40
52
|
speakerInfo: ISonosSpeakerInfo;
|
|
41
53
|
transportInfo: ISonosTransportInfo;
|
|
42
54
|
positionInfo: ISonosPositionInfo;
|
|
@@ -44,6 +56,23 @@ export interface ISonosSnapshot {
|
|
|
44
56
|
muted: boolean;
|
|
45
57
|
group: ISonosGroup;
|
|
46
58
|
observedAt: string;
|
|
59
|
+
capabilities: ISonosCapabilities;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ISonosCapabilities {
|
|
63
|
+
readonly transportActions: readonly string[];
|
|
64
|
+
readonly declaredTransportActions: readonly string[];
|
|
65
|
+
readonly volumeWritable: boolean;
|
|
66
|
+
readonly muteWritable: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ISonosControlResult {
|
|
70
|
+
readonly acknowledgedActions: readonly string[];
|
|
71
|
+
readonly snapshot: ISonosSnapshot;
|
|
72
|
+
readonly outcome: 'observed-target' | 'observed-different' | 'observed-state';
|
|
73
|
+
readonly requestedValue?: string | number | boolean;
|
|
74
|
+
readonly observedValue?: string | number | boolean;
|
|
75
|
+
readonly physicalEffectConfirmed: false;
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
export interface ISonosGroup {
|
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
# Native Tasmota runtime
|
|
2
2
|
|
|
3
|
-
This integration connects directly to a MQTT broker through the package's shared `MqttClient
|
|
3
|
+
This integration connects directly to a MQTT broker through the package's shared `MqttSession`, which owns credential resolution and its `MqttClient` transport. Configure each Tasmota device's broker and unique `Topic` before setup. No Home Assistant connection is required.
|
|
4
4
|
|
|
5
5
|
```typescript
|
|
6
6
|
import { HomeAssistantTasmotaIntegration } from '@smarthome.exchange/integrations';
|
|
7
7
|
|
|
8
|
-
const
|
|
9
|
-
|
|
8
|
+
const integration = new HomeAssistantTasmotaIntegration({ enableControls: true });
|
|
9
|
+
const runtime = await integration.setup({
|
|
10
|
+
homeId: selectedHomeId,
|
|
11
|
+
authentication: { mode: 'credential-reference', credentialRef: 'broker-main' },
|
|
12
|
+
broker: { host: 'mqtt.home', protocol: 'mqtts' },
|
|
10
13
|
devices: [{ topic: 'desk', relays: [1], brightness: true }],
|
|
11
14
|
initialSnapshotTimeoutMs: 10_000,
|
|
12
|
-
}, { abortSignal });
|
|
15
|
+
}, { homeId: selectedHomeId, configStore: borrowedEncryptedStore, abortSignal });
|
|
13
16
|
|
|
14
17
|
try {
|
|
15
18
|
const [device] = await runtime.devices();
|
|
16
19
|
const feature = device.features.find((feature) => feature.id === 'power_1');
|
|
17
20
|
if (feature) await runtime.writeFeature({ device, feature, value: false });
|
|
18
21
|
} finally {
|
|
19
|
-
await
|
|
22
|
+
await integration.destroy();
|
|
20
23
|
}
|
|
21
24
|
```
|
|
22
25
|
|
|
26
|
+
The embedding host must independently supply its selected `homeId` in setup and config-flow context. The configured home must match it. An optional constructor `homeId` adds an immutable constraint; without it, the first admitted setup or flow fixes the instance's home even if setup fails. Discovery metadata, editable values and store scope cannot select the home. Default registry construction remains inert. Entity identifiers include the captured host home. This contract relies on the embedding host's authorization boundary; a home string is not an authentication token.
|
|
27
|
+
|
|
28
|
+
Authentication is explicit: `authentication: { mode: 'anonymous' }` selects a broker requiring no authentication. Otherwise, a credential reference requires an actual matching borrowed `NoSqlConfigStore` or a runtime-only constructor `credentialResolver`. Legacy plaintext broker username/password configuration fails with an explicit reconfiguration requirement. Flow secret inputs are transient; an admitted encrypted store creates an insert-only reference and the returned configuration contains no secrets. A cancelled late credential write is joined and removed before flow shutdown completes. Anonymous mode never substitutes for a missing or failed credential source.
|
|
29
|
+
|
|
30
|
+
A constructor resolver receives frozen `{ homeId, credentialRef, broker: { host, port, protocol }, signal, deadlineMonotonicMs }`; the deadline uses `performance.now()`. It returns a plain version1 record containing exactly that broker identity and optional username/password or TLS ca/cert/key/servername. Certificate and private key must be paired; TLS material requires mqtts. A result for another broker is rejected before connecting. Tasmota adapts this broker-bound contract to the shared MQTT session resolver. The session fences startup on cancellation or deadline and retains the actual resolver promise for shutdown, so an ignored cancellation or late result cannot connect. Resolver failures are sanitized and remain observable through session cleanup; explicit `retryCleanup()` acknowledges drained failures without resolving credentials again. The caller owns its resolver, store, database and key provider; the integration closes none of them.
|
|
31
|
+
|
|
32
|
+
Relay and brightness writes require the embedding owner's runtime-only constructor `enableControls: true`. This permission is not accepted from config forms. Without it, controls remain readable and writes fail before publishing. Every admitted command shares the owner's bounded capacity with pending setups, flows, runtimes and failed cleanup handles (default16, adjustable with constructor `capacity`). Integration destruction is terminal, drains every owned runtime and pending operation, and returns a stable close promise. Runtime destruction releases its owned lease. Failed cleanup remains retained; `integration.retryCleanup()` retries resource cleanup only, never setup, authentication, subscription or control commands.
|
|
33
|
+
|
|
23
34
|
`relays` defaults to `[1]`; use `[]` for sensor-only devices. `brightness: true` enables the single-channel `Dimmer` command with integer percentage values from 0 through 100. Device identifiers default to `tasmota:<encoded-topic>` and can be assigned explicitly through `id`. `fullTopic` defaults to `%prefix%/%topic%/`; custom patterns must end in `/` and include each token exactly once. Only those two substitution tokens are supported. Custom `prefixes` specify all three command, status and telemetry prefixes. `powerOnPayload` and `powerOffPayload` describe customized `StateText` output; commands still use the standard `ON` and `OFF` arguments.
|
|
24
35
|
|
|
25
36
|
Setup shares one deadline across the broker connection, subscription acknowledgments, state queries and first fresh snapshots. It requests `State` and `Status 10` for every selected device. A fresh nonretained `STATE`, `STATUS11` or complete `RESULT` state response must report every configured control before that device becomes ready. Retained state can provide an offline snapshot, but cannot establish readiness or overwrite ready state. The protocol does not correlate MQTT 3.1.1 command responses; readiness establishes current nonretained state after a query, rather than proving which request caused the message.
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { BaseIntegration } from '../../core/classes.baseintegration.js';
|
|
2
|
+
import { NativeIntegrationOwner } from '../../core/classes.nativeintegrationowner.js';
|
|
2
3
|
import type { IIntegrationSetupContext } from '../../core/types.js';
|
|
3
4
|
import { createTasmotaDiscoveryDescriptor, TasmotaConfigFlow } from './tasmota.discovery.js';
|
|
4
5
|
import { TasmotaRuntime } from './tasmota.classes.runtime.js';
|
|
5
6
|
import type { IHomeAssistantTasmotaConfig } from './tasmota.types.js';
|
|
7
|
+
import type { ITasmotaIntegrationOptions } from './tasmota.types.js';
|
|
8
|
+
import { normalizeTasmotaConfig } from './tasmota.config.js';
|
|
9
|
+
import { tasmotaOptions } from './tasmota.credentials.js';
|
|
6
10
|
|
|
7
11
|
export class HomeAssistantTasmotaIntegration extends BaseIntegration<IHomeAssistantTasmotaConfig> {
|
|
8
12
|
public readonly domain = 'tasmota';
|
|
9
13
|
public readonly displayName = 'Tasmota';
|
|
10
14
|
public readonly status = 'control-runtime' as const;
|
|
11
15
|
public readonly discoveryDescriptor = createTasmotaDiscoveryDescriptor();
|
|
12
|
-
public readonly configFlow
|
|
16
|
+
public readonly configFlow: TasmotaConfigFlow;
|
|
17
|
+
private readonly owner: NativeIntegrationOwner;
|
|
18
|
+
private readonly options: ITasmotaIntegrationOptions;
|
|
13
19
|
public readonly metadata = {
|
|
14
20
|
source: 'home-assistant/core',
|
|
15
21
|
upstreamPath: 'homeassistant/components/tasmota',
|
|
@@ -19,12 +25,24 @@ export class HomeAssistantTasmotaIntegration extends BaseIntegration<IHomeAssist
|
|
|
19
25
|
scope: 'selected-device-relays-brightness-sensors',
|
|
20
26
|
};
|
|
21
27
|
|
|
28
|
+
constructor(optionsArg: ITasmotaIntegrationOptions = {}) {
|
|
29
|
+
super();
|
|
30
|
+
this.options = tasmotaOptions(optionsArg);
|
|
31
|
+
this.owner = new NativeIntegrationOwner(this.domain, { homeId: this.options.homeId, capacity: this.options.capacity });
|
|
32
|
+
this.configFlow = new TasmotaConfigFlow(this.options, this.owner);
|
|
33
|
+
}
|
|
34
|
+
|
|
22
35
|
public async setup(configArg: IHomeAssistantTasmotaConfig, contextArg: IIntegrationSetupContext): Promise<TasmotaRuntime> {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
const config = normalizeTasmotaConfig(configArg);
|
|
37
|
+
return this.owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
|
|
38
|
+
const runtime = new TasmotaRuntime(config, leaseArg, this.options, this.owner);
|
|
39
|
+
leaseArg.own(() => runtime.dispose());
|
|
40
|
+
await runtime.start(leaseArg.signal);
|
|
41
|
+
leaseArg.promoteToRuntime();
|
|
42
|
+
return runtime;
|
|
43
|
+
});
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
public
|
|
46
|
+
public destroy(): Promise<void> { return this.owner.close(); }
|
|
47
|
+
public retryCleanup(): Promise<void> { return this.owner.retryCleanup(); }
|
|
30
48
|
}
|
|
@@ -1,32 +1,43 @@
|
|
|
1
1
|
import * as plugins from '../../plugins.js';
|
|
2
2
|
import { DeviceCommunicationError } from '../../core/errors.js';
|
|
3
|
+
import type { INativeSetupLease, NativeIntegrationOwner } from '../../core/classes.nativeintegrationowner.js';
|
|
3
4
|
import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IServiceCallResult } from '../../core/types.js';
|
|
4
|
-
import { MqttClient } from '../mqtt/mqtt.classes.client.js';
|
|
5
|
-
import
|
|
5
|
+
import type { MqttClient } from '../mqtt/mqtt.classes.client.js';
|
|
6
|
+
import { MqttSession } from '../mqtt/mqtt.classes.session.js';
|
|
7
|
+
import type { IMqttMessage, TMqttCredentialResolver } from '../mqtt/mqtt.types.js';
|
|
6
8
|
import { parseTasmotaRecord, tasmotaRecord, TasmotaMapper } from './tasmota.mapper.js';
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
9
|
+
import { tasmotaBrokerIdentity, tasmotaCredential, tasmotaCredentialKey } from './tasmota.credentials.js';
|
|
10
|
+
import type { ITasmotaConfig, ITasmotaDeviceState, ITasmotaIntegrationOptions, ITasmotaSelectedDevice } from './tasmota.types.js';
|
|
9
11
|
|
|
10
12
|
export class TasmotaRuntime implements IIntegrationRuntime {
|
|
11
13
|
public readonly domain = 'tasmota';
|
|
12
|
-
|
|
13
|
-
private
|
|
14
|
+
public readonly homeId: string;
|
|
15
|
+
private session?: MqttSession;
|
|
16
|
+
private client?: MqttClient;
|
|
14
17
|
private readonly lifetime = new AbortController();
|
|
15
18
|
private readonly states = new Map<string, ITasmotaDeviceState>();
|
|
16
19
|
private readonly listeners = new Set<(eventArg: IIntegrationEvent) => void>();
|
|
17
20
|
private readonly changed = new Set<() => void>();
|
|
18
21
|
private readonly subscriptionCleanups = new Set<() => void>();
|
|
19
|
-
private
|
|
22
|
+
private stopConnectionListener?: () => void;
|
|
23
|
+
private readonly operations = new Set<Promise<unknown>>();
|
|
20
24
|
private initializing = true;
|
|
21
25
|
private started = false;
|
|
22
26
|
private destroyed = false;
|
|
23
27
|
private destroyPromise?: Promise<void>;
|
|
28
|
+
private disposePromise?: Promise<void>;
|
|
29
|
+
private cleanupFailed = false;
|
|
24
30
|
|
|
25
|
-
constructor(
|
|
26
|
-
|
|
27
|
-
this.
|
|
31
|
+
constructor(private readonly config: ITasmotaConfig, private readonly lease: INativeSetupLease,
|
|
32
|
+
private readonly options: ITasmotaIntegrationOptions, private readonly owner: NativeIntegrationOwner) {
|
|
33
|
+
this.homeId = lease.context.homeId;
|
|
34
|
+
lease.assertOpen();
|
|
35
|
+
if (config.homeId !== this.homeId) throw new TypeError('Tasmota runtime must match its admitted host home.');
|
|
28
36
|
for (const device of this.config.devices) this.states.set(device.id, { generation: 0, ready: false, online: false, queryPending: false, updatedAt: new Date(0).toISOString(), controls: {}, sensors: {} });
|
|
29
|
-
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private observeConnection(clientArg: MqttClient): void {
|
|
40
|
+
this.stopConnectionListener = clientArg.onConnectionState((stateArg) => {
|
|
30
41
|
if (this.destroyed) return;
|
|
31
42
|
if (!stateArg.connected) {
|
|
32
43
|
for (const device of this.config.devices) this.markOffline(device);
|
|
@@ -41,33 +52,50 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
41
52
|
this.checkActive(abortSignalArg);
|
|
42
53
|
if (this.started) throw new DeviceCommunicationError('Tasmota runtime has already started.');
|
|
43
54
|
this.started = true;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
const deadlineMonotonicMs = performance.now() + this.config.initialSnapshotTimeoutMs;
|
|
56
|
+
await this.withDeadline(async (signalArg) => {
|
|
57
|
+
const broker = tasmotaBrokerIdentity(this.config);
|
|
58
|
+
const reference = this.config.authentication.mode === 'credential-reference' ? this.config.authentication.credentialRef : undefined;
|
|
59
|
+
const resolver = this.options.credentialResolver;
|
|
60
|
+
const store = this.lease.context.configStore;
|
|
61
|
+
if (reference && !resolver && !store) throw new DeviceCommunicationError('Tasmota credential reference requires an explicit credential source.');
|
|
62
|
+
const resolveCredentials: TMqttCredentialResolver | undefined = reference ? async (requestArg) => {
|
|
63
|
+
const request = Object.freeze({ homeId: requestArg.homeId, credentialRef: requestArg.credentialKey, broker,
|
|
64
|
+
signal: AbortSignal.any([requestArg.signal, signalArg]), deadlineMonotonicMs: Math.min(deadlineMonotonicMs, requestArg.deadline) });
|
|
65
|
+
const credential = resolver ? await resolver(request) : await store!.get<unknown>(tasmotaCredentialKey(requestArg.credentialKey));
|
|
66
|
+
return tasmotaCredential(credential, broker);
|
|
67
|
+
} : undefined;
|
|
68
|
+
this.checkActive(signalArg);
|
|
69
|
+
const remaining = Math.floor(deadlineMonotonicMs - performance.now());
|
|
70
|
+
if (remaining < 1) throw new DeviceCommunicationError('Tasmota fresh state deadline exceeded.');
|
|
71
|
+
const session = new MqttSession({ ...this.config.broker, homeId: this.homeId, credentialKey: reference,
|
|
72
|
+
connectTimeoutMs: Math.min(this.config.broker.connectTimeoutMs ?? remaining, remaining) }, this.lease, resolveCredentials);
|
|
73
|
+
// The runtime's lease owns dispose() before session startup enters the provider or allocates transport.
|
|
74
|
+
this.session = session;
|
|
75
|
+
const client = await session.start();
|
|
76
|
+
this.checkActive(signalArg);
|
|
77
|
+
this.client = client;
|
|
78
|
+
this.observeConnection(client);
|
|
79
|
+
for (const device of this.config.devices) {
|
|
80
|
+
await client.subscribe(`${device.statusBase}#`, (messageArg) => this.receive(device, messageArg), signalArg);
|
|
81
|
+
await client.subscribe(`${device.telemetryBase}#`, (messageArg) => this.receive(device, messageArg), signalArg);
|
|
82
|
+
}
|
|
83
|
+
await this.refresh(this.config.devices, signalArg);
|
|
84
|
+
}, abortSignalArg);
|
|
85
|
+
this.initializing = false;
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
public isHealthy(): boolean {
|
|
61
|
-
return !this.destroyed && this.client
|
|
89
|
+
return !this.destroyed && this.client?.isHealthy() === true && this.config.devices.every((deviceArg) => this.ready(deviceArg));
|
|
62
90
|
}
|
|
63
91
|
|
|
64
92
|
public async devices(abortSignalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
|
|
65
93
|
this.checkActive(abortSignalArg);
|
|
66
|
-
return this.config.devices.map((deviceArg) => TasmotaMapper.device(deviceArg, this.state(deviceArg), this.client.
|
|
94
|
+
return this.config.devices.map((deviceArg) => TasmotaMapper.device(deviceArg, this.state(deviceArg), this.client?.connected === true, this.options.enableControls === true));
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
public async entities(): Promise<IIntegrationEntity[]> {
|
|
70
|
-
return (await this.devices()).flatMap((deviceArg) => TasmotaMapper.entities(deviceArg));
|
|
98
|
+
return (await this.devices()).flatMap((deviceArg) => TasmotaMapper.entities(deviceArg, this.homeId));
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
|
|
@@ -86,10 +114,12 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
86
114
|
|
|
87
115
|
public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
|
|
88
116
|
this.checkActive(abortSignalArg);
|
|
117
|
+
if (this.options.enableControls !== true) return { success: false, error: 'Tasmota controls require explicit embedding-owner enablement.' };
|
|
89
118
|
if (requestArg.device.integrationDomain !== this.domain) return { success: false, error: 'Tasmota device is not selected.' };
|
|
90
119
|
const device = this.config.devices.find((deviceArg) => deviceArg.id === requestArg.device.id);
|
|
91
120
|
if (!device) return { success: false, error: 'Tasmota device is not selected.' };
|
|
92
|
-
|
|
121
|
+
const client = this.client;
|
|
122
|
+
if (!client?.isHealthy() || !this.ready(device)) return { success: false, error: 'Tasmota device has no fresh online state.' };
|
|
93
123
|
const featureId = requestArg.feature.id;
|
|
94
124
|
let command: string;
|
|
95
125
|
let payload: string;
|
|
@@ -103,22 +133,60 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
103
133
|
} else {
|
|
104
134
|
return { success: false, error: 'Tasmota write requires a selected relay boolean or brightness integer from 0 through 100.' };
|
|
105
135
|
}
|
|
106
|
-
|
|
136
|
+
const operation = this.owner.runOperation(this.lease.context, this.homeId, async (operationArg) => {
|
|
137
|
+
const signal = AbortSignal.any([operationArg.signal, this.lease.signal, this.lifetime.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
138
|
+
this.checkActive(signal);
|
|
139
|
+
await client.publish({ topic: `${device.commandBase}${command}`, payload, qos: 0, retain: false }, signal);
|
|
140
|
+
});
|
|
141
|
+
await this.retain(operation);
|
|
107
142
|
return { success: true, data: { accepted: true } };
|
|
108
143
|
}
|
|
109
144
|
|
|
110
145
|
public destroy(): Promise<void> {
|
|
111
|
-
if (this.destroyPromise)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
this.listeners.clear();
|
|
117
|
-
this.changed.clear();
|
|
118
|
-
this.destroyPromise = this.client.destroy();
|
|
146
|
+
if (!this.destroyPromise) {
|
|
147
|
+
this.destroyed = true;
|
|
148
|
+
this.destroyPromise = Promise.resolve().then(() => this.lease.close());
|
|
149
|
+
this.lifetime.abort(new DeviceCommunicationError('Tasmota runtime destroyed.'));
|
|
150
|
+
}
|
|
119
151
|
return this.destroyPromise;
|
|
120
152
|
}
|
|
121
153
|
|
|
154
|
+
/** Resource drain registered on the native lease, separate from its public closure. */
|
|
155
|
+
public dispose(): Promise<void> {
|
|
156
|
+
if (!this.disposePromise || this.cleanupFailed) {
|
|
157
|
+
const retry = this.cleanupFailed;
|
|
158
|
+
this.cleanupFailed = false;
|
|
159
|
+
this.destroyed = true;
|
|
160
|
+
this.disposePromise = Promise.resolve().then(async () => {
|
|
161
|
+
const cleanup = Promise.allSettled([
|
|
162
|
+
Promise.resolve().then(() => this.stopConnectionListener?.()),
|
|
163
|
+
...[...this.subscriptionCleanups].map((cleanupArg) => Promise.resolve().then(cleanupArg)),
|
|
164
|
+
...(this.session ? [Promise.resolve().then(() => retry ? this.session!.retryCleanup() : this.session!.close())] : []),
|
|
165
|
+
]);
|
|
166
|
+
const [results] = await Promise.all([cleanup, Promise.allSettled([...this.operations])]);
|
|
167
|
+
this.listeners.clear();
|
|
168
|
+
this.changed.clear();
|
|
169
|
+
const failures = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected')
|
|
170
|
+
.map((resultArg) => resultArg.reason as unknown);
|
|
171
|
+
if (failures.length) { this.cleanupFailed = true; throw new AggregateError(failures, 'Tasmota runtime cleanup failed.'); }
|
|
172
|
+
});
|
|
173
|
+
this.lifetime.abort(new DeviceCommunicationError('Tasmota runtime destroyed.'));
|
|
174
|
+
}
|
|
175
|
+
return this.disposePromise;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private track<TValue>(workArg: () => Promise<TValue>): Promise<TValue> {
|
|
179
|
+
this.checkActive();
|
|
180
|
+
const operation = Promise.resolve().then(() => { this.checkActive(); return workArg(); });
|
|
181
|
+
return this.retain(operation);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private retain<TValue>(operationArg: Promise<TValue>): Promise<TValue> {
|
|
185
|
+
this.operations.add(operationArg);
|
|
186
|
+
void operationArg.then(() => this.operations.delete(operationArg), () => this.operations.delete(operationArg));
|
|
187
|
+
return operationArg;
|
|
188
|
+
}
|
|
189
|
+
|
|
122
190
|
private state(deviceArg: ITasmotaSelectedDevice): ITasmotaDeviceState {
|
|
123
191
|
const state = this.states.get(deviceArg.id);
|
|
124
192
|
if (!state) throw new DeviceCommunicationError('Selected Tasmota device state is missing.');
|
|
@@ -133,12 +201,13 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
133
201
|
private checkActive(abortSignalArg?: AbortSignal): void {
|
|
134
202
|
abortSignalArg?.throwIfAborted();
|
|
135
203
|
this.lifetime.signal.throwIfAborted();
|
|
204
|
+
this.lease.assertOpen();
|
|
136
205
|
}
|
|
137
206
|
|
|
138
207
|
private async withDeadline<TValue>(operationArg: (signalArg: AbortSignal) => Promise<TValue>, abortSignalArg?: AbortSignal): Promise<TValue> {
|
|
139
208
|
const deadline = new AbortController();
|
|
140
209
|
const timer = setTimeout(() => deadline.abort(new DeviceCommunicationError('Tasmota fresh state deadline exceeded.')), this.config.initialSnapshotTimeoutMs);
|
|
141
|
-
const signal = AbortSignal.any([this.lifetime.signal, deadline.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
210
|
+
const signal = AbortSignal.any([this.lifetime.signal, this.lease.signal, deadline.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
|
|
142
211
|
try {
|
|
143
212
|
signal.throwIfAborted();
|
|
144
213
|
return await operationArg(signal);
|
|
@@ -160,8 +229,8 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
160
229
|
const fresh = this.waitForFresh(devicesArg, generationsArg, signalArg);
|
|
161
230
|
const queries = async (): Promise<void> => {
|
|
162
231
|
for (const device of devicesArg) {
|
|
163
|
-
await this.client
|
|
164
|
-
await this.client
|
|
232
|
+
await this.client!.publish({ topic: `${device.commandBase}State`, payload: '', qos: 0, retain: false }, signalArg);
|
|
233
|
+
await this.client!.publish({ topic: `${device.commandBase}Status`, payload: '10', qos: 0, retain: false }, signalArg);
|
|
165
234
|
}
|
|
166
235
|
};
|
|
167
236
|
await Promise.all([fresh, queries()]);
|
|
@@ -169,7 +238,7 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
169
238
|
|
|
170
239
|
private refreshInBackground(devicesArg: ITasmotaSelectedDevice[]): void {
|
|
171
240
|
const generations = new Map<string, number>();
|
|
172
|
-
void this.withDeadline((signalArg) => this.refresh(devicesArg, signalArg, generations)).catch((errorArg: unknown) => {
|
|
241
|
+
void this.track(() => this.withDeadline((signalArg) => this.refresh(devicesArg, signalArg, generations))).catch((errorArg: unknown) => {
|
|
173
242
|
if (this.destroyed) return;
|
|
174
243
|
const currentDevices = devicesArg.filter((deviceArg) => this.state(deviceArg).generation === generations.get(deviceArg.id));
|
|
175
244
|
if (!currentDevices.length) return;
|
|
@@ -190,7 +259,7 @@ export class TasmotaRuntime implements IIntegrationRuntime {
|
|
|
190
259
|
reject(new DeviceCommunicationError('Tasmota state refresh superseded by a connection or availability change.'));
|
|
191
260
|
return;
|
|
192
261
|
}
|
|
193
|
-
if (this.client
|
|
262
|
+
if (this.client?.connected && devicesArg.every((deviceArg) => this.ready(deviceArg))) {
|
|
194
263
|
cleanup();
|
|
195
264
|
resolve();
|
|
196
265
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { IntegrationError } from '../../core/errors.js';
|
|
2
2
|
import type { IHomeAssistantTasmotaConfig, ITasmotaConfig, ITasmotaDeviceConfig, ITasmotaSelectedDevice } from './tasmota.types.js';
|
|
3
|
+
import { tasmotaCopy, tasmotaData, tasmotaText } from './tasmota.credentials.js';
|
|
3
4
|
|
|
4
5
|
const fail = (messageArg: string): never => {
|
|
5
6
|
throw new IntegrationError(messageArg, 'INVALID_TASMOTA_CONFIG');
|
|
@@ -51,6 +52,20 @@ const selectedDevice = (deviceArg: ITasmotaDeviceConfig): ITasmotaSelectedDevice
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
export const normalizeTasmotaConfig = (configArg: IHomeAssistantTasmotaConfig): ITasmotaConfig => {
|
|
55
|
+
const input = tasmotaData(tasmotaCopy(configArg), 'Tasmota configuration');
|
|
56
|
+
const broker = tasmotaData(input.broker, 'Tasmota broker');
|
|
57
|
+
if (['username', 'password', 'token', 'ca', 'cert', 'key'].some((fieldArg) => broker[fieldArg] !== undefined)) {
|
|
58
|
+
return fail('Legacy plaintext Tasmota broker credentials require explicit reconfiguration with an encrypted credential reference.');
|
|
59
|
+
}
|
|
60
|
+
const homeId = tasmotaText(input.homeId, 'Tasmota configuration home');
|
|
61
|
+
const authentication = tasmotaData(input.authentication, 'Tasmota authentication');
|
|
62
|
+
if (authentication.mode === 'anonymous') {
|
|
63
|
+
if (Object.keys(authentication).some((keyArg) => keyArg !== 'mode')) return fail('Anonymous Tasmota authentication must be selected explicitly without credentials.');
|
|
64
|
+
} else if (authentication.mode === 'credential-reference') {
|
|
65
|
+
if (Object.keys(authentication).some((keyArg) => !['mode', 'credentialRef'].includes(keyArg))) return fail('Tasmota authentication accepts only a credential reference.');
|
|
66
|
+
tasmotaText(authentication.credentialRef, 'Tasmota credential reference', 512);
|
|
67
|
+
} else return fail('Select explicit anonymous authentication or a broker credential reference.');
|
|
68
|
+
configArg = structuredClone(input) as unknown as IHomeAssistantTasmotaConfig;
|
|
54
69
|
if (!configArg || typeof configArg !== 'object' || !configArg.broker || typeof configArg.broker.host !== 'string' || !configArg.broker.host.trim() || configArg.broker.host !== configArg.broker.host.trim()) return fail('Tasmota requires a real MQTT broker host without surrounding whitespace.');
|
|
55
70
|
if (configArg.broker.port !== undefined && (!Number.isInteger(configArg.broker.port) || configArg.broker.port < 1 || configArg.broker.port > 65_535)) return fail('Tasmota broker port must be an integer from 1 through 65535.');
|
|
56
71
|
if (configArg.broker.protocol !== undefined && configArg.broker.protocol !== 'mqtt' && configArg.broker.protocol !== 'mqtts') return fail('Tasmota broker protocol must be mqtt or mqtts.');
|
|
@@ -62,6 +77,8 @@ export const normalizeTasmotaConfig = (configArg: IHomeAssistantTasmotaConfig):
|
|
|
62
77
|
const initialSnapshotTimeoutMs = configArg.initialSnapshotTimeoutMs ?? 10_000;
|
|
63
78
|
if (!Number.isInteger(initialSnapshotTimeoutMs) || initialSnapshotTimeoutMs < 1 || initialSnapshotTimeoutMs > 2_147_483_647) return fail('initialSnapshotTimeoutMs must be a timer-safe positive integer.');
|
|
64
79
|
return {
|
|
80
|
+
homeId,
|
|
81
|
+
authentication: Object.freeze({ ...configArg.authentication }),
|
|
65
82
|
devices,
|
|
66
83
|
initialSnapshotTimeoutMs,
|
|
67
84
|
broker: { ...configArg.broker, discovery: false, birthMessage: false, willMessage: false, subscriptions: [], entities: [], discoveryMessages: [], retainedMessages: [] },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { IMqttTransportCredentials } from '../mqtt/mqtt.types.js';
|
|
2
|
+
import type { ITasmotaBrokerIdentity, ITasmotaConfig, ITasmotaCredential, ITasmotaIntegrationOptions } from './tasmota.types.js';
|
|
3
|
+
|
|
4
|
+
export const tasmotaData = (valueArg: unknown, nameArg: string): Record<string, unknown> => {
|
|
5
|
+
if (!valueArg || typeof valueArg !== 'object'
|
|
6
|
+
|| (Object.getPrototypeOf(valueArg) !== Object.prototype && Object.getPrototypeOf(valueArg) !== null)) {
|
|
7
|
+
throw new TypeError(`${nameArg} must be a plain data object.`);
|
|
8
|
+
}
|
|
9
|
+
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
10
|
+
for (const key of Reflect.ownKeys(valueArg)) {
|
|
11
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
|
|
12
|
+
if (typeof key !== 'string' || !descriptor?.enumerable || !('value' in descriptor)) {
|
|
13
|
+
throw new TypeError(`${nameArg} must not contain accessors or hidden fields.`);
|
|
14
|
+
}
|
|
15
|
+
result[key] = descriptor.value as unknown;
|
|
16
|
+
}
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Capture configuration data without invoking nested user accessors. */
|
|
21
|
+
export const tasmotaCopy = (valueArg: unknown, ancestorsArg = new Set<object>(), depthArg = 0): unknown => {
|
|
22
|
+
if (valueArg === null || valueArg === undefined || typeof valueArg === 'string' || typeof valueArg === 'boolean'
|
|
23
|
+
|| (typeof valueArg === 'number' && Number.isFinite(valueArg))) return valueArg;
|
|
24
|
+
if (!valueArg || typeof valueArg !== 'object' || depthArg > 32 || ancestorsArg.has(valueArg)) throw new TypeError('Tasmota configuration must contain bounded plain data.');
|
|
25
|
+
ancestorsArg.add(valueArg);
|
|
26
|
+
try {
|
|
27
|
+
if (Array.isArray(valueArg)) {
|
|
28
|
+
const items: unknown[] = [];
|
|
29
|
+
for (let index = 0; index < valueArg.length; index += 1) {
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, String(index));
|
|
31
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) throw new TypeError('Tasmota configuration arrays must contain data values.');
|
|
32
|
+
items.push(tasmotaCopy(descriptor.value as unknown, ancestorsArg, depthArg + 1));
|
|
33
|
+
}
|
|
34
|
+
if (Reflect.ownKeys(valueArg).length !== items.length + 1) throw new TypeError('Tasmota configuration arrays contain unsupported fields.');
|
|
35
|
+
return items;
|
|
36
|
+
}
|
|
37
|
+
const data = tasmotaData(valueArg, 'Tasmota configuration');
|
|
38
|
+
return Object.fromEntries(Object.entries(data).map(([keyArg, itemArg]) => [keyArg, tasmotaCopy(itemArg, ancestorsArg, depthArg + 1)]));
|
|
39
|
+
} finally { ancestorsArg.delete(valueArg); }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const tasmotaText = (valueArg: unknown, nameArg: string, maximumArg = 256): string => {
|
|
43
|
+
if (typeof valueArg !== 'string' || !valueArg || valueArg.length > maximumArg
|
|
44
|
+
|| valueArg.trim() !== valueArg || /[\p{Cc}\p{Cs}]/u.test(valueArg)) {
|
|
45
|
+
throw new TypeError(`${nameArg} must be a bounded nonempty identifier.`);
|
|
46
|
+
}
|
|
47
|
+
return valueArg;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const tasmotaOptions = (valueArg: ITasmotaIntegrationOptions): ITasmotaIntegrationOptions => {
|
|
51
|
+
const options = tasmotaData(valueArg, 'Tasmota owner options');
|
|
52
|
+
if (Object.keys(options).some((keyArg) => !['homeId', 'capacity', 'enableControls', 'credentialResolver'].includes(keyArg))) {
|
|
53
|
+
throw new TypeError('Tasmota owner options contain unsupported fields.');
|
|
54
|
+
}
|
|
55
|
+
if (options.enableControls !== undefined && typeof options.enableControls !== 'boolean') throw new TypeError('Tasmota enableControls must be boolean.');
|
|
56
|
+
if (options.credentialResolver !== undefined && typeof options.credentialResolver !== 'function') throw new TypeError('Tasmota credential resolver must be a function.');
|
|
57
|
+
return Object.freeze({ ...options }) as ITasmotaIntegrationOptions;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const tasmotaBrokerIdentity = (configArg: ITasmotaConfig): ITasmotaBrokerIdentity => Object.freeze({
|
|
61
|
+
host: configArg.broker.host, port: configArg.broker.port ?? (configArg.broker.protocol === 'mqtts' ? 8883 : 1883),
|
|
62
|
+
protocol: configArg.broker.protocol ?? 'mqtt',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const tasmotaCredentialKey = (referenceArg: string): string => `tasmota.credential.${encodeURIComponent(tasmotaText(referenceArg, 'Credential reference', 512))}`;
|
|
66
|
+
|
|
67
|
+
export const tasmotaCredential = (valueArg: unknown, brokerArg: ITasmotaBrokerIdentity): IMqttTransportCredentials => {
|
|
68
|
+
const value = tasmotaData(valueArg, 'Tasmota credential');
|
|
69
|
+
if (Object.keys(value).some((keyArg) => !['version', 'host', 'port', 'protocol', 'username', 'password', 'ca', 'cert', 'key', 'servername'].includes(keyArg))
|
|
70
|
+
|| value.version !== 1 || value.host !== brokerArg.host || value.port !== brokerArg.port || value.protocol !== brokerArg.protocol) {
|
|
71
|
+
throw new TypeError('Tasmota credential does not match the selected broker.');
|
|
72
|
+
}
|
|
73
|
+
const credentials: IMqttTransportCredentials = {};
|
|
74
|
+
for (const field of ['username', 'password', 'ca', 'cert', 'key', 'servername'] as const) {
|
|
75
|
+
const item = value[field];
|
|
76
|
+
if (item !== undefined) {
|
|
77
|
+
if (typeof item !== 'string' || item.length > 1_048_576 || item.includes('\u0000')) throw new TypeError('Tasmota credential fields are invalid.');
|
|
78
|
+
credentials[field] = item;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (!credentials.username && !credentials.password && !credentials.cert) throw new TypeError('Tasmota credential reference must resolve authenticated broker credentials.');
|
|
82
|
+
if ((credentials.cert === undefined) !== (credentials.key === undefined)) throw new TypeError('Tasmota client certificate and private key must be supplied together.');
|
|
83
|
+
if ((credentials.ca || credentials.cert || credentials.key || credentials.servername) && brokerArg.protocol !== 'mqtts') throw new TypeError('Tasmota TLS credentials require mqtts.');
|
|
84
|
+
if (credentials.servername !== undefined) tasmotaText(credentials.servername, 'TLS server name', 253);
|
|
85
|
+
return Object.freeze(credentials);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const createTasmotaCredential = (brokerArg: ITasmotaBrokerIdentity, usernameArg?: string, passwordArg?: string): ITasmotaCredential => {
|
|
89
|
+
const credential: ITasmotaCredential = { version: 1, ...brokerArg, username: usernameArg, password: passwordArg };
|
|
90
|
+
tasmotaCredential(credential, brokerArg);
|
|
91
|
+
return credential;
|
|
92
|
+
};
|