@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,14 @@
1
1
  import * as plugins from '../../plugins.js';
2
+ import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
3
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
4
+ import type { INativeIntegrationSetupContext } from '../../core/types.js';
2
5
  import type * as homeAssistantWebsocket from 'home-assistant-js-websocket';
3
6
  import type {
4
7
  IHomeAssistantHomeassistantConfig,
5
8
  IHomeAssistantClient,
6
9
  IHomeAssistantEntityRegistryForDisplay,
7
10
  IHomeAssistantNormalizedConfig,
11
+ IHomeAssistantOperationSession,
8
12
  IHomeAssistantServiceCall,
9
13
  IHomeAssistantWebsocketApi,
10
14
  IHomeAssistantTargetScope,
@@ -18,6 +22,27 @@ const maximumTimerMs = 2_147_483_647;
18
22
  const reconnectRefreshBackoffBaseMs = 1000;
19
23
  const reconnectRefreshBackoffMaximumMs = 30_000;
20
24
 
25
+ interface IHomeAssistantClientOwnership {
26
+ readonly owner: NativeIntegrationOwner;
27
+ readonly lease: INativeSetupLease;
28
+ }
29
+
30
+ interface IHomeAssistantCleanup {
31
+ readonly close: () => Promise<void>;
32
+ readonly connection: boolean;
33
+ status: 'idle' | 'pending' | 'closed' | 'failed';
34
+ promise?: Promise<void>;
35
+ }
36
+
37
+ interface IHomeAssistantSubscription {
38
+ readonly handler: () => void | Promise<void>;
39
+ readonly pending: Set<Promise<unknown>>;
40
+ readonly failures: Set<unknown>;
41
+ readonly removeAbortListener: () => void;
42
+ active: boolean;
43
+ closePromise?: Promise<void>;
44
+ }
45
+
21
46
  type TConnectionGenerationResult<TResult> =
22
47
  | { status: 'completed'; value: TResult }
23
48
  | { status: 'changed' };
@@ -148,7 +173,13 @@ export const normalizeHomeAssistantConfig = (
148
173
 
149
174
  export class HomeAssistantClient implements IHomeAssistantClient {
150
175
  private readonly config: IHomeAssistantNormalizedConfig;
151
- private readonly handlers = new Set<() => void>();
176
+ private readonly context: INativeAdmissionContext;
177
+ private readonly owner: NativeIntegrationOwner;
178
+ private readonly standalone: boolean;
179
+ private lease?: INativeSetupLease;
180
+ private initStarted = false;
181
+ private ownedDestroyPromise?: Promise<void>;
182
+ private readonly handlers = new Set<IHomeAssistantSubscription>();
152
183
  private readonly lifetimeAbortController = new AbortController();
153
184
  private readonly ownedSockets = new Set<homeAssistantWebsocket.HaWebSocket>();
154
185
  private connection?: homeAssistantWebsocket.Connection;
@@ -158,7 +189,9 @@ export class HomeAssistantClient implements IHomeAssistantClient {
158
189
  private entityPlatforms: Record<string, string> = {};
159
190
  private services = new Set<string>();
160
191
  private readonly catalogueUnsubscribes = new Set<() => Promise<void>>();
161
- private readonly serviceSettlements = new Set<Promise<void>>();
192
+ private readonly actualWork = new Set<Promise<unknown>>();
193
+ private readonly cleanup = new Set<IHomeAssistantCleanup>();
194
+ private retryPromise?: Promise<void>;
162
195
  private connectionReady = false;
163
196
  private snapshotReady = false;
164
197
  private requiredEntitiesPresent = true;
@@ -192,12 +225,12 @@ export class HomeAssistantClient implements IHomeAssistantClient {
192
225
  return;
193
226
  }
194
227
  const initializationDeadline = Date.now() + this.config.initialSnapshotTimeoutMs;
195
- void this.refreshAuthoritativeSnapshot(
228
+ void this.trackWork(this.refreshAuthoritativeSnapshot(
196
229
  connection,
197
230
  generation,
198
231
  this.lifetimeAbortController.signal,
199
232
  initializationDeadline
200
- ).then((refreshedArg) => {
233
+ )).then((refreshedArg) => {
201
234
  if (
202
235
  refreshedArg
203
236
  && !this.isStopping()
@@ -228,22 +261,70 @@ export class HomeAssistantClient implements IHomeAssistantClient {
228
261
 
229
262
  constructor(
230
263
  configArg: IHomeAssistantHomeassistantConfig,
231
- private readonly api: IHomeAssistantWebsocketApi,
232
- private readonly webSocketFactory?: THomeAssistantWebSocketFactory
264
+ contextArg: INativeIntegrationSetupContext,
265
+ private readonly api: IHomeAssistantWebsocketApi = plugins.homeAssistantWebsocket,
266
+ private readonly webSocketFactory?: THomeAssistantWebSocketFactory,
267
+ ownershipArg?: IHomeAssistantClientOwnership
233
268
  ) {
234
269
  this.config = normalizeHomeAssistantConfig(configArg);
270
+ this.context = new NativeHomeAdmission('homeassistant').admit(contextArg, this.config.homeId);
271
+ if (ownershipArg && !(ownershipArg.owner instanceof NativeIntegrationOwner)) throw new TypeError('Home Assistant requires an actual native owner.');
272
+ this.standalone = ownershipArg === undefined;
273
+ this.owner = ownershipArg?.owner ?? new NativeIntegrationOwner('homeassistant', { homeId: this.context.homeId });
274
+ this.owner.admission.admit(this.context, this.config.homeId);
275
+ if (ownershipArg) {
276
+ ownershipArg.lease.assertOpen();
277
+ if (ownershipArg.lease.context.homeId !== this.context.homeId || ownershipArg.lease.context.configStore !== this.context.configStore) {
278
+ throw new Error('Home Assistant client ownership conflicts with its admitted context.');
279
+ }
280
+ this.lease = ownershipArg.lease;
281
+ }
235
282
  this.requiredEntitiesPresent = !this.config.requireEntityIds;
236
283
  }
237
284
 
238
285
  public get connected(): boolean {
239
- return this.connectionReady
286
+ return !this.lease?.signal.aborted
287
+ && !this.isStopping()
288
+ && this.connectionReady
240
289
  && this.snapshotReady
241
290
  && this.requiredEntitiesPresent
242
291
  && (!this.config.requireEntityRegistry || [...this.config.entityIds!]
243
292
  .every((entityIdArg) => Boolean(this.entityPlatforms[entityIdArg])));
244
293
  }
245
294
 
246
- public async init(abortSignalArg?: AbortSignal): Promise<void> {
295
+ public init(abortSignalArg?: AbortSignal): Promise<void> {
296
+ try {
297
+ if (this.initStarted || this.lifecycle !== 'idle') throw new Error('Home Assistant client cannot initialize again.');
298
+ if (abortSignalArg !== undefined && !(abortSignalArg instanceof AbortSignal)) throw new TypeError('Home Assistant cancellation must be an AbortSignal.');
299
+ this.initStarted = true;
300
+ if (!this.standalone) return this.initializeOwned(AbortSignal.any([this.lease!.signal, ...(abortSignalArg ? [abortSignalArg] : [])]));
301
+ const signal = AbortSignal.any([...(this.context.abortSignal ? [this.context.abortSignal] : []), ...(abortSignalArg ? [abortSignalArg] : [])]);
302
+ return this.owner.runSetup({ ...this.context, abortSignal: signal }, this.config.homeId, async (leaseArg) => {
303
+ this.lease = leaseArg;
304
+ let cleanupStarted = false;
305
+ leaseArg.own(() => {
306
+ const retry = cleanupStarted;
307
+ cleanupStarted = true;
308
+ return retry ? this.retryOwnedCleanup() : this.destroyOwned();
309
+ });
310
+ await this.initializeOwned(leaseArg.signal);
311
+ leaseArg.assertOpen();
312
+ leaseArg.promoteToRuntime();
313
+ });
314
+ } catch (errorArg) { return Promise.reject(errorArg); }
315
+ }
316
+
317
+ private initializeOwned(signalArg: AbortSignal): Promise<void> {
318
+ // Cleanup runs after actual initialization settles, avoiding setup/close self-wait.
319
+ const work = this.trackWork(Promise.resolve().then(() => this.initialize(signalArg)));
320
+ return work.catch(async (errorArg: unknown) => {
321
+ try { await this.destroyOwned(); }
322
+ catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'Home Assistant initialization and cleanup both failed.'); }
323
+ throw errorArg;
324
+ });
325
+ }
326
+
327
+ private async initialize(abortSignalArg?: AbortSignal): Promise<void> {
247
328
  if (this.lifecycle !== 'idle') {
248
329
  throw new Error(
249
330
  'Home Assistant client cannot initialize from lifecycle state '
@@ -314,16 +395,6 @@ export class HomeAssistantClient implements IHomeAssistantClient {
314
395
  }
315
396
  throw new Error('Home Assistant client was destroyed during initialization.');
316
397
  }
317
- } catch (errorArg) {
318
- try {
319
- await this.destroy();
320
- } catch (cleanupErrorArg) {
321
- throw new AggregateError(
322
- [errorArg, cleanupErrorArg],
323
- 'Home Assistant initialization and cleanup both failed.'
324
- );
325
- }
326
- throw errorArg;
327
398
  } finally {
328
399
  abortSignalArg?.removeEventListener('abort', propagateAbort);
329
400
  if (this.setupAbortController === setupAbortController) {
@@ -348,25 +419,106 @@ export class HomeAssistantClient implements IHomeAssistantClient {
348
419
  return { homeId: this.config.homeId, instanceId: this.config.instanceId };
349
420
  }
350
421
 
351
- public subscribe(handlerArg: () => void): () => void {
422
+ public subscribe(handlerArg: () => void | Promise<void>, abortSignalArg?: AbortSignal): () => Promise<void> {
423
+ this.lease?.assertOpen();
352
424
  if (this.isStopping()) {
353
425
  throw new Error('Home Assistant client is destroyed.');
354
426
  }
355
- this.handlers.add(handlerArg);
356
- let subscribed = true;
357
- return () => {
358
- if (!subscribed) {
359
- return;
427
+ if (typeof handlerArg !== 'function' || (abortSignalArg !== undefined && !(abortSignalArg instanceof AbortSignal))) {
428
+ throw new TypeError('Home Assistant subscriber and cancellation must be a function and AbortSignal.');
429
+ }
430
+ abortSignalArg?.throwIfAborted();
431
+ const onAbort = (): void => { void unsubscribe().catch(() => undefined); };
432
+ const subscription: IHomeAssistantSubscription = {
433
+ handler: handlerArg, active: true, pending: new Set(), failures: new Set(),
434
+ removeAbortListener: () => abortSignalArg?.removeEventListener('abort', onAbort),
435
+ };
436
+ const unsubscribe = (): Promise<void> => {
437
+ if (!subscription.closePromise) {
438
+ subscription.active = false;
439
+ subscription.removeAbortListener();
440
+ subscription.closePromise = Promise.resolve().then(async () => {
441
+ await Promise.allSettled([...subscription.pending]);
442
+ if (subscription.failures.size) throw new AggregateError([...subscription.failures], 'Home Assistant subscriber cleanup failed.');
443
+ this.handlers.delete(subscription);
444
+ });
360
445
  }
361
- subscribed = false;
362
- this.handlers.delete(handlerArg);
446
+ return subscription.closePromise;
363
447
  };
448
+ this.handlers.add(subscription);
449
+ abortSignalArg?.addEventListener('abort', onAbort, { once: true });
450
+ if (abortSignalArg?.aborted) onAbort();
451
+ return unsubscribe;
364
452
  }
365
453
 
366
- public async callService(
454
+ /** Caller cancellation may settle first; the owner retains the actual continuation. */
455
+ public withOperation<TResult>(
456
+ workArg: (sessionArg: IHomeAssistantOperationSession) => Promise<TResult>,
457
+ abortSignalArg?: AbortSignal
458
+ ): Promise<TResult> {
459
+ try {
460
+ const admitted = this.admitOperation(workArg, abortSignalArg);
461
+ return this.raceAbort(admitted.promise, admitted.signal);
462
+ } catch (errorArg) { return Promise.reject(errorArg); }
463
+ }
464
+
465
+ private admitOperation<TResult>(
466
+ workArg: (sessionArg: IHomeAssistantOperationSession) => Promise<TResult>,
467
+ abortSignalArg?: AbortSignal
468
+ ): { promise: Promise<TResult>; signal: AbortSignal } {
469
+ this.lease?.assertOpen();
470
+ if (this.isStopping()) throw new Error('Home Assistant client is destroyed.');
471
+ if (typeof workArg !== 'function' || (abortSignalArg !== undefined && !(abortSignalArg instanceof AbortSignal))) {
472
+ throw new TypeError('Home Assistant operation and cancellation must be a function and AbortSignal.');
473
+ }
474
+ const cancellation = new AbortController();
475
+ const signal = AbortSignal.any([this.lifetimeAbortController.signal, cancellation.signal,
476
+ ...(this.lease ? [this.lease.signal] : []), ...(this.context.abortSignal ? [this.context.abortSignal] : []),
477
+ ...(abortSignalArg ? [abortSignalArg] : [])]);
478
+ signal.throwIfAborted();
479
+ const operation = this.owner.runOperation({ ...this.context, abortSignal: signal }, this.config.homeId, async (leaseArg) => {
480
+ const pending = new Set<Promise<unknown>>();
481
+ let active = true;
482
+ const session: IHomeAssistantOperationSession = Object.freeze({
483
+ signal: leaseArg.signal,
484
+ callService: (requestArg: IHomeAssistantServiceCall): Promise<unknown> => {
485
+ try {
486
+ leaseArg.assertOpen();
487
+ if (!active) throw new Error('Home Assistant service session is closed.');
488
+ return this.dispatchService(requestArg, leaseArg.signal, pending, (reasonArg) => cancellation.abort(reasonArg));
489
+ } catch (errorArg) { return Promise.reject(errorArg); }
490
+ },
491
+ });
492
+ try { return await workArg(session); }
493
+ finally {
494
+ active = false;
495
+ // An abort race is not settlement of the effect dispatched to the foreign API.
496
+ await Promise.allSettled([...pending]);
497
+ }
498
+ });
499
+ return { promise: this.trackWork(operation), signal };
500
+ }
501
+
502
+ public callService(
367
503
  requestArg: IHomeAssistantServiceCall,
368
504
  abortSignalArg?: AbortSignal
369
505
  ): Promise<unknown> {
506
+ try {
507
+ homeAssistantRecord(requestArg);
508
+ homeAssistantRecord(requestArg.target);
509
+ if (requestArg.data !== undefined) homeAssistantRecord(requestArg.data);
510
+ const request = structuredClone(requestArg);
511
+ return this.withOperation((sessionArg) => sessionArg.callService(request), abortSignalArg);
512
+ } catch (errorArg) { return Promise.reject(errorArg); }
513
+ }
514
+
515
+ private async dispatchService(
516
+ requestArg: IHomeAssistantServiceCall,
517
+ abortSignalArg: AbortSignal,
518
+ pendingArg: Set<Promise<unknown>>,
519
+ cancelOperationArg: (reasonArg: Error) => void
520
+ ): Promise<unknown> {
521
+ this.lease?.assertOpen();
370
522
  if (abortSignalArg?.aborted) {
371
523
  throw abortError(abortSignalArg);
372
524
  }
@@ -410,26 +562,28 @@ export class HomeAssistantClient implements IHomeAssistantClient {
410
562
  throw abortError(operationAbortController.signal);
411
563
  }
412
564
  let timeout: ReturnType<typeof setTimeout> | undefined;
413
- let resolveSettlement!: () => void;
414
- const settlement = new Promise<void>((resolve) => { resolveSettlement = resolve; });
415
- this.serviceSettlements.add(settlement);
416
565
  this.activeServiceCallCount++;
417
566
  try {
418
- const operationPromise = this.api.callService(
567
+ const operationPromise = this.trackWork(this.api.callService(
419
568
  connection,
420
569
  request.domain,
421
570
  request.service,
422
571
  request.data,
423
572
  request.target
424
- );
573
+ ));
574
+ pendingArg.add(operationPromise);
575
+ const settled = (): void => { pendingArg.delete(operationPromise); };
576
+ void operationPromise.then(settled, settled);
425
577
  operationAbortController.signal.addEventListener('abort', cancelPendingCommand, { once: true });
426
578
  if (operationAbortController.signal.aborted) {
427
579
  cancelPendingCommand();
428
580
  }
429
581
  timeout = setTimeout(() => {
430
- operationAbortController.abort(new Error(
582
+ const reason = new Error(
431
583
  `Home Assistant service call did not complete within ${this.config.serviceCallTimeoutMs} ms.`
432
- ));
584
+ );
585
+ operationAbortController.abort(reason);
586
+ cancelOperationArg(reason);
433
587
  }, this.config.serviceCallTimeoutMs);
434
588
  return await this.raceAbort(operationPromise, operationAbortController.signal);
435
589
  } finally {
@@ -440,38 +594,73 @@ export class HomeAssistantClient implements IHomeAssistantClient {
440
594
  abortSignalArg?.removeEventListener('abort', propagateAbort);
441
595
  operationAbortController.signal.removeEventListener('abort', cancelPendingCommand);
442
596
  this.lifetimeAbortController.signal.removeEventListener('abort', propagateLifetimeAbort);
443
- resolveSettlement();
444
- this.serviceSettlements.delete(settlement);
445
597
  }
446
598
  }
447
599
 
448
600
  public destroy(): Promise<void> {
449
- if (this.destroyPromise) {
450
- return this.destroyPromise;
451
- }
452
- if (this.lifecycle === 'destroyed' && !this.stateUnsubscribe && this.catalogueUnsubscribes.size === 0 && this.ownedSockets.size === 0) {
453
- return Promise.resolve();
454
- }
455
- this.lifecycle = 'destroying';
456
- const destroyPromise = this.performDestroy();
457
- this.destroyPromise = destroyPromise;
458
- void destroyPromise.finally(() => {
459
- if (this.destroyPromise === destroyPromise) {
460
- this.destroyPromise = undefined;
461
- }
462
- this.lifecycle = 'destroyed';
463
- }).catch(() => undefined);
464
- return destroyPromise;
601
+ if (!this.destroyPromise) {
602
+ this.lifecycle = 'destroying';
603
+ this.destroyPromise = Promise.resolve().then(() => this.standalone ? this.owner.close() : this.destroyOwned());
604
+ this.fenceSubscribers();
605
+ this.setupAbortController?.abort(new Error('Home Assistant client was destroyed.'));
606
+ this.lifetimeAbortController.abort(new Error('Home Assistant client was destroyed.'));
607
+ }
608
+ return this.destroyPromise;
609
+ }
610
+
611
+ private destroyOwned(): Promise<void> {
612
+ if (!this.ownedDestroyPromise) {
613
+ this.lifecycle = 'destroying';
614
+ this.ownedDestroyPromise = Promise.resolve().then(() => this.performDestroy(false));
615
+ this.fenceSubscribers();
616
+ this.setupAbortController?.abort(new Error('Home Assistant client was destroyed.'));
617
+ this.lifetimeAbortController.abort(new Error('Home Assistant client was destroyed.'));
618
+ }
619
+ return this.ownedDestroyPromise;
620
+ }
621
+
622
+ public retryCleanup(): Promise<void> {
623
+ if (!this.destroyPromise && !this.ownedDestroyPromise) return Promise.reject(new Error('Home Assistant cleanup retry requires destruction.'));
624
+ return this.standalone ? this.owner.retryCleanup() : this.retryOwnedCleanup();
465
625
  }
466
626
 
467
- private async performDestroy(): Promise<void> {
627
+ private retryOwnedCleanup(): Promise<void> {
628
+ if (!this.retryPromise) {
629
+ const retry = Promise.resolve().then(async () => {
630
+ await this.ownedDestroyPromise?.catch(() => undefined);
631
+ await this.performDestroy(true);
632
+ });
633
+ this.retryPromise = retry;
634
+ const settled = (): void => { this.retryPromise = undefined; };
635
+ void retry.then(settled, settled);
636
+ }
637
+ return this.retryPromise;
638
+ }
639
+
640
+ private trackWork<TResult>(promiseArg: Promise<TResult>): Promise<TResult> {
641
+ this.actualWork.add(promiseArg);
642
+ const settled = (): void => { this.actualWork.delete(promiseArg); };
643
+ void promiseArg.then(settled, settled);
644
+ return promiseArg;
645
+ }
646
+
647
+ private ownCleanup(closeArg: () => Promise<void>, connectionArg = false): void {
648
+ this.cleanup.add({ close: closeArg, connection: connectionArg, status: 'idle' });
649
+ }
650
+
651
+ private closeResource(resourceArg: IHomeAssistantCleanup, retryArg: boolean): Promise<void> {
652
+ if (retryArg && resourceArg.status === 'failed') resourceArg.promise = undefined;
653
+ if (!resourceArg.promise) {
654
+ resourceArg.status = 'pending';
655
+ resourceArg.promise = this.trackWork(Promise.resolve().then(resourceArg.close));
656
+ void resourceArg.promise.then(() => { resourceArg.status = 'closed'; }, () => { resourceArg.status = 'failed'; });
657
+ }
658
+ return resourceArg.promise;
659
+ }
660
+
661
+ private async performDestroy(retryArg: boolean): Promise<void> {
468
662
  this.clearReconnectRefreshTimer();
469
663
  this.reconnectRefreshFailureCount = 0;
470
- this.setupAbortController?.abort(new Error('Home Assistant client was destroyed.'));
471
- this.setupAbortController = undefined;
472
- this.lifetimeAbortController.abort(new Error('Home Assistant client was destroyed.'));
473
-
474
- const unsubscribe = this.stateUnsubscribe;
475
664
  const connection = this.connection;
476
665
  const connectionSocket = connection?.socket;
477
666
  this.connection = undefined;
@@ -479,40 +668,56 @@ export class HomeAssistantClient implements IHomeAssistantClient {
479
668
  connection.removeEventListener('ready', this.handleReady);
480
669
  connection.removeEventListener('disconnected', this.handleDisconnected);
481
670
  }
671
+ this.advanceConnectionGeneration();
672
+ this.connectionReady = false;
673
+ this.snapshotReady = false;
674
+ this.requiredEntitiesPresent = false;
675
+ this.bufferedStateEvents.clear();
676
+ this.upstreamSnapshot = {};
677
+ this.snapshot = {};
678
+ this.entityPlatforms = {};
679
+ this.services.clear();
680
+ this.fenceSubscribers();
681
+ if (retryArg) for (const subscription of this.handlers) subscription.failures.clear();
682
+
683
+ const failures: unknown[] = [];
684
+ const attempted = new Set<IHomeAssistantCleanup>();
685
+ const closeGroup = async (connectionsArg: boolean): Promise<void> => {
686
+ const entries = [...this.cleanup].filter((entry) => entry.connection === connectionsArg && !attempted.has(entry));
687
+ entries.forEach((entry) => attempted.add(entry));
688
+ const results = await Promise.allSettled(entries.map((entry) => {
689
+ const actual = this.closeResource(entry, retryArg);
690
+ // A missing unsubscribe acknowledgement must not prevent sibling socket close.
691
+ // The actual promise remains in the ledger after the deadline rejects.
692
+ return connectionsArg ? actual : this.settleStateUnsubscribe(() => actual);
693
+ }));
694
+ for (const result of results) if (result.status === 'rejected') failures.push(result.reason as unknown);
695
+ };
482
696
  try {
483
- await Promise.all([...this.serviceSettlements]);
484
- for (const unsubscribeCatalogue of [...this.catalogueUnsubscribes]) {
485
- await this.settleStateUnsubscribe(unsubscribeCatalogue);
486
- this.catalogueUnsubscribes.delete(unsubscribeCatalogue);
487
- }
488
- if (unsubscribe) {
489
- await this.settleStateUnsubscribe(unsubscribe);
490
- if (this.stateUnsubscribe === unsubscribe) {
491
- this.stateUnsubscribe = undefined;
492
- }
697
+ await closeGroup(false);
698
+ await closeGroup(true);
699
+ const sockets = await Promise.allSettled([this.drainOwnedSockets(connectionSocket)]);
700
+ for (const result of sockets) if (result.status === 'rejected') failures.push(result.reason as unknown);
701
+ // Foreign continuations can produce late connections or unsubscribe handles.
702
+ // Join real settlement, then close every newly adopted handle, without replay.
703
+ while (this.actualWork.size || [...this.cleanup].some((entry) => !attempted.has(entry))) {
704
+ await Promise.allSettled([...this.actualWork]);
705
+ await closeGroup(false);
706
+ await closeGroup(true);
707
+ const lateSockets = await Promise.allSettled([this.drainOwnedSockets()]);
708
+ for (const result of lateSockets) if (result.status === 'rejected') failures.push(result.reason as unknown);
493
709
  }
494
- } finally {
495
- if (connection) {
496
- if (this.activeServiceCallCount > 0) {
497
- connection.reconnect(true);
498
- }
499
- connection.close();
710
+ for (const entry of [...this.cleanup]) if (entry.status === 'closed') this.cleanup.delete(entry);
711
+ if (!this.cleanup.size) {
712
+ this.stateUnsubscribe = undefined;
713
+ this.catalogueUnsubscribes.clear();
500
714
  }
501
- try {
502
- await this.drainOwnedSockets(connectionSocket);
503
- } finally {
504
- this.advanceConnectionGeneration();
505
- this.connectionReady = false;
506
- this.snapshotReady = false;
507
- this.requiredEntitiesPresent = false;
508
- this.bufferedStateEvents.clear();
509
- this.upstreamSnapshot = {};
510
- this.snapshot = {};
511
- this.entityPlatforms = {};
512
- this.services.clear();
513
- this.handlers.clear();
715
+ for (const subscription of [...this.handlers]) {
716
+ failures.push(...subscription.failures);
717
+ if (!subscription.failures.size) this.handlers.delete(subscription);
514
718
  }
515
- }
719
+ if (failures.length) throw new AggregateError([...new Set(failures)], 'Home Assistant cleanup failed; handles remain owned for explicit retry.');
720
+ } finally { this.lifecycle = 'destroyed'; }
516
721
  }
517
722
 
518
723
  private async createConnection(
@@ -526,7 +731,7 @@ export class HomeAssistantClient implements IHomeAssistantClient {
526
731
  throw abortError(abortSignalArg);
527
732
  }
528
733
  try {
529
- const connectionPromise = this.api.createConnection({
734
+ const connectionPromise = this.trackWork(this.api.createConnection({
530
735
  auth: authArg,
531
736
  setupRetry: 0,
532
737
  createSocket: (optionsArg) => this.createManagedSocket(
@@ -535,7 +740,10 @@ export class HomeAssistantClient implements IHomeAssistantClient {
535
740
  abortSignalArg,
536
741
  initializationDeadlineArg
537
742
  ),
538
- });
743
+ }).then((connectionArg) => {
744
+ this.ownCleanup(async () => { connectionArg.close(); }, true);
745
+ return connectionArg;
746
+ }));
539
747
  try {
540
748
  return await this.raceAbortWithDeadline(
541
749
  connectionPromise,
@@ -545,9 +753,6 @@ export class HomeAssistantClient implements IHomeAssistantClient {
545
753
  );
546
754
  } catch (errorArg) {
547
755
  this.closeOwnedSockets();
548
- void connectionPromise.then((connectionArg) => {
549
- connectionArg.close();
550
- }).catch(() => undefined);
551
756
  throw errorArg;
552
757
  }
553
758
  } catch (errorArg) {
@@ -584,14 +789,16 @@ export class HomeAssistantClient implements IHomeAssistantClient {
584
789
  ? `Home Assistant connection and authentication did not complete within ${this.config.initialSnapshotTimeoutMs} ms.`
585
790
  : `Home Assistant reconnect authentication did not complete within ${this.config.initialSnapshotTimeoutMs} ms.`;
586
791
 
792
+ abortSignal.throwIfAborted();
793
+ if (this.isStopping()) throw new Error('Home Assistant client was destroyed.');
587
794
  if (this.webSocketFactory) {
588
795
  return this.createOwnedSocket(authArg, abortSignal, deadline, timeoutMessage);
589
796
  }
590
797
 
591
- const socketPromise = this.api.createSocket({
798
+ const socketPromise = this.trackWork(this.api.createSocket({
592
799
  ...optionsArg,
593
800
  setupRetry: 0,
594
- });
801
+ }).then((socketArg) => { this.trackOwnedSocket(socketArg); return socketArg; }));
595
802
  try {
596
803
  const socket = await this.raceAbortWithDeadline(
597
804
  socketPromise,
@@ -606,12 +813,8 @@ export class HomeAssistantClient implements IHomeAssistantClient {
606
813
  }
607
814
  throw new Error('Home Assistant client discarded a late socket after destruction.');
608
815
  }
609
- this.trackOwnedSocket(socket);
610
816
  return socket;
611
817
  } catch (errorArg) {
612
- void socketPromise.then((socketArg) => {
613
- socketArg.close();
614
- }).catch(() => undefined);
615
818
  throw errorArg;
616
819
  }
617
820
  }
@@ -763,7 +966,7 @@ export class HomeAssistantClient implements IHomeAssistantClient {
763
966
  private async drainOwnedSockets(connectionSocketArg?: homeAssistantWebsocket.HaWebSocket): Promise<void> {
764
967
  const sockets = new Set(this.ownedSockets);
765
968
  if (connectionSocketArg) sockets.add(connectionSocketArg);
766
- await Promise.all([...sockets].map((socket) => new Promise<void>((resolve, reject) => {
969
+ const results = await Promise.allSettled([...sockets].map((socket) => new Promise<void>((resolve, reject) => {
767
970
  if (socket.readyState === 3) { this.ownedSockets.delete(socket); resolve(); return; }
768
971
  const finish = (error?: Error): void => {
769
972
  clearTimeout(timeout);
@@ -777,6 +980,8 @@ export class HomeAssistantClient implements IHomeAssistantClient {
777
980
  try { if (socket.readyState !== 2) socket.close(); }
778
981
  catch (error) { finish(error instanceof Error ? error : new Error('Home Assistant socket close failed.')); }
779
982
  })));
983
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected').map((result) => result.reason as unknown);
984
+ if (failures.length) throw new AggregateError(failures, 'Home Assistant socket cleanup failed.');
780
985
  }
781
986
 
782
987
  private async raceAbort<T>(
@@ -904,34 +1109,29 @@ export class HomeAssistantClient implements IHomeAssistantClient {
904
1109
  abortSignalArg: AbortSignal,
905
1110
  initializationDeadlineArg: number
906
1111
  ): Promise<void> {
1112
+ const adopt = (promiseArg: Promise<() => Promise<void>>): Promise<() => Promise<void>> => this.trackWork(promiseArg.then((unsubscribeArg) => {
1113
+ this.ownCleanup(unsubscribeArg);
1114
+ return unsubscribeArg;
1115
+ }));
907
1116
  const unsubscribe = await this.raceAbortWithDeadline(
908
- connectionArg.subscribeEvents<homeAssistantWebsocket.StateChangedEvent>((eventArg) => {
1117
+ adopt(connectionArg.subscribeEvents<homeAssistantWebsocket.StateChangedEvent>((eventArg) => {
909
1118
  this.handleStateChangedEvent(eventArg);
910
- }, 'state_changed'),
1119
+ }, 'state_changed')),
911
1120
  abortSignalArg,
912
1121
  initializationDeadlineArg,
913
1122
  `Home Assistant did not acknowledge the state subscription within ${this.config.initialSnapshotTimeoutMs} ms.`
914
1123
  );
915
- if (this.wasDestroyed() || abortSignalArg.aborted) {
916
- await this.settleStateUnsubscribe(unsubscribe).catch(() => undefined);
917
- if (abortSignalArg.aborted) {
918
- throw abortError(abortSignalArg);
919
- }
920
- throw new Error('Home Assistant client was destroyed during state subscription.');
921
- }
1124
+ abortSignalArg.throwIfAborted();
1125
+ if (this.wasDestroyed()) throw new Error('Home Assistant client was destroyed during state subscription.');
922
1126
  this.stateUnsubscribe = unsubscribe;
923
1127
  for (const eventType of ['service_registered', 'service_removed', 'entity_registry_updated']) {
924
- const unsubscribePromise = connectionArg.subscribeEvents(() => this.handleReady(), eventType);
925
- try {
926
- const unsubscribeCatalogue = await this.raceAbortWithDeadline(
927
- unsubscribePromise, abortSignalArg, initializationDeadlineArg,
928
- 'Home Assistant did not acknowledge its catalogue subscription before the startup deadline.'
929
- );
930
- this.catalogueUnsubscribes.add(unsubscribeCatalogue);
931
- } catch (errorArg) {
932
- void unsubscribePromise.then((lateUnsubscribe) => this.settleStateUnsubscribe(lateUnsubscribe)).catch(() => undefined);
933
- throw errorArg;
934
- }
1128
+ abortSignalArg.throwIfAborted();
1129
+ const unsubscribeCatalogue = await this.raceAbortWithDeadline(
1130
+ adopt(connectionArg.subscribeEvents(() => this.handleReady(), eventType)),
1131
+ abortSignalArg, initializationDeadlineArg,
1132
+ 'Home Assistant did not acknowledge its catalogue subscription before the startup deadline.'
1133
+ );
1134
+ this.catalogueUnsubscribes.add(unsubscribeCatalogue);
935
1135
  }
936
1136
  }
937
1137
 
@@ -1266,7 +1466,7 @@ export class HomeAssistantClient implements IHomeAssistantClient {
1266
1466
  return { status: 'changed' } as const;
1267
1467
  }
1268
1468
  try {
1269
- const value = await operationArg();
1469
+ const value = await this.trackWork(operationArg());
1270
1470
  if (generationArg !== this.connectionGeneration || !this.connectionReady) {
1271
1471
  return { status: 'changed' } as const;
1272
1472
  }
@@ -1333,12 +1533,40 @@ export class HomeAssistantClient implements IHomeAssistantClient {
1333
1533
  }
1334
1534
 
1335
1535
  private emitChange(): void {
1336
- for (const handler of [...this.handlers]) {
1536
+ if (this.isStopping()) return;
1537
+ for (const subscription of this.handlers) {
1538
+ if (!subscription.active) continue;
1337
1539
  try {
1338
- handler();
1339
- } catch {
1340
- // Subscriber failures must not break connection lifecycle or other subscribers.
1540
+ const notification = this.admitOperation(async (sessionArg) => {
1541
+ if (!subscription.active || sessionArg.signal.aborted) return;
1542
+ try { await subscription.handler(); }
1543
+ catch (errorArg) {
1544
+ subscription.failures.add(errorArg);
1545
+ subscription.active = false;
1546
+ }
1547
+ }).promise;
1548
+ subscription.pending.add(notification);
1549
+ const settled = (): void => { subscription.pending.delete(notification); };
1550
+ void notification.then(settled, (errorArg: unknown) => {
1551
+ if (!this.isStopping() && !this.lease?.signal.aborted) {
1552
+ subscription.failures.add(errorArg);
1553
+ subscription.active = false;
1554
+ }
1555
+ settled();
1556
+ });
1557
+ } catch (errorArg) {
1558
+ if (!this.isStopping() && !this.lease?.signal.aborted) {
1559
+ subscription.failures.add(errorArg);
1560
+ subscription.active = false;
1561
+ }
1341
1562
  }
1342
1563
  }
1343
1564
  }
1565
+
1566
+ private fenceSubscribers(): void {
1567
+ for (const subscription of this.handlers) {
1568
+ subscription.active = false;
1569
+ subscription.removeAbortListener();
1570
+ }
1571
+ }
1344
1572
  }