@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.
Files changed (251) hide show
  1. package/changelog.md +46 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
  4. package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
  5. package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
  6. package/dist_ts/core/classes.runtimemanager.js +11 -3
  7. package/dist_ts/core/types.d.ts +2 -0
  8. package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
  9. package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
  10. package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
  11. package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
  12. package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
  13. package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
  14. package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
  15. package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
  16. package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
  17. package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
  18. package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
  19. package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
  20. package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
  21. package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
  22. package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
  23. package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
  24. package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
  25. package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
  26. package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
  27. package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
  28. package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
  29. package/dist_ts/integrations/esphome/esphome.types.js +1 -1
  30. package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
  31. package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
  32. package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
  33. package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
  34. package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
  35. package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
  36. package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
  37. package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
  38. package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
  39. package/dist_ts/integrations/hue/hue.classes.client.d.ts +32 -4
  40. package/dist_ts/integrations/hue/hue.classes.client.js +331 -57
  41. package/dist_ts/integrations/hue/hue.classes.configflow.d.ts +6 -1
  42. package/dist_ts/integrations/hue/hue.classes.configflow.js +79 -27
  43. package/dist_ts/integrations/hue/hue.classes.integration.d.ts +3 -1
  44. package/dist_ts/integrations/hue/hue.classes.integration.js +83 -62
  45. package/dist_ts/integrations/hue/hue.mapper.d.ts +2 -0
  46. package/dist_ts/integrations/hue/hue.mapper.js +8 -6
  47. package/dist_ts/integrations/hue/hue.types.d.ts +32 -1
  48. package/dist_ts/integrations/hue/hue.validation.d.ts +9 -3
  49. package/dist_ts/integrations/hue/hue.validation.js +112 -11
  50. package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
  51. package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
  52. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
  53. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
  54. package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
  55. package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
  56. package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
  57. package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
  58. package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
  59. package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
  60. package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
  61. package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
  62. package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
  63. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
  64. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
  65. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
  66. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
  67. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
  68. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
  69. package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
  70. package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
  71. package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
  72. package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
  73. package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
  74. package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
  75. package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
  76. package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
  77. package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
  78. package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
  79. package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
  80. package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
  81. package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
  82. package/dist_ts/integrations/shelly/shelly.types.js +11 -2
  83. package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
  84. package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
  85. package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
  86. package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
  87. package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
  88. package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
  89. package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
  90. package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
  91. package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
  92. package/dist_ts/integrations/sonos/sonos.config.js +23 -2
  93. package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
  94. package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
  95. package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
  96. package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
  97. package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
  98. package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
  99. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
  100. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
  101. package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
  102. package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
  103. package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
  104. package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
  105. package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
  106. package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
  107. package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
  108. package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
  109. package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
  110. package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
  111. package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
  112. package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
  113. package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
  114. package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
  115. package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
  116. package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
  117. package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
  118. package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
  119. package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
  120. package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
  121. package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
  122. package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
  123. package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
  124. package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
  125. package/dist_ts/integrations/webostv/webostv.types.js +1 -1
  126. package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
  127. package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
  128. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
  129. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
  130. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
  131. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
  132. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
  133. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
  134. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
  135. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
  136. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
  137. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
  138. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
  139. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
  140. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
  141. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
  142. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
  143. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
  144. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
  145. package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
  146. package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
  147. package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
  148. package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
  149. package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
  150. package/dist_ts/plugins.d.ts +2 -1
  151. package/dist_ts/plugins.js +3 -2
  152. package/package.json +10 -10
  153. package/readme.md +25 -6
  154. package/ts/00_commitinfo_data.ts +1 -1
  155. package/ts/core/classes.nativeintegrationowner.ts +5 -0
  156. package/ts/core/classes.runtimemanager.ts +11 -2
  157. package/ts/core/types.ts +2 -0
  158. package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
  159. package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
  160. package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
  161. package/ts/integrations/deconz/deconz.mapper.ts +22 -5
  162. package/ts/integrations/deconz/deconz.types.ts +49 -6
  163. package/ts/integrations/deconz/deconz.validation.ts +177 -4
  164. package/ts/integrations/deconz/readme.md +15 -1
  165. package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
  166. package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
  167. package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
  168. package/ts/integrations/esphome/esphome.discovery.ts +3 -12
  169. package/ts/integrations/esphome/esphome.mapper.ts +9 -7
  170. package/ts/integrations/esphome/esphome.types.ts +35 -6
  171. package/ts/integrations/esphome/esphome.validation.ts +66 -0
  172. package/ts/integrations/esphome/readme.md +20 -6
  173. package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
  174. package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
  175. package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
  176. package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
  177. package/ts/integrations/homeassistant/readme.md +22 -6
  178. package/ts/integrations/hue/hue.classes.client.ts +288 -55
  179. package/ts/integrations/hue/hue.classes.configflow.ts +68 -25
  180. package/ts/integrations/hue/hue.classes.integration.ts +75 -54
  181. package/ts/integrations/hue/hue.mapper.ts +8 -5
  182. package/ts/integrations/hue/hue.types.ts +37 -1
  183. package/ts/integrations/hue/hue.validation.ts +102 -13
  184. package/ts/integrations/hue/readme.md +49 -12
  185. package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
  186. package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
  187. package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
  188. package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
  189. package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
  190. package/ts/integrations/mqtt/mqtt.types.ts +52 -0
  191. package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
  192. package/ts/integrations/mqtt/readme.md +12 -3
  193. package/ts/integrations/samsungtv/readme.md +11 -5
  194. package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
  195. package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
  196. package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
  197. package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
  198. package/ts/integrations/shelly/readme.md +22 -7
  199. package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
  200. package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
  201. package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
  202. package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
  203. package/ts/integrations/shelly/shelly.mapper.ts +19 -20
  204. package/ts/integrations/shelly/shelly.rpc.ts +2 -0
  205. package/ts/integrations/shelly/shelly.types.ts +50 -2
  206. package/ts/integrations/shelly/shelly.validation.ts +76 -0
  207. package/ts/integrations/sonos/readme.md +117 -38
  208. package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
  209. package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
  210. package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
  211. package/ts/integrations/sonos/sonos.config.ts +24 -2
  212. package/ts/integrations/sonos/sonos.mapper.ts +13 -8
  213. package/ts/integrations/sonos/sonos.matchers.ts +2 -2
  214. package/ts/integrations/sonos/sonos.types.ts +30 -1
  215. package/ts/integrations/tasmota/readme.md +16 -5
  216. package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
  217. package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
  218. package/ts/integrations/tasmota/tasmota.config.ts +17 -0
  219. package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
  220. package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
  221. package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
  222. package/ts/integrations/tasmota/tasmota.types.ts +43 -2
  223. package/ts/integrations/tuya/readme.md +52 -10
  224. package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
  225. package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
  226. package/ts/integrations/tuya/tuya.mapper.ts +1 -1
  227. package/ts/integrations/tuya/tuya.types.ts +4 -0
  228. package/ts/integrations/tuya/tuya.validation.ts +5 -8
  229. package/ts/integrations/webostv/readme.md +115 -22
  230. package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
  231. package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
  232. package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
  233. package/ts/integrations/webostv/webostv.mapper.ts +4 -4
  234. package/ts/integrations/webostv/webostv.types.ts +18 -3
  235. package/ts/integrations/webostv/webostv.validation.ts +22 -1
  236. package/ts/integrations/zigbee2mqtt/readme.md +22 -27
  237. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
  238. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
  239. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
  240. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
  241. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
  242. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
  243. package/ts/integrations/zwave_js/readme.md +42 -9
  244. package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
  245. package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
  246. package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
  247. package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
  248. package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
  249. package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
  250. package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
  251. 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
- { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
13
- { id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
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: `sonos_${this.slug(snapshotArg.speakerInfo.uid)}`,
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.sonos_${this.slug(uid)}`;
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, normalizeSonosConfig, normalizeSonosUid } from './sonos.config.js';
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 = normalizeSonosConfig({ host, port, ...(uid ? { expectedUid: uid } : {}) });
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 ISonosConfig {
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`. Configure each Tasmota device's broker and unique `Topic` before setup. No Home Assistant connection is required.
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 runtime = await new HomeAssistantTasmotaIntegration().setup({
9
- broker: { host: 'mqtt.home', protocol: 'mqtts', username, password },
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 runtime.destroy();
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 = new TasmotaConfigFlow();
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
- contextArg.abortSignal?.throwIfAborted();
24
- const runtime = new TasmotaRuntime(configArg);
25
- await runtime.start(contextArg.abortSignal);
26
- return runtime;
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 async destroy(): Promise<void> {}
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 type { IMqttMessage } from '../mqtt/mqtt.types.js';
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 { normalizeTasmotaConfig } from './tasmota.config.js';
8
- import type { IHomeAssistantTasmotaConfig, ITasmotaConfig, ITasmotaDeviceState, ITasmotaSelectedDevice } from './tasmota.types.js';
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
- private readonly config: ITasmotaConfig;
13
- private readonly client: MqttClient;
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 readonly stopConnectionListener: () => void;
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(configArg: IHomeAssistantTasmotaConfig) {
26
- this.config = normalizeTasmotaConfig(configArg);
27
- this.client = new MqttClient(this.config.broker);
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
- this.stopConnectionListener = this.client.onConnectionState((stateArg) => {
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
- try {
45
- await this.withDeadline(async (signalArg) => {
46
- await this.client.start(signalArg);
47
- for (const device of this.config.devices) {
48
- await this.client.subscribe(`${device.statusBase}#`, (messageArg) => this.receive(device, messageArg), signalArg);
49
- await this.client.subscribe(`${device.telemetryBase}#`, (messageArg) => this.receive(device, messageArg), signalArg);
50
- }
51
- await this.refresh(this.config.devices, signalArg);
52
- }, abortSignalArg);
53
- this.initializing = false;
54
- } catch (errorArg) {
55
- await this.destroy();
56
- throw errorArg;
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.isHealthy() && this.config.devices.every((deviceArg) => this.ready(deviceArg));
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.connected));
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
- if (!this.client.isHealthy() || !this.ready(device)) return { success: false, error: 'Tasmota device has no fresh online state.' };
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
- await this.client.publish({ topic: `${device.commandBase}${command}`, payload, qos: 0, retain: false }, abortSignalArg);
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) return this.destroyPromise;
112
- this.destroyed = true;
113
- this.stopConnectionListener();
114
- this.lifetime.abort(new DeviceCommunicationError('Tasmota runtime destroyed.'));
115
- for (const cleanup of [...this.subscriptionCleanups]) cleanup();
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.publish({ topic: `${device.commandBase}State`, payload: '', qos: 0, retain: false }, signalArg);
164
- await this.client.publish({ topic: `${device.commandBase}Status`, payload: '10', qos: 0, retain: false }, signalArg);
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.connected && devicesArg.every((deviceArg) => this.ready(deviceArg))) {
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
+ };