@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
@@ -1,7 +1,10 @@
1
1
  import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
2
+ import * as plugins from '../../plugins.js';
3
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
2
4
  import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
3
5
  import { normalizeTasmotaConfig } from './tasmota.config.js';
4
- import type { IHomeAssistantTasmotaConfig } from './tasmota.types.js';
6
+ import type { IHomeAssistantTasmotaConfig, ITasmotaIntegrationOptions } from './tasmota.types.js';
7
+ import { createTasmotaCredential, tasmotaBrokerIdentity, tasmotaCopy, tasmotaCredentialKey, tasmotaData, tasmotaOptions, tasmotaText } from './tasmota.credentials.js';
5
8
 
6
9
  class TasmotaManualMatcher implements IDiscoveryMatcher<IDiscoveryCandidate> {
7
10
  public readonly id = 'tasmota-selected-device';
@@ -35,8 +38,28 @@ export const createTasmotaDiscoveryDescriptor = (): DiscoveryDescriptor => {
35
38
  };
36
39
 
37
40
  export class TasmotaConfigFlow implements IConfigFlow<IHomeAssistantTasmotaConfig> {
41
+ private readonly scope: NativeIntegrationScope;
42
+ private readonly hasResolver: boolean;
43
+
44
+ constructor(optionsArg: ITasmotaIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
45
+ const options = tasmotaOptions(optionsArg);
46
+ const owner = ownerArg ?? new NativeIntegrationOwner('tasmota', { homeId: options.homeId, capacity: options.capacity });
47
+ this.scope = owner.createFlowScope();
48
+ this.hasResolver = options.credentialResolver !== undefined;
49
+ }
50
+
51
+ public destroy(): Promise<void> { return this.scope.close(); }
52
+ public retryCleanup(): Promise<void> { return this.scope.retryCleanup(); }
53
+
38
54
  public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHomeAssistantTasmotaConfig>> {
39
- void contextArg;
55
+ const candidate = tasmotaData(tasmotaCopy(candidateArg), 'Tasmota candidate');
56
+ const metadata = candidate.metadata === undefined ? undefined : tasmotaData(candidate.metadata, 'Tasmota candidate metadata');
57
+ const context = this.scope.admit(contextArg);
58
+ const candidateHost = candidate.host;
59
+ const candidatePort = candidate.port;
60
+ const candidateTopic = metadata?.topic;
61
+ let pending = false;
62
+ let submitted = false;
40
63
  return {
41
64
  kind: 'form',
42
65
  title: 'Connect a selected Tasmota device',
@@ -45,6 +68,8 @@ export class TasmotaConfigFlow implements IConfigFlow<IHomeAssistantTasmotaConfi
45
68
  { name: 'host', label: 'MQTT broker host', type: 'text', required: true },
46
69
  { name: 'port', label: 'MQTT broker port', type: 'number' },
47
70
  { name: 'useTls', label: 'Use MQTT TLS', type: 'boolean' },
71
+ { name: 'anonymous', label: 'Use an anonymous broker', type: 'boolean' },
72
+ { name: 'credentialRef', label: 'Existing broker credential reference', type: 'text' },
48
73
  { name: 'username', label: 'Broker username', type: 'text' },
49
74
  { name: 'password', label: 'Broker password', type: 'password' },
50
75
  { name: 'topic', label: 'Device Topic', type: 'text', required: true },
@@ -53,31 +78,69 @@ export class TasmotaConfigFlow implements IConfigFlow<IHomeAssistantTasmotaConfi
53
78
  { name: 'brightness', label: 'Single-channel Dimmer', type: 'boolean' },
54
79
  ],
55
80
  submit: async (valuesArg) => {
81
+ if (pending || submitted) return { kind: 'error', error: 'This Tasmota form is already submitting or completed.' };
82
+ pending = true;
56
83
  try {
57
- for (const field of ['username', 'password', 'fullTopic']) if (valuesArg[field] !== undefined && typeof valuesArg[field] !== 'string') throw new Error(`${field} must be a string.`);
58
- for (const field of ['useTls', 'brightness']) if (valuesArg[field] !== undefined && typeof valuesArg[field] !== 'boolean') throw new Error(`${field} must be boolean.`);
59
- const relayCount = valuesArg.relayCount ?? 1;
60
- if (typeof relayCount !== 'number' || !Number.isInteger(relayCount) || relayCount < 0 || relayCount > 32) throw new Error('relayCount must be an integer from 0 through 32.');
61
- const host = valuesArg.host ?? candidateArg.host;
62
- const topic = valuesArg.topic ?? candidateArg.metadata?.topic;
63
- if (typeof host !== 'string' || typeof topic !== 'string') throw new Error('Broker host and device topic are required.');
64
- const port = valuesArg.port ?? candidateArg.port;
65
- if (port !== undefined && (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535)) throw new Error('Broker port must be an integer from 1 through 65535.');
66
- const config: IHomeAssistantTasmotaConfig = {
67
- broker: {
68
- host,
69
- port,
70
- protocol: valuesArg.useTls === true ? 'mqtts' : 'mqtt',
71
- username: typeof valuesArg.username === 'string' ? valuesArg.username : undefined,
72
- password: typeof valuesArg.password === 'string' ? valuesArg.password : undefined,
73
- },
74
- devices: [{ topic, fullTopic: typeof valuesArg.fullTopic === 'string' && valuesArg.fullTopic ? valuesArg.fullTopic : undefined, relays: Array.from({ length: relayCount }, (_, indexArg) => indexArg + 1), brightness: valuesArg.brightness === true }],
75
- };
76
- normalizeTasmotaConfig(config);
77
- return { kind: 'done', title: 'Selected Tasmota device configured', config };
84
+ const values = tasmotaData(tasmotaCopy(valuesArg), 'Tasmota form');
85
+ return await this.scope.run(context, context.homeId, async (leaseArg) => {
86
+ if (Object.keys(values).some((keyArg) => !['homeId', 'host', 'port', 'useTls', 'anonymous', 'credentialRef', 'username', 'password', 'topic', 'fullTopic', 'relayCount', 'brightness'].includes(keyArg))) throw new Error('Tasmota form contains unsupported fields.');
87
+ if (values.homeId !== undefined && values.homeId !== context.homeId) throw new Error('Tasmota form home conflicts with the admitted host home.');
88
+ for (const field of ['username', 'password', 'fullTopic', 'credentialRef']) if (values[field] !== undefined && typeof values[field] !== 'string') throw new Error(`${field} must be a string.`);
89
+ for (const field of ['useTls', 'brightness', 'anonymous']) if (values[field] !== undefined && typeof values[field] !== 'boolean') throw new Error(`${field} must be boolean.`);
90
+ const relayCount = values.relayCount ?? 1;
91
+ if (typeof relayCount !== 'number' || !Number.isInteger(relayCount) || relayCount < 0 || relayCount > 32) throw new Error('relayCount must be an integer from 0 through 32.');
92
+ const host = values.host ?? candidateHost;
93
+ const topic = values.topic ?? candidateTopic;
94
+ if (typeof host !== 'string' || typeof topic !== 'string') throw new Error('Broker host and device topic are required.');
95
+ const port = values.port ?? candidatePort;
96
+ if (port !== undefined && (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65_535)) throw new Error('Broker port must be an integer from 1 through 65535.');
97
+ const secretInput = values.username !== undefined || values.password !== undefined;
98
+ const anonymous = values.anonymous === true;
99
+ if (anonymous && (secretInput || values.credentialRef !== undefined)) throw new Error('Anonymous Tasmota mode cannot contain credentials.');
100
+ if (!anonymous && !secretInput && values.credentialRef === undefined) throw new Error('Select anonymous mode explicitly or provide broker credentials.');
101
+ if (secretInput && values.credentialRef !== undefined) throw new Error('Provide transient credentials or an existing reference, not both.');
102
+ const reference = anonymous ? undefined : secretInput ? plugins.crypto.randomUUID() : tasmotaText(values.credentialRef, 'Credential reference', 512);
103
+ const config: IHomeAssistantTasmotaConfig = {
104
+ homeId: context.homeId,
105
+ authentication: reference === undefined ? { mode: 'anonymous' } : { mode: 'credential-reference', credentialRef: reference },
106
+ broker: {
107
+ host,
108
+ port,
109
+ protocol: values.useTls === true ? 'mqtts' : 'mqtt',
110
+ },
111
+ devices: [{ topic, fullTopic: typeof values.fullTopic === 'string' && values.fullTopic ? values.fullTopic : undefined, relays: Array.from({ length: relayCount }, (_, indexArg) => indexArg + 1), brightness: values.brightness === true }],
112
+ };
113
+ const normalized = normalizeTasmotaConfig(config);
114
+ if (!anonymous) {
115
+ const store = leaseArg.context.configStore;
116
+ if (secretInput) {
117
+ if (!store) throw new Error('Transient Tasmota broker credentials require an admitted encrypted config store.');
118
+ const credential = createTasmotaCredential(tasmotaBrokerIdentity(normalized),
119
+ typeof values.username === 'string' ? values.username : undefined,
120
+ typeof values.password === 'string' ? values.password : undefined);
121
+ const key = tasmotaCredentialKey(reference!);
122
+ let committed = false;
123
+ let write: Promise<void> = Promise.resolve();
124
+ leaseArg.own(async () => {
125
+ const result = await Promise.allSettled([write]);
126
+ if (result[0].status === 'fulfilled' && !committed) {
127
+ try { await store.delete(key); }
128
+ catch { throw new Error('Tasmota cancelled credential cleanup failed.'); }
129
+ }
130
+ });
131
+ write = Promise.resolve().then(() => store.create(key, credential));
132
+ try { await write; }
133
+ catch { throw new Error('Tasmota encrypted credential persistence failed.'); }
134
+ leaseArg.assertOpen();
135
+ committed = true;
136
+ } else if (!store && !this.hasResolver) throw new Error('Tasmota credential reference requires an explicit credential source.');
137
+ }
138
+ submitted = true;
139
+ return { kind: 'done', title: 'Selected Tasmota device configured', config };
140
+ });
78
141
  } catch (errorArg) {
79
142
  return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : 'Invalid Tasmota configuration.' };
80
- }
143
+ } finally { pending = false; }
81
144
  },
82
145
  };
83
146
  }
@@ -50,9 +50,9 @@ export class TasmotaMapper {
50
50
  return sensors;
51
51
  }
52
52
 
53
- public static device(deviceArg: ITasmotaSelectedDevice, stateArg: ITasmotaDeviceState, connectedArg: boolean): plugins.shxInterfaces.data.IDeviceDefinition {
54
- const features: plugins.shxInterfaces.data.IDeviceFeature[] = deviceArg.relays.map((relayArg) => ({ id: `power_${relayArg}`, capability: 'switch', name: `Relay ${relayArg}`, readable: true, writable: true }));
55
- if (deviceArg.brightness) features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: true, unit: '%' });
53
+ public static device(deviceArg: ITasmotaSelectedDevice, stateArg: ITasmotaDeviceState, connectedArg: boolean, enableControlsArg = false): plugins.shxInterfaces.data.IDeviceDefinition {
54
+ const features: plugins.shxInterfaces.data.IDeviceFeature[] = deviceArg.relays.map((relayArg) => ({ id: `power_${relayArg}`, capability: 'switch', name: `Relay ${relayArg}`, readable: true, writable: enableControlsArg }));
55
+ if (deviceArg.brightness) features.push({ id: 'brightness', capability: 'light', name: 'Brightness', readable: true, writable: enableControlsArg, unit: '%' });
56
56
  for (const id of Object.keys(stateArg.sensors).sort()) features.push({ id, capability: id.startsWith('sensor_ENERGY/') ? 'energy' : 'sensor', name: id.slice('sensor_'.length).split('/').map(decodeURIComponent).join(' '), readable: true, writable: false });
57
57
  const values = { ...stateArg.controls, ...stateArg.sensors };
58
58
  return {
@@ -69,12 +69,12 @@ export class TasmotaMapper {
69
69
  };
70
70
  }
71
71
 
72
- public static entities(deviceArg: plugins.shxInterfaces.data.IDeviceDefinition): IIntegrationEntity[] {
72
+ public static entities(deviceArg: plugins.shxInterfaces.data.IDeviceDefinition, homeIdArg: string): IIntegrationEntity[] {
73
73
  return deviceArg.features.map((featureArg) => {
74
74
  const platform = featureArg.capability === 'switch' ? 'switch' : featureArg.capability === 'light' ? 'light' : 'sensor';
75
75
  return {
76
- id: `${platform}.${encodeURIComponent(deviceArg.id)}.${encodeURIComponent(featureArg.id)}`,
77
- uniqueId: `${deviceArg.id}:${featureArg.id}`,
76
+ id: `${encodeURIComponent(homeIdArg)}:${platform}.${encodeURIComponent(JSON.stringify([deviceArg.id, featureArg.id]))}`,
77
+ uniqueId: JSON.stringify([homeIdArg, deviceArg.id, featureArg.id]),
78
78
  integrationDomain: 'tasmota',
79
79
  deviceId: deviceArg.id,
80
80
  platform,
@@ -1,4 +1,41 @@
1
1
  import type { IMqttConfig } from '../mqtt/mqtt.types.js';
2
+ import type { INativeIntegrationOwnerOptions } from '../../core/classes.nativeintegrationowner.js';
3
+
4
+ export interface ITasmotaBrokerIdentity {
5
+ readonly host: string;
6
+ readonly port: number;
7
+ readonly protocol: 'mqtt' | 'mqtts';
8
+ }
9
+
10
+ export interface ITasmotaCredential extends ITasmotaBrokerIdentity {
11
+ readonly version: 1;
12
+ readonly username?: string;
13
+ readonly password?: string;
14
+ readonly ca?: string;
15
+ readonly cert?: string;
16
+ readonly key?: string;
17
+ readonly servername?: string;
18
+ }
19
+
20
+ export interface ITasmotaCredentialRequest {
21
+ readonly homeId: string;
22
+ readonly credentialRef: string;
23
+ readonly broker: ITasmotaBrokerIdentity;
24
+ readonly signal: AbortSignal;
25
+ /** Absolute deadline on performance.now(), not wall-clock time. */
26
+ readonly deadlineMonotonicMs: number;
27
+ }
28
+
29
+ export type TTasmotaCredentialResolver = (requestArg: ITasmotaCredentialRequest) => Promise<ITasmotaCredential>;
30
+
31
+ export interface ITasmotaIntegrationOptions extends INativeIntegrationOwnerOptions {
32
+ /** Runtime-only embedding-owner permission. Defaults to false. */
33
+ readonly enableControls?: boolean;
34
+ readonly credentialResolver?: TTasmotaCredentialResolver;
35
+ }
36
+
37
+ export type TTasmotaAuthentication = { readonly mode: 'anonymous' }
38
+ | { readonly mode: 'credential-reference'; readonly credentialRef: string };
2
39
 
3
40
  export interface ITasmotaDeviceConfig {
4
41
  topic: string;
@@ -14,7 +51,9 @@ export interface ITasmotaDeviceConfig {
14
51
  }
15
52
 
16
53
  export interface IHomeAssistantTasmotaConfig {
17
- broker: IMqttConfig & { host: string };
54
+ readonly homeId: string;
55
+ broker: Omit<IMqttConfig, 'username' | 'password'> & { host: string };
56
+ readonly authentication: TTasmotaAuthentication;
18
57
  devices: ITasmotaDeviceConfig[];
19
58
  initialSnapshotTimeoutMs?: number;
20
59
  }
@@ -34,7 +73,9 @@ export interface ITasmotaSelectedDevice {
34
73
  }
35
74
 
36
75
  export interface ITasmotaConfig {
37
- broker: IMqttConfig & { host: string };
76
+ readonly homeId: string;
77
+ readonly authentication: TTasmotaAuthentication;
78
+ broker: Omit<IMqttConfig, 'username' | 'password'> & { host: string };
38
79
  devices: ITasmotaSelectedDevice[];
39
80
  initialSnapshotTimeoutMs: number;
40
81
  }
@@ -1,7 +1,7 @@
1
1
  # Tuya local protocol 3.4
2
2
 
3
3
  `TuyaIntegration` and the existing `HomeAssistantTuyaIntegration` export provide a
4
- native local runtime through published `@ecobridge.xyz/devicemanager` 4.2.0.
4
+ native local runtime through published `@ecobridge.xyz/devicemanager` 4.11.1.
5
5
  Setup requires an explicit home, device ID, literal private/link-local/loopback
6
6
  IPv4 endpoint, local key and this product's verified datapoint schema. Discovery
7
7
  has no broadcast probes and makes no claim to locate devices or obtain credentials.
@@ -33,11 +33,12 @@ const config: IHomeAssistantTuyaConfig = {
33
33
  { id: 108, name: 'Temperature', kind: 'sensor', valueType: 'integer', scale: 1, unit: '°C' },
34
34
  ],
35
35
  };
36
- const integration = new TuyaIntegration();
36
+ const integration = new TuyaIntegration({ homeId: 'home-one' });
37
+ const context = Object.freeze({ homeId: 'home-one', configStore: store });
37
38
  // One explicit provisioning action stores configuration and the opaque 16-byte
38
39
  // local key together, encrypted under the caller's key provider.
39
- await integration.provisionLocalKey(config, configuredLocalKeyBytes, { configStore: store });
40
- const runtime = await integration.setup(config, { configStore: store });
40
+ await integration.provisionLocalKey(config, configuredLocalKeyBytes, context);
41
+ const runtime = await integration.setup(config, context);
41
42
  try {
42
43
  const [device] = await runtime.devices();
43
44
  const power = device.features.find((feature) => feature.id === 'dp_7');
@@ -52,6 +53,14 @@ try {
52
53
  }
53
54
  ```
54
55
 
56
+ The host must independently select `context.homeId` for provisioning and setup.
57
+ It must match the configuration home and the actual store scope; it is never
58
+ inferred from either. An optional constructor `homeId` constrains the instance.
59
+ Otherwise, the first valid synchronous admission fixes its home even if subsequent
60
+ I/O fails. Configuration, context fields and local-key bytes are captured before
61
+ asynchronous work. Accessors and overridden store-scope methods cannot grant
62
+ admission. The host still owns authenticated access to the selected home.
63
+
55
64
  The store must be an actual `NoSqlConfigStore` scoped to the exact configured home
56
65
  and `tuya` domain. Generic stores are rejected. Configuration and credentials use
57
66
  `device:<configured device ID>` in that encrypted scope; setup rejects a different
@@ -85,14 +94,38 @@ arrive. Failures retain `effectOutcome: 'not-sent'`, `'rejected'` or `'indetermi
85
94
  a transmitted effect with a lost/late response may already have happened. There is
86
95
  no retry, retransmission or reconnect. Lifetime/setup cancellation and feature-write
87
96
  signals are supported. `destroy()` is permanent, idempotent and drains setup and
88
- protocol operations; use a new integration for a new lifetime. Cached reads after
89
- shutdown remain marked unavailable with their original report timestamps.
97
+ protocol operations and admitted asynchronous subscriber continuations. A fulfilled
98
+ provisioning write remains fulfilled if cancellation or close arrives during its
99
+ persistence; close joins the actual write result and never replays it. Setup and
100
+ provisioning are reserved before store or protocol callbacks run. Destruction
101
+ publishes one stable close promise before abort callbacks run. Caller cancellation
102
+ also closes promoted runtime ownership and releases its slot after cleanup.
103
+ Use a new integration for a new lifetime. Cached reads after
104
+ shutdown remain unavailable and read-only with their original report timestamps.
90
105
 
91
- Subscriptions are bounded to 128 listeners, abortable and explicitly removable.
92
- Handlers must not block. Asynchronous handler work is caller-owned; handler failures
93
- are reported through the supplied logger using a generic credential-free message.
106
+ Subscriptions are bounded to 128 listeners and 128 outstanding callback
107
+ continuations, abortable and explicitly removable. Handlers must not block or
108
+ await destruction of their own runtime: destruction joins their continuation.
109
+ Returned promises and thenables are owned and joined even after listener removal.
110
+ Callback failures are retained through cleanup and logged with a generic,
111
+ credential-free message. Overflow closes the runtime and retains the failure.
94
112
  All listeners are removed during destruction.
95
- One integration owns at most 32 simultaneous setup/provisioning/runtime lifetimes.
113
+
114
+ The shared native owner accounts for pending setup/provisioning operations,
115
+ promoted runtimes and retained failed cleanup. Its default limit is 16; the optional
116
+ constructor `capacity` selects a positive safe-integer limit. A failed cleanup
117
+ retains its capacity slot and rejects destruction. `integration.retryCleanup()`
118
+ retries only failed cleanup handles and explicitly acknowledges already-drained
119
+ subscriber failures; it never reruns callbacks, provisioning, queries or controls,
120
+ and never reopens a session. It does not change the original rejected close promise.
121
+ A resource whose owning cleanup still rejects remains retained.
122
+
123
+ Per-DP provisioned `read-write` access continues to grant control. There is no
124
+ additional host control-permission API and no automatic preflight or post-write
125
+ readback. Those public-contract changes require a separate product decision, as
126
+ does a config flow. Protocol authentication proves possession of the local key;
127
+ identity fields are checked when present, but this is not independent hardware
128
+ attestation. Firmware qualification remains separate from fixture verification.
96
129
 
97
130
  Other protocol versions, cloud-only and sleeping devices, gateway children, BLE,
98
131
  Zigbee, raw/bitmap/enum mappings and arbitrary actions are unsupported. The shared
@@ -101,6 +134,15 @@ device protocol union currently has no Tuya member, so the generic device report
101
134
  loopback fixture tests cover the implementation; qualification against real product
102
135
  firmware requires separately authorized hardware access.
103
136
 
137
+ Fixture qualification uses `pnpm exec tstest test/tuya/ --verbose --logfile --timeout 60`:
138
+ `test.tuya.node.ts` contains 28 encrypted persistence/protocol cases and
139
+ `test.tuya.owner.node.ts` adds 22 admission, reentry, cancellation, capacity,
140
+ accepted-persistence, retained-cleanup and callback-continuation regressions.
141
+ The 50-case suite passes with no device or cloud account. Strict domain/test checks
142
+ use `strict: true`, `noImplicitAny: true` and `skipLibCheck: false`; production
143
+ build and the complete test type check are also required. This fixture evidence
144
+ does not establish physical acceptance for a particular product or firmware.
145
+
104
146
  Product types and permissions follow the vendor's [DP Model and Control Protocol](https://developer.tuya.com/en/docs/iot-device-dev/TuyaOS-iot_abi_dp_ctrl?id=Kcoglhn5r7ajr).
105
147
  LAN framing is implemented by the owning dependency, whose public API and wire
106
148
  contract are documented in its [readme](https://code.foss.global/ecobridge.xyz/devicemanager).
@@ -1,10 +1,11 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
+ import { NativeIntegrationOwner } from '../../core/classes.nativeintegrationowner.js';
3
4
  import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
4
5
  import type { IIntegrationRuntime, IIntegrationSetupContext } from '../../core/types.js';
5
6
  import { TuyaRuntime } from './tuya.classes.runtime.js';
6
- import type { IHomeAssistantTuyaConfig, ITuyaStoredConfiguration } from './tuya.types.js';
7
- import { normalizeTuyaConfig, scopedTuyaStore, tuyaCredentialId, tuyaKeys, tuyaRecord } from './tuya.validation.js';
7
+ import type { IHomeAssistantTuyaConfig, ITuyaIntegrationOptions, ITuyaStoredConfiguration } from './tuya.types.js';
8
+ import { normalizeTuyaConfig, tuyaSetupContext, tuyaCredentialId, tuyaKeys, tuyaRecord } from './tuya.validation.js';
8
9
 
9
10
  export class TuyaIntegration extends BaseIntegration<IHomeAssistantTuyaConfig> {
10
11
  public readonly domain = 'tuya';
@@ -12,118 +13,88 @@ export class TuyaIntegration extends BaseIntegration<IHomeAssistantTuyaConfig> {
12
13
  public readonly status = 'control-runtime' as const;
13
14
  public readonly discoveryDescriptor = new DiscoveryDescriptor({ integrationDomain: 'tuya', displayName: 'Tuya 3.4 local device' });
14
15
  public readonly metadata = Object.freeze({ source: 'native-typescript', integrationType: 'device', iotClass: 'local_push', protocolVersion: '3.4' });
15
- readonly #setups = new Set<Promise<unknown>>();
16
- readonly #controllers = new Set<AbortController>();
17
- readonly #runtimes = new Set<TuyaRuntime>();
18
- #destroyed = false;
19
- #destroy?: Promise<void>;
16
+ readonly #owner: NativeIntegrationOwner;
17
+
18
+ constructor(optionsArg: ITuyaIntegrationOptions = {}) {
19
+ super();
20
+ this.#owner = new NativeIntegrationOwner(this.domain, optionsArg);
21
+ }
20
22
 
21
23
  public provisionLocalKey(configArg: IHomeAssistantTuyaConfig, localKeyArg: Uint8Array, contextArg: IIntegrationSetupContext): Promise<void> {
22
- this.#assertOpen();
23
- this.#assertCapacity();
24
24
  const config = normalizeTuyaConfig(configArg);
25
- const store = scopedTuyaStore(config, contextArg);
25
+ const context = tuyaSetupContext(contextArg);
26
26
  if (!(localKeyArg instanceof Uint8Array) || localKeyArg.byteLength !== 16) throw new TypeError('Tuya local key must contain exactly 16 opaque bytes.');
27
27
  const copy = Buffer.from(localKeyArg);
28
- const controller = new AbortController();
29
- this.#controllers.add(controller);
30
- const signal = contextArg.abortSignal ? AbortSignal.any([controller.signal, contextArg.abortSignal]) : controller.signal;
31
- const operation = (async () => {
32
- try {
33
- signal.throwIfAborted();
34
- await store.initialize({ signal });
35
- this.#assertOpen();
36
- signal.throwIfAborted();
28
+ try {
29
+ return this.#owner.runOperation(context, config.homeId, async (leaseArg) => {
30
+ const store = leaseArg.context.configStore!;
31
+ await store.initialize({ signal: leaseArg.signal });
32
+ leaseArg.assertOpen();
37
33
  await store.set<ITuyaStoredConfiguration>(tuyaCredentialId(config.deviceId), { version: 1, config, localKey: copy.toString('base64') });
38
- this.#assertOpen();
39
- signal.throwIfAborted();
40
- } finally { copy.fill(0); this.#controllers.delete(controller); }
41
- })();
42
- return this.#track(operation);
34
+ // A fulfilled admitted write stays fulfilled even if close/cancellation arrives during persistence.
35
+ }).finally(() => copy.fill(0));
36
+ } catch (errorArg) { copy.fill(0); throw errorArg; }
43
37
  }
44
38
 
45
39
  public setup(configArg: IHomeAssistantTuyaConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
46
- this.#assertOpen();
47
- this.#assertCapacity();
48
40
  const config = normalizeTuyaConfig(configArg);
49
- const store = scopedTuyaStore(config, contextArg);
50
- const controller = new AbortController();
51
- this.#controllers.add(controller);
52
- const signal = contextArg.abortSignal ? AbortSignal.any([controller.signal, contextArg.abortSignal]) : controller.signal;
53
- const operation = (async () => {
54
- let protocol: plugins.ecobridgeDeviceManager.TuyaProtocol | undefined;
41
+ const context = tuyaSetupContext(contextArg);
42
+ return this.#owner.runSetup(context, config.homeId, async (leaseArg) => {
43
+ const store = leaseArg.context.configStore!;
44
+ const signal = leaseArg.signal;
45
+ await store.initialize({ signal });
46
+ leaseArg.assertOpen();
47
+ const stored = tuyaRecord(await store.get<unknown>(tuyaCredentialId(config.deviceId)), 'stored configuration');
48
+ tuyaKeys(stored, ['version', 'config', 'localKey']);
49
+ if (stored.version !== 1 || JSON.stringify(normalizeTuyaConfig(stored.config)) !== JSON.stringify(config)) {
50
+ throw new TypeError('Tuya configuration does not match its encrypted provisioned configuration.');
51
+ }
52
+ if (typeof stored.localKey !== 'string' || stored.localKey.length !== 24) throw new TypeError('Tuya stored local key is invalid.');
53
+ const key = Buffer.from(stored.localKey, 'base64');
54
+ let protocol: plugins.ecobridgeDeviceManager.TuyaProtocol;
55
+ let runtime: TuyaRuntime | undefined;
55
56
  let unsubscribeAdmission: (() => void) | undefined;
57
+ let cleanupAttempted = false;
56
58
  try {
57
- signal.throwIfAborted();
58
- await store.initialize({ signal });
59
- signal.throwIfAborted();
60
- const stored = tuyaRecord(await store.get<unknown>(tuyaCredentialId(config.deviceId)), 'stored configuration');
61
- tuyaKeys(stored, ['version', 'config', 'localKey']);
62
- if (stored.version !== 1 || JSON.stringify(normalizeTuyaConfig(stored.config)) !== JSON.stringify(config)) {
63
- throw new TypeError('Tuya configuration does not match its encrypted provisioned configuration.');
64
- }
65
- if (typeof stored.localKey !== 'string' || stored.localKey.length !== 24) throw new TypeError('Tuya stored local key is invalid.');
66
- const key = Buffer.from(stored.localKey, 'base64');
67
- try {
68
- if (key.byteLength !== 16 || key.toString('base64') !== stored.localKey) throw new TypeError('Tuya stored local key is invalid.');
69
- signal.throwIfAborted();
70
- protocol = new plugins.ecobridgeDeviceManager.TuyaProtocol({
71
- deviceId: config.deviceId, localKey: key, host: config.host, port: config.port, version: config.version,
72
- connectTimeoutMs: config.connectTimeoutMs, requestTimeoutMs: config.requestTimeoutMs,
73
- heartbeatIntervalMs: config.heartbeatIntervalMs, abortSignal: signal,
74
- });
75
- } finally { key.fill(0); }
76
- const reports: plugins.ecobridgeDeviceManager.ITuyaDatapointReport[] = [];
77
- let admissionOverflow = false;
78
- unsubscribeAdmission = protocol.subscribe((reportArg) => {
79
- if (reports.length >= 128) { admissionOverflow = true; controller.abort(); }
80
- else reports.push(reportArg);
59
+ if (key.byteLength !== 16 || key.toString('base64') !== stored.localKey) throw new TypeError('Tuya stored local key is invalid.');
60
+ leaseArg.assertOpen();
61
+ protocol = new plugins.ecobridgeDeviceManager.TuyaProtocol({
62
+ deviceId: config.deviceId, localKey: key, host: config.host, port: config.port, version: config.version,
63
+ connectTimeoutMs: config.connectTimeoutMs, requestTimeoutMs: config.requestTimeoutMs,
64
+ heartbeatIntervalMs: config.heartbeatIntervalMs, abortSignal: signal,
81
65
  });
82
- await protocol.connect(signal);
83
- await protocol.queryDatapoints(signal);
84
- if (admissionOverflow) throw new Error('Tuya admission report capacity exceeded.');
85
- signal.throwIfAborted();
86
- this.#assertOpen();
87
- unsubscribeAdmission();
88
- unsubscribeAdmission = undefined;
89
- const runtime = new TuyaRuntime(config, protocol, reports, () => {
90
- this.#runtimes.delete(runtime);
91
- this.#controllers.delete(controller);
92
- }, contextArg.logger);
93
- this.#runtimes.add(runtime);
94
- return runtime;
95
- } catch (errorArg) {
96
- controller.abort();
97
- unsubscribeAdmission?.();
98
- await protocol?.close();
99
- this.#controllers.delete(controller);
100
- throw errorArg;
101
- }
102
- })();
103
- return this.#track(operation);
104
- }
105
-
106
- public destroy(): Promise<void> {
107
- if (this.#destroy) return this.#destroy;
108
- this.#destroyed = true;
109
- for (const controller of this.#controllers) controller.abort();
110
- this.#destroy = (async () => {
111
- await Promise.allSettled([...this.#setups]);
112
- await Promise.all([...this.#runtimes].map((runtimeArg) => runtimeArg.destroy()));
113
- this.#controllers.clear();
114
- })();
115
- return this.#destroy;
66
+ // One composite handle transfers protocol and callback cleanup together to the runtime.
67
+ leaseArg.own(async () => {
68
+ const retry = cleanupAttempted;
69
+ cleanupAttempted = true;
70
+ unsubscribeAdmission?.();
71
+ unsubscribeAdmission = undefined;
72
+ if (runtime) await runtime.closeOwnedResources(retry);
73
+ else await protocol.close();
74
+ });
75
+ } finally { key.fill(0); }
76
+ const reports: plugins.ecobridgeDeviceManager.ITuyaDatapointReport[] = [];
77
+ let admissionOverflow = false;
78
+ unsubscribeAdmission = protocol.subscribe((reportArg) => {
79
+ if (reports.length >= 128) { admissionOverflow = true; void leaseArg.close().catch(() => undefined); }
80
+ else reports.push(reportArg);
81
+ });
82
+ await protocol.connect(signal);
83
+ await protocol.queryDatapoints(signal);
84
+ if (admissionOverflow) throw new Error('Tuya admission report capacity exceeded.');
85
+ leaseArg.assertOpen();
86
+ unsubscribeAdmission();
87
+ unsubscribeAdmission = undefined;
88
+ runtime = new TuyaRuntime(config, protocol, reports, leaseArg);
89
+ leaseArg.promoteToRuntime();
90
+ return runtime;
91
+ });
116
92
  }
117
93
 
118
- #assertOpen(): void { if (this.#destroyed) throw new Error('Tuya integration is permanently destroyed.'); }
94
+ public destroy(): Promise<void> { return this.#owner.close(); }
119
95
 
120
- #assertCapacity(): void { if (this.#controllers.size >= 32) throw new Error('Tuya integration lifetime capacity reached.'); }
121
-
122
- #track<TResult>(operationArg: Promise<TResult>): Promise<TResult> {
123
- this.#setups.add(operationArg);
124
- void operationArg.then(() => this.#setups.delete(operationArg), () => this.#setups.delete(operationArg));
125
- return operationArg;
126
- }
96
+ /** Retry retained cleanup only; never reopen a session or replay an accepted operation. */
97
+ public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
127
98
  }
128
99
 
129
100
  export class HomeAssistantTuyaIntegration extends TuyaIntegration {}