@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,10 +1,15 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEntityDescriptor, IEsphomeEntityState, IEsphomeEvent, IEsphomeSnapshot } from './esphome.types.js';
3
- import { esphomeDefaultNativeApiPort } from './esphome.types.js';
2
+ import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEntityDescriptor, IEsphomeEntityState, IEsphomeEvent, IEsphomeSnapshot, TEsphomeCredentials } from './esphome.types.js';
3
+ import { captureEsphomeConfig, captureEsphomeCredentials, esphomeMac } from './esphome.validation.js';
4
4
 
5
5
  type TEsphomeEventHandler = (eventArg: IEsphomeEvent) => void;
6
6
  type TNativeClient = plugins.esphomeNativeApi.EspHomeClient;
7
7
  type TNativeEntity = plugins.esphomeNativeApi.Entity;
8
+ interface IEsphomeCleanup {
9
+ readonly cleanup: () => void;
10
+ failed: boolean;
11
+ promise?: Promise<void>;
12
+ }
8
13
 
9
14
  const readablePlatforms = new Set(['light', 'switch', 'fan', 'cover', 'climate', 'number', 'select', 'text', 'sensor', 'binary_sensor', 'text_sensor']);
10
15
  const writablePlatforms = new Set(['light', 'switch', 'fan', 'cover', 'number', 'select', 'text']);
@@ -12,12 +17,12 @@ const writablePlatforms = new Set(['light', 'switch', 'fan', 'cover', 'number',
12
17
  export class EsphomeClient {
13
18
  private readonly connection: EsphomeNativeApiConnection;
14
19
 
15
- constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient) {
16
- this.connection = new EsphomeNativeApiConnection(configArg, nativeClientArg);
20
+ constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient, credentialsArg: TEsphomeCredentials = {}) {
21
+ this.connection = new EsphomeNativeApiConnection(configArg, nativeClientArg, credentialsArg);
17
22
  }
18
23
 
19
- public async connectLive(signalArg?: AbortSignal): Promise<void> {
20
- await this.connection.connect(signalArg);
24
+ public connectLive(signalArg?: AbortSignal): Promise<void> {
25
+ return this.connection.connect(signalArg);
21
26
  }
22
27
 
23
28
  public isHealthy(): boolean {
@@ -28,16 +33,20 @@ export class EsphomeClient {
28
33
  return this.connection.getSnapshot();
29
34
  }
30
35
 
31
- public onEvent(handlerArg: TEsphomeEventHandler): () => void {
32
- return this.connection.onEvent(handlerArg);
36
+ public onEvent(handlerArg: TEsphomeEventHandler, signalArg?: AbortSignal): () => void {
37
+ return this.connection.onEvent(handlerArg, signalArg);
33
38
  }
34
39
 
35
40
  public async sendCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
36
41
  return this.connection.sendCommand(commandArg, signalArg);
37
42
  }
38
43
 
39
- public async destroy(): Promise<void> {
40
- await this.connection.destroy();
44
+ public destroy(): Promise<void> {
45
+ return this.connection.destroy();
46
+ }
47
+
48
+ public retryCleanup(): Promise<void> {
49
+ return this.connection.retryCleanup();
41
50
  }
42
51
  }
43
52
 
@@ -48,83 +57,88 @@ export class EsphomeNativeApiConnection {
48
57
  private readonly setupTimeoutMs: number;
49
58
  private readonly commandTimeoutMs: number;
50
59
  private readonly requireEncryption: boolean;
60
+ private readonly config: IEsphomeConfig;
51
61
  private readonly handlers = new Set<TEsphomeEventHandler>();
52
- private readonly subscriptions: Disposable[] = [];
62
+ private readonly cleanup = new Set<IEsphomeCleanup>();
63
+ private readonly operations = new Set<Promise<unknown>>();
53
64
  private readonly pendingCommands = new Set<string>();
54
65
  private readonly lifetime = new AbortController();
55
66
  private ready = false;
56
67
  private connecting = false;
57
68
  private destroyed = false;
69
+ private subscriptionsStarted = false;
70
+ private identityMismatch = false;
71
+ private closePromise?: Promise<void>;
72
+ private retryPromise?: Promise<void>;
58
73
 
59
- constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient) {
60
- const manual = configArg.manualEntries?.[0];
61
- if (configArg.password || manual?.password) {
62
- throw new Error('ESPHome legacy API password authentication is unsupported. Upgrade the device to ESPHome 2025.10 or newer and configure native API encryption.');
63
- }
64
- if (configArg.snapshot || configArg.entities || configArg.states || configArg.services || configArg.commandExecutor) {
65
- throw new Error('ESPHome live setup does not accept injected snapshots, states, services, or command executors.');
66
- }
67
- if (configArg.manualEntries && configArg.manualEntries.length > 1) {
68
- throw new Error('Configure each ESPHome native API device as a separate integration instance.');
69
- }
70
- this.host = (configArg.host || manual?.host || '').trim();
71
- if (!this.host || /\s/.test(this.host)) {
72
- throw new Error('ESPHome native API requires a valid host.');
73
- }
74
- this.port = configArg.port ?? manual?.port ?? esphomeDefaultNativeApiPort;
75
- if (!Number.isInteger(this.port) || this.port < 1 || this.port > 65535) {
76
- throw new Error('ESPHome native API port must be an integer from 1 through 65535.');
77
- }
78
- this.setupTimeoutMs = this.timeout(configArg.initialSnapshotTimeoutMs, 15000);
79
- this.commandTimeoutMs = this.timeout(configArg.commandTimeoutMs, 5000);
80
- const keys = [configArg.encryptionKey, configArg.noisePsk, manual?.encryptionKey, manual?.noisePsk].filter((valueArg): valueArg is string => valueArg !== undefined);
81
- if (new Set(keys).size > 1) throw new Error('ESPHome encryption key configuration is conflicting.');
82
- const key = keys[0];
83
- if (key !== undefined && (!/^[A-Za-z0-9+/]{43}=$/.test(key) || Buffer.from(key, 'base64').length !== 32 || Buffer.from(key, 'base64').toString('base64') !== key)) {
84
- throw new Error('ESPHome encryption key must be a canonical base64-encoded 32-byte key.');
85
- }
86
- this.requireEncryption = key !== undefined;
74
+ constructor(configArg: IEsphomeConfig, nativeClientArg?: TNativeClient, credentialsArg: TEsphomeCredentials = {}) {
75
+ this.config = captureEsphomeConfig(configArg);
76
+ const credentials = captureEsphomeCredentials(credentialsArg);
77
+ this.host = this.config.host;
78
+ this.port = this.config.port ?? 6053;
79
+ this.setupTimeoutMs = this.config.initialSnapshotTimeoutMs ?? 15000;
80
+ this.commandTimeoutMs = this.config.commandTimeoutMs ?? 5000;
81
+ this.requireEncryption = credentials.psk != null;
87
82
  this.native = nativeClientArg ?? new plugins.esphomeNativeApi.EspHomeClient({
88
83
  host: this.host,
89
84
  port: this.port,
90
- psk: key,
91
- serverName: configArg.deviceName || manual?.deviceName,
85
+ psk: credentials.psk,
86
+ serverName: this.config.deviceName,
92
87
  connectTimeoutMs: this.setupTimeoutMs,
93
88
  });
94
- this.subscriptions.push(
95
- this.native.on('telemetry', (eventArg) => {
96
- this.refreshReadiness();
97
- this.emit({ type: 'state_changed', entityId: this.eventEntityId(eventArg), data: eventArg });
98
- }),
99
- this.native.onHealthChange((healthArg) => {
100
- if (healthArg.state === 'connected' && this.requireEncryption && !healthArg.encrypted) {
101
- this.ready = false;
102
- this.native.disconnect();
103
- this.emit({ type: 'error', data: { message: 'ESPHome encryption is required; the plaintext session was rejected.' } });
104
- return;
105
- }
106
- this.refreshReadiness();
107
- this.emit({ type: 'availability_changed', data: { available: this.isHealthy() } });
108
- }),
109
- );
89
+ // Construction is inert: own the native client before invoking any subscription callbacks.
90
+ this.cleanup.add({ cleanup: () => this.native.disconnect(), failed: false });
91
+ }
92
+
93
+ private startSubscriptions(): void {
94
+ if (this.subscriptionsStarted) return;
95
+ this.subscriptionsStarted = true;
96
+ const telemetry = this.native.on('telemetry', (eventArg) => {
97
+ this.refreshReadiness();
98
+ if (!this.identityMismatch) this.emit({ type: 'state_changed', entityId: this.eventEntityId(eventArg), data: eventArg });
99
+ });
100
+ this.cleanup.add({ cleanup: () => telemetry[Symbol.dispose](), failed: false });
101
+ this.lifetime.signal.throwIfAborted();
102
+ const health = this.native.onHealthChange((healthArg) => {
103
+ if (healthArg.state === 'connected' && this.requireEncryption && !healthArg.encrypted) {
104
+ this.ready = false;
105
+ this.native.disconnect();
106
+ this.emit({ type: 'error', data: { message: 'ESPHome encryption is required; the plaintext session was rejected.' } });
107
+ return;
108
+ }
109
+ this.refreshReadiness();
110
+ this.emit({ type: 'availability_changed', data: { available: this.isHealthy() } });
111
+ });
112
+ this.cleanup.add({ cleanup: () => health[Symbol.dispose](), failed: false });
113
+ this.lifetime.signal.throwIfAborted();
110
114
  }
111
115
 
112
- public async connect(signalArg?: AbortSignal): Promise<void> {
113
- if (this.destroyed || this.connecting || this.isHealthy()) throw new Error('ESPHome connection is destroyed, already connecting, or already connected.');
116
+ public connect(signalArg?: AbortSignal): Promise<void> {
117
+ if (this.destroyed || this.connecting || this.isHealthy()) return Promise.reject(new Error('ESPHome connection is destroyed, already connecting, or already connected.'));
114
118
  this.connecting = true;
115
- const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.setupTimeoutMs), ...(signalArg ? [signalArg] : [])]);
116
- try {
117
- signal.throwIfAborted();
118
- await this.native.connect({ signal });
119
- if (this.requireEncryption && !this.native.health().encrypted) throw new Error('ESPHome encryption is required; the plaintext session was rejected.');
120
- if (this.native.deviceInfo()?.usesPassword) throw new Error('ESPHome legacy API password authentication is unsupported.');
121
- await this.waitForInitialStates(signal);
122
- } catch (error) {
123
- await this.destroy();
124
- throw error;
125
- } finally {
126
- this.connecting = false;
127
- }
119
+ const operation = this.track(async () => {
120
+ const signal = AbortSignal.any([this.lifetime.signal, AbortSignal.timeout(this.setupTimeoutMs), ...(signalArg ? [signalArg] : [])]);
121
+ try {
122
+ signal.throwIfAborted();
123
+ this.startSubscriptions();
124
+ await this.native.connect({ signal });
125
+ if (this.requireEncryption && !this.native.health().encrypted) throw new Error('ESPHome encryption is required; the plaintext session was rejected.');
126
+ if (this.native.deviceInfo()?.usesPassword) throw new Error('ESPHome legacy API password authentication is unsupported.');
127
+ this.assertIdentity();
128
+ await this.waitForInitialStates(signal);
129
+ } catch {
130
+ // SDK diagnostics may contain credentials; do not expose their messages or causes.
131
+ throw new Error('ESPHome native setup failed or was cancelled.');
132
+ } finally {
133
+ this.connecting = false;
134
+ }
135
+ });
136
+ // Join cleanup only after the tracked connect operation settles, so cleanup never awaits itself.
137
+ return operation.catch(async (errorArg: unknown) => {
138
+ try { await this.destroy(); }
139
+ catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'ESPHome setup and owned cleanup failed.'); }
140
+ throw errorArg;
141
+ });
128
142
  }
129
143
 
130
144
  public isHealthy(): boolean {
@@ -132,9 +146,11 @@ export class EsphomeNativeApiConnection {
132
146
  }
133
147
 
134
148
  public getSnapshot(): IEsphomeSnapshot {
149
+ if (this.identityMismatch) throw new Error('ESPHome selected device identity changed; native observations are unavailable.');
135
150
  const connected = this.isHealthy();
136
151
  const entities = this.native.entitiesByDevice(undefined).filter((entityArg) => readablePlatforms.has(entityArg.type));
137
152
  return {
153
+ homeId: this.config.homeId,
138
154
  host: this.host,
139
155
  port: this.port,
140
156
  connected,
@@ -155,13 +171,31 @@ export class EsphomeNativeApiConnection {
155
171
  };
156
172
  }
157
173
 
158
- public onEvent(handlerArg: TEsphomeEventHandler): () => void {
174
+ public onEvent(handlerArg: TEsphomeEventHandler, signalArg?: AbortSignal): () => void {
159
175
  if (this.destroyed) throw new Error('ESPHome connection was destroyed.');
176
+ signalArg?.throwIfAborted();
177
+ let active = true;
178
+ const dispose = (): void => {
179
+ if (!active) return;
180
+ signalArg?.removeEventListener('abort', dispose);
181
+ this.handlers.delete(handlerArg);
182
+ active = false;
183
+ this.cleanup.delete(resource);
184
+ };
185
+ const resource: IEsphomeCleanup = { cleanup: dispose, failed: false };
186
+ // Own the handler and caller-signal listener before the foreign registration method.
187
+ this.cleanup.add(resource);
160
188
  this.handlers.add(handlerArg);
161
- return () => this.handlers.delete(handlerArg);
189
+ signalArg?.addEventListener('abort', dispose, { once: true });
190
+ if (signalArg?.aborted) dispose();
191
+ return dispose;
162
192
  }
163
193
 
164
- public async sendCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
194
+ public sendCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
195
+ return this.track(() => this.executeTrackedCommand(commandArg, signalArg));
196
+ }
197
+
198
+ private async executeTrackedCommand(commandArg: IEsphomeClientCommand, signalArg?: AbortSignal): Promise<IEsphomeCommandResult> {
165
199
  signalArg?.throwIfAborted();
166
200
  if (!this.isHealthy()) return { success: false, error: 'ESPHome is unavailable or has not received its authoritative initial states.' };
167
201
  const entity = this.native.entitiesByDevice(undefined).find((entityArg) => entityArg.key === commandArg.key && entityArg.type === commandArg.platform && (entityArg.deviceId ?? 0) === (commandArg.deviceId ?? 0));
@@ -174,22 +208,56 @@ export class EsphomeNativeApiConnection {
174
208
  try {
175
209
  const state = await this.executeCommand(entity, commandArg.payload, signalArg);
176
210
  return { success: true, data: structuredClone(state) };
177
- } catch (error) {
178
- return { success: false, error: error instanceof Error ? error.message : 'ESPHome command failed.' };
211
+ } catch {
212
+ return { success: false, error: 'ESPHome command failed or was cancelled; its physical effect is unknown.' };
179
213
  } finally {
180
214
  this.pendingCommands.delete(plugins.esphomeNativeApi.entityId(entity.type, entity.objectId));
181
215
  }
182
216
  }
183
217
 
184
- public async destroy(): Promise<void> {
185
- if (this.destroyed) return;
186
- this.destroyed = true;
187
- this.ready = false;
188
- this.lifetime.abort(new Error('ESPHome connection was destroyed.'));
189
- this.native.disconnect();
190
- for (const subscription of this.subscriptions) subscription[Symbol.dispose]();
191
- this.subscriptions.length = 0;
218
+ public destroy(): Promise<void> {
219
+ if (!this.closePromise) {
220
+ this.destroyed = true;
221
+ this.ready = false;
222
+ this.closePromise = Promise.resolve().then(() => this.drain(false));
223
+ this.lifetime.abort(new Error('ESPHome connection was destroyed.'));
224
+ }
225
+ return this.closePromise;
226
+ }
227
+
228
+ public retryCleanup(): Promise<void> {
229
+ if (!this.closePromise) throw new Error('ESPHome cleanup retry requires a closed client.');
230
+ if (!this.retryPromise) {
231
+ this.retryPromise = Promise.resolve().then(() => this.drain(true));
232
+ const settled = () => { this.retryPromise = undefined; };
233
+ void this.retryPromise.then(settled, settled);
234
+ }
235
+ return this.retryPromise;
236
+ }
237
+
238
+ private track<T>(workArg: () => Promise<T>): Promise<T> {
239
+ if (this.destroyed) return Promise.reject(new Error('ESPHome connection was destroyed.'));
240
+ // Store the actual operation before invoking native or consumer callbacks.
241
+ const operation = Promise.resolve().then(workArg);
242
+ this.operations.add(operation);
243
+ const settled = () => this.operations.delete(operation);
244
+ void operation.then(settled, settled);
245
+ return operation;
246
+ }
247
+
248
+ private async drain(retryArg: boolean): Promise<void> {
249
+ const cleanup = [...this.cleanup].reverse().map((resourceArg) => {
250
+ if (retryArg && resourceArg.failed) { resourceArg.promise = undefined; resourceArg.failed = false; }
251
+ resourceArg.promise ??= Promise.resolve().then(() => resourceArg.cleanup()).then(() => undefined, () => {
252
+ resourceArg.failed = true;
253
+ throw new Error('ESPHome owned resource cleanup failed.');
254
+ });
255
+ return resourceArg.promise;
256
+ });
257
+ const [results] = await Promise.all([Promise.allSettled(cleanup), Promise.allSettled([...this.operations])]);
192
258
  this.handlers.clear();
259
+ const failures = results.filter((resultArg) => resultArg.status === 'rejected');
260
+ if (failures.length) throw new AggregateError(failures.map(() => new Error('ESPHome owned resource cleanup failed.')), 'ESPHome cleanup failed; resources remain retained for explicit retry.');
193
261
  }
194
262
 
195
263
  private async executeCommand(entityArg: TNativeEntity, payloadArg: Record<string, unknown>, signalArg?: AbortSignal): Promise<unknown> {
@@ -253,14 +321,32 @@ export class EsphomeNativeApiConnection {
253
321
 
254
322
  private refreshReadiness(): void {
255
323
  const health = this.native.health();
256
- this.ready = health.state === 'connected' && (!this.requireEncryption || health.encrypted) && this.native.entitiesByDevice(undefined).filter((entityArg) => readablePlatforms.has(entityArg.type)).every((entityArg) => this.native.latest(plugins.esphomeNativeApi.entityId(entityArg.type, entityArg.objectId)) != null);
324
+ if (health.state === 'connected') {
325
+ try { this.assertIdentity(); }
326
+ catch {
327
+ this.identityMismatch = true;
328
+ this.ready = false;
329
+ this.native.disconnect();
330
+ this.emit({ type: 'error', data: { message: 'ESPHome selected native device identity changed.' } });
331
+ return;
332
+ }
333
+ }
334
+ this.ready = !this.destroyed && !this.identityMismatch && health.state === 'connected' && (!this.requireEncryption || health.encrypted) && this.native.entitiesByDevice(undefined).filter((entityArg) => readablePlatforms.has(entityArg.type)).every((entityArg) => this.native.latest(plugins.esphomeNativeApi.entityId(entityArg.type, entityArg.objectId)) != null);
335
+ }
336
+
337
+ private assertIdentity(): void {
338
+ const info = this.native.deviceInfo();
339
+ if (!info || esphomeMac(info.macAddress) !== this.config.expectedMacAddress || info.name !== this.config.deviceName
340
+ || (this.config.expectedModel !== undefined && info.model !== this.config.expectedModel)) {
341
+ throw new Error('ESPHome native device identity does not match the selected endpoint.');
342
+ }
257
343
  }
258
344
 
259
345
  private entityId(entityArg: TNativeEntity): string {
260
346
  const macAddress = this.native.deviceInfo()?.macAddress?.toLowerCase().replace(/[^a-f0-9]/g, '');
261
347
  const identity = macAddress?.length === 12 ? macAddress : `${this.host}:${this.port}`;
262
348
  const digest = plugins.crypto.createHash('sha256').update(JSON.stringify([identity, entityArg.type, entityArg.key, entityArg.deviceId ?? 0])).digest('hex').slice(0, 24);
263
- return `${entityArg.type}.esphome_${digest}`;
349
+ return `home.${encodeURIComponent(this.config.homeId)}.${entityArg.type}.esphome_${digest}`;
264
350
  }
265
351
 
266
352
  private eventEntityId(eventArg: plugins.esphomeNativeApi.TelemetryEvent): string | undefined {
@@ -285,12 +371,11 @@ export class EsphomeNativeApiConnection {
285
371
  }
286
372
 
287
373
  private emit(eventArg: IEsphomeEvent): void {
288
- for (const handler of this.handlers) handler({ ...structuredClone(eventArg), timestamp: Date.now() });
289
- }
290
-
291
- private timeout(valueArg: number | undefined, defaultArg: number): number {
292
- const value = valueArg ?? defaultArg;
293
- if (!Number.isInteger(value) || value < 1 || value > 2147483647) throw new Error('ESPHome timeouts must be integers from 1 through 2147483647.');
294
- return value;
374
+ const failures: unknown[] = [];
375
+ for (const handler of this.handlers) {
376
+ try { handler({ ...structuredClone(eventArg), timestamp: Date.now() }); }
377
+ catch (error) { failures.push(error); }
378
+ }
379
+ if (failures.length) throw new AggregateError(failures.map(() => new Error('ESPHome subscriber callback failed.')), 'ESPHome event callbacks failed.');
295
380
  }
296
381
  }
@@ -1,49 +1,56 @@
1
1
  import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
2
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
2
3
  import type { IEsphomeConfig } from './esphome.types.js';
3
- import { esphomeDefaultNativeApiPort } from './esphome.types.js';
4
+ import { captureEsphomeConfig, esphomeData } from './esphome.validation.js';
4
5
 
5
6
  export class EsphomeConfigFlow implements IConfigFlow<IEsphomeConfig> {
7
+ private readonly scope: NativeIntegrationScope;
8
+
9
+ constructor(ownerArg: NativeIntegrationOwner = new NativeIntegrationOwner('esphome')) {
10
+ this.scope = ownerArg.createFlowScope();
11
+ }
12
+
6
13
  public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IEsphomeConfig>> {
7
- void contextArg;
14
+ const context = this.scope.admit(contextArg);
15
+ // Discovery offers endpoint hints; its metadata, home fields and credentials are never copied.
16
+ const hints: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
17
+ for (const key of ['host', 'port', 'name']) {
18
+ const field = Object.getOwnPropertyDescriptor(candidateArg, key);
19
+ if (field && (!field.enumerable || !('value' in field))) throw new TypeError('ESPHome discovery hints must be ordinary data fields.');
20
+ if (field) hints[key] = field.value as unknown;
21
+ }
22
+ const hint = Object.freeze(hints);
8
23
  return {
9
24
  kind: 'form',
10
25
  title: 'Connect ESPHome',
11
- description: 'Connect directly to the native API of an ESPHome 2025.10 or newer device. Use its 32-byte base64 encryption key when encryption is configured.',
26
+ description: 'Confirm the selected native endpoint, node name and MAC address. Provision credentials outside device configuration.',
12
27
  fields: [
13
28
  { name: 'host', label: 'Host', type: 'text', required: true },
14
29
  { name: 'port', label: 'Native API port', type: 'number' },
15
- { name: 'encryptionKey', label: 'Encryption key', type: 'password' },
30
+ { name: 'deviceName', label: 'Selected native node name', type: 'text', required: true },
31
+ { name: 'expectedMacAddress', label: 'Selected device MAC address', type: 'text', required: true },
32
+ { name: 'expectedModel', label: 'Selected model (optional)', type: 'text' },
33
+ { name: 'credentialKey', label: 'Credential reference (optional)', type: 'text' },
34
+ { name: 'credentialSource', label: 'Credential source', type: 'select', options: [{ label: 'External provider', value: 'provider' }, { label: 'Encrypted store', value: 'store' }] },
16
35
  ],
17
- submit: async (valuesArg) => ({
18
- kind: 'done',
19
- title: 'ESPHome configured',
20
- config: {
21
- host: this.stringValue(valuesArg.host) || candidateArg.host || '',
22
- port: this.numberValue(valuesArg.port) || candidateArg.port || esphomeDefaultNativeApiPort,
23
- name: candidateArg.name,
24
- deviceName: this.stringValue(candidateArg.metadata?.deviceName),
25
- encryptionKey: this.stringValue(valuesArg.encryptionKey) || this.stringValue(candidateArg.metadata?.encryptionKey),
26
- noisePsk: this.stringValue(candidateArg.metadata?.noisePsk),
27
- deviceInfo: {
28
- name: this.stringValue(candidateArg.metadata?.deviceName) || candidateArg.name,
29
- friendlyName: candidateArg.name,
30
- macAddress: candidateArg.macAddress,
31
- manufacturer: candidateArg.manufacturer,
32
- model: candidateArg.model,
33
- host: candidateArg.host,
34
- port: candidateArg.port || esphomeDefaultNativeApiPort,
35
- apiEncryptionSupported: Boolean(candidateArg.metadata?.encryptionRequired),
36
- },
37
- },
38
- }),
36
+ submit: async (valuesArg) => {
37
+ const values = esphomeData(valuesArg, ['homeId', 'host', 'port', 'deviceName', 'expectedMacAddress', 'expectedModel', 'credentialKey', 'credentialSource'], 'ESPHome submitted configuration');
38
+ if (values.homeId !== undefined && values.homeId !== context.homeId) throw new Error('ESPHome form home does not match the selected host home.');
39
+ const config = captureEsphomeConfig({
40
+ homeId: context.homeId, host: (values.host ?? hint.host) as string, port: (values.port ?? hint.port) as number | undefined,
41
+ name: hint.name as string | undefined, deviceName: values.deviceName as string, expectedMacAddress: values.expectedMacAddress as string,
42
+ expectedModel: values.expectedModel as string | undefined, credentialKey: values.credentialKey as string | undefined,
43
+ credentialSource: values.credentialSource as 'provider' | 'store' | undefined,
44
+ });
45
+ return this.scope.run(context, config.homeId, async (leaseArg) => {
46
+ leaseArg.assertOpen();
47
+ if (config.credentialSource === 'store' && !leaseArg.context.configStore) throw new Error('ESPHome store credential references require a scoped NoSqlConfigStore.');
48
+ return { kind: 'done', title: 'ESPHome configured', config };
49
+ });
50
+ },
39
51
  };
40
52
  }
41
53
 
42
- private stringValue(valueArg: unknown): string | undefined {
43
- return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
44
- }
45
-
46
- private numberValue(valueArg: unknown): number | undefined {
47
- return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined;
48
- }
54
+ public destroy(): Promise<void> { return this.scope.close(); }
55
+ public retryCleanup(): Promise<void> { return this.scope.retryCleanup(); }
49
56
  }
@@ -1,18 +1,20 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
3
4
  import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
4
5
  import { EsphomeClient } from './esphome.classes.client.js';
5
6
  import { EsphomeConfigFlow } from './esphome.classes.configflow.js';
6
7
  import { createEsphomeDiscoveryDescriptor } from './esphome.discovery.js';
7
8
  import { EsphomeMapper } from './esphome.mapper.js';
8
- import type { IEsphomeConfig } from './esphome.types.js';
9
+ import type { IEsphomeConfig, IEsphomeIntegrationOptions, TEsphomeCredentials } from './esphome.types.js';
10
+ import { captureEsphomeConfig, captureEsphomeCredentials, esphomeData } from './esphome.validation.js';
9
11
 
10
12
  export class EsphomeIntegration extends BaseIntegration<IEsphomeConfig> {
11
13
  public readonly domain = 'esphome';
12
14
  public readonly displayName = 'ESPHome';
13
15
  public readonly status = 'control-runtime' as const;
14
16
  public readonly discoveryDescriptor = createEsphomeDiscoveryDescriptor();
15
- public readonly configFlow = new EsphomeConfigFlow();
17
+ public readonly configFlow: EsphomeConfigFlow;
16
18
  public readonly metadata = {
17
19
  integrationType: 'device',
18
20
  iotClass: 'local_push',
@@ -21,35 +23,61 @@ export class EsphomeIntegration extends BaseIntegration<IEsphomeConfig> {
21
23
  minimumFirmwareVersion: '2025.10',
22
24
  zeroconf: ['_esphomelib._tcp.local.'],
23
25
  };
24
- private readonly runtimes = new Set<EsphomeRuntime>();
25
- private readonly pendingClients = new Set<EsphomeClient>();
26
- private destroyed = false;
26
+ private readonly owner: NativeIntegrationOwner;
27
+ private readonly resolveCredentials?: IEsphomeIntegrationOptions['resolveCredentials'];
28
+
29
+ constructor(optionsArg: IEsphomeIntegrationOptions = {}) {
30
+ super();
31
+ const options = esphomeData(optionsArg, ['homeId', 'capacity', 'resolveCredentials'], 'ESPHome integration options');
32
+ if (options.resolveCredentials !== undefined && typeof options.resolveCredentials !== 'function') throw new TypeError('ESPHome resolveCredentials must be an asynchronous callback.');
33
+ this.resolveCredentials = options.resolveCredentials as IEsphomeIntegrationOptions['resolveCredentials'];
34
+ this.owner = new NativeIntegrationOwner(this.domain, { homeId: options.homeId as string | undefined, capacity: options.capacity as number | undefined });
35
+ this.configFlow = new EsphomeConfigFlow(this.owner);
36
+ }
27
37
 
28
38
  public async setup(configArg: IEsphomeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
29
- if (this.destroyed) throw new Error('ESPHome integration was destroyed.');
30
- const client = new EsphomeClient(configArg);
31
- this.pendingClients.add(client);
32
- try {
33
- await client.connectLive(contextArg.abortSignal);
34
- if (this.destroyed) throw new Error('ESPHome integration was destroyed during setup.');
35
- const runtime = new EsphomeRuntime(client, () => this.runtimes.delete(runtime));
36
- this.runtimes.add(runtime);
39
+ const config = captureEsphomeConfig(configArg);
40
+ return this.owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
41
+ const timeout = config.initialSnapshotTimeoutMs ?? 15000;
42
+ const monotonicDeadline = performance.now() + timeout;
43
+ const signal = AbortSignal.any([leaseArg.signal, AbortSignal.timeout(timeout)]);
44
+ let credentials: TEsphomeCredentials = {};
45
+ if (config.credentialKey !== undefined) {
46
+ const request = Object.freeze({ capturedHomeId: leaseArg.context.homeId, credentialKey: config.credentialKey, signal, monotonicDeadline });
47
+ // Setup is retained before the deferred callback. Adopt its real completion, never an abort race.
48
+ const provision = Promise.resolve().then(() => {
49
+ signal.throwIfAborted();
50
+ if (config.credentialSource === 'store') {
51
+ if (!leaseArg.context.configStore) throw new Error('ESPHome store credential references require a scoped NoSqlConfigStore.');
52
+ return leaseArg.context.configStore.get<TEsphomeCredentials>(request.credentialKey);
53
+ }
54
+ if (!this.resolveCredentials) throw new Error('ESPHome provider credential references require resolveCredentials.');
55
+ return this.resolveCredentials(request);
56
+ });
57
+ try { credentials = captureEsphomeCredentials(await provision); }
58
+ catch { throw new Error('ESPHome credentials could not be resolved.'); }
59
+ }
60
+ leaseArg.assertOpen();
61
+ signal.throwIfAborted();
62
+ const client = new EsphomeClient(config, undefined, credentials);
63
+ let cleanupAttempted = false;
64
+ leaseArg.own(() => {
65
+ if (cleanupAttempted) return client.retryCleanup();
66
+ cleanupAttempted = true;
67
+ return client.destroy();
68
+ });
69
+ await client.connectLive(signal);
70
+ leaseArg.assertOpen();
71
+ signal.throwIfAborted();
72
+ const runtime = new EsphomeRuntime(client, leaseArg);
73
+ leaseArg.promoteToRuntime();
37
74
  return runtime;
38
- } catch (error) {
39
- await client.destroy();
40
- throw error;
41
- } finally {
42
- this.pendingClients.delete(client);
43
- }
75
+ });
44
76
  }
45
77
 
46
- public async destroy(): Promise<void> {
47
- this.destroyed = true;
48
- await Promise.all([
49
- ...[...this.runtimes].map((runtimeArg) => runtimeArg.destroy()),
50
- ...[...this.pendingClients].map((clientArg) => clientArg.destroy()),
51
- ]);
52
- }
78
+ public destroy(): Promise<void> { return this.owner.close(); }
79
+
80
+ public retryCleanup(): Promise<void> { return this.owner.retryCleanup(); }
53
81
  }
54
82
 
55
83
  export class HomeAssistantEsphomeIntegration extends EsphomeIntegration {}
@@ -57,22 +85,25 @@ export class HomeAssistantEsphomeIntegration extends EsphomeIntegration {}
57
85
  export class EsphomeRuntime implements IIntegrationRuntime {
58
86
  public readonly domain = 'esphome';
59
87
 
60
- constructor(private readonly client: EsphomeClient, private readonly onDestroy?: () => void) {}
88
+ constructor(private readonly client: EsphomeClient, private readonly lease?: INativeSetupLease) {}
61
89
 
62
90
  public isHealthy(): boolean {
63
91
  return this.client.isHealthy();
64
92
  }
65
93
 
66
94
  public async devices(signalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
95
+ this.lease?.assertOpen();
67
96
  signalArg?.throwIfAborted();
68
97
  return EsphomeMapper.toDevices(await this.client.getSnapshot());
69
98
  }
70
99
 
71
100
  public async entities(): Promise<IIntegrationEntity[]> {
101
+ this.lease?.assertOpen();
72
102
  return EsphomeMapper.toEntities(await this.client.getSnapshot());
73
103
  }
74
104
 
75
105
  public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
106
+ this.lease?.assertOpen();
76
107
  signalArg?.throwIfAborted();
77
108
  const unsubscribe = this.client.onEvent((eventArg) => {
78
109
  handlerArg({
@@ -83,14 +114,12 @@ export class EsphomeRuntime implements IIntegrationRuntime {
83
114
  data: eventArg.data,
84
115
  timestamp: eventArg.timestamp || Date.now(),
85
116
  });
86
- });
87
- const cleanup = () => { unsubscribe(); signalArg?.removeEventListener('abort', cleanup); };
88
- signalArg?.addEventListener('abort', cleanup, { once: true });
89
- if (signalArg?.aborted) cleanup();
90
- return async () => cleanup();
117
+ }, signalArg);
118
+ return async () => unsubscribe();
91
119
  }
92
120
 
93
121
  public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
122
+ this.lease?.assertOpen();
94
123
  if (!requestArg.target.entityId && !requestArg.target.deviceId) return { success: false, error: 'ESPHome service calls require an explicit entity or device target.' };
95
124
  const command = EsphomeMapper.commandForService(await this.client.getSnapshot(), requestArg);
96
125
  if (!command) return { success: false, error: `ESPHome service ${requestArg.domain}.${requestArg.service} has no unique supported native command target.` };
@@ -98,6 +127,7 @@ export class EsphomeRuntime implements IIntegrationRuntime {
98
127
  }
99
128
 
100
129
  public async writeFeature(requestArg: IDeviceFeatureWriteRequest, signalArg?: AbortSignal): Promise<IServiceCallResult> {
130
+ this.lease?.assertOpen();
101
131
  signalArg?.throwIfAborted();
102
132
  if (requestArg.device.integrationDomain !== this.domain) return { success: false, error: 'ESPHome feature writes require an ESPHome device target.' };
103
133
  const command = EsphomeMapper.commandForFeature(await this.client.getSnapshot(), requestArg.device.id, requestArg.feature.id, requestArg.value);
@@ -105,8 +135,5 @@ export class EsphomeRuntime implements IIntegrationRuntime {
105
135
  return this.client.sendCommand(command, signalArg);
106
136
  }
107
137
 
108
- public async destroy(): Promise<void> {
109
- await this.client.destroy();
110
- this.onDestroy?.();
111
- }
138
+ public destroy(): Promise<void> { return this.lease ? this.lease.close() : this.client.destroy(); }
112
139
  }