@smarthome.exchange/integrations 2.0.0 → 3.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 (245) hide show
  1. package/changelog.md +35 -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 +6 -1
  40. package/dist_ts/integrations/hue/hue.classes.client.js +120 -45
  41. package/dist_ts/integrations/hue/hue.classes.configflow.js +16 -7
  42. package/dist_ts/integrations/hue/hue.classes.integration.d.ts +2 -0
  43. package/dist_ts/integrations/hue/hue.classes.integration.js +103 -30
  44. package/dist_ts/integrations/hue/hue.validation.d.ts +1 -0
  45. package/dist_ts/integrations/hue/hue.validation.js +14 -4
  46. package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
  47. package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
  48. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
  49. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
  50. package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
  51. package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
  52. package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
  53. package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
  54. package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
  55. package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
  56. package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
  57. package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
  58. package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
  59. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
  60. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
  61. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
  62. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
  63. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
  64. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
  65. package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
  66. package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
  67. package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
  68. package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
  69. package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
  70. package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
  71. package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
  72. package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
  73. package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
  74. package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
  75. package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
  76. package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
  77. package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
  78. package/dist_ts/integrations/shelly/shelly.types.js +11 -2
  79. package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
  80. package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
  81. package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
  82. package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
  83. package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
  84. package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
  85. package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
  86. package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
  87. package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
  88. package/dist_ts/integrations/sonos/sonos.config.js +23 -2
  89. package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
  90. package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
  91. package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
  92. package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
  93. package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
  94. package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
  95. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
  96. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
  97. package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
  98. package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
  99. package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
  100. package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
  101. package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
  102. package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
  103. package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
  104. package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
  105. package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
  106. package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
  107. package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
  108. package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
  109. package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
  110. package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
  111. package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
  112. package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
  113. package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
  114. package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
  115. package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
  116. package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
  117. package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
  118. package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
  119. package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
  120. package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
  121. package/dist_ts/integrations/webostv/webostv.types.js +1 -1
  122. package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
  123. package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
  124. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
  125. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
  126. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
  127. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
  128. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
  129. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
  130. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
  131. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
  132. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
  133. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
  134. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
  135. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
  136. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
  137. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
  138. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
  139. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
  140. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
  141. package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
  142. package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
  143. package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
  144. package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
  145. package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
  146. package/dist_ts/plugins.d.ts +2 -1
  147. package/dist_ts/plugins.js +3 -2
  148. package/package.json +8 -8
  149. package/readme.md +25 -6
  150. package/ts/00_commitinfo_data.ts +1 -1
  151. package/ts/core/classes.nativeintegrationowner.ts +5 -0
  152. package/ts/core/classes.runtimemanager.ts +11 -2
  153. package/ts/core/types.ts +2 -0
  154. package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
  155. package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
  156. package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
  157. package/ts/integrations/deconz/deconz.mapper.ts +22 -5
  158. package/ts/integrations/deconz/deconz.types.ts +49 -6
  159. package/ts/integrations/deconz/deconz.validation.ts +177 -4
  160. package/ts/integrations/deconz/readme.md +15 -1
  161. package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
  162. package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
  163. package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
  164. package/ts/integrations/esphome/esphome.discovery.ts +3 -12
  165. package/ts/integrations/esphome/esphome.mapper.ts +9 -7
  166. package/ts/integrations/esphome/esphome.types.ts +35 -6
  167. package/ts/integrations/esphome/esphome.validation.ts +66 -0
  168. package/ts/integrations/esphome/readme.md +20 -6
  169. package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
  170. package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
  171. package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
  172. package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
  173. package/ts/integrations/homeassistant/readme.md +22 -6
  174. package/ts/integrations/hue/hue.classes.client.ts +110 -41
  175. package/ts/integrations/hue/hue.classes.configflow.ts +12 -6
  176. package/ts/integrations/hue/hue.classes.integration.ts +92 -26
  177. package/ts/integrations/hue/hue.validation.ts +13 -3
  178. package/ts/integrations/hue/readme.md +3 -3
  179. package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
  180. package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
  181. package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
  182. package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
  183. package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
  184. package/ts/integrations/mqtt/mqtt.types.ts +52 -0
  185. package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
  186. package/ts/integrations/mqtt/readme.md +12 -3
  187. package/ts/integrations/samsungtv/readme.md +11 -5
  188. package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
  189. package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
  190. package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
  191. package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
  192. package/ts/integrations/shelly/readme.md +22 -7
  193. package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
  194. package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
  195. package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
  196. package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
  197. package/ts/integrations/shelly/shelly.mapper.ts +19 -20
  198. package/ts/integrations/shelly/shelly.rpc.ts +2 -0
  199. package/ts/integrations/shelly/shelly.types.ts +50 -2
  200. package/ts/integrations/shelly/shelly.validation.ts +76 -0
  201. package/ts/integrations/sonos/readme.md +117 -38
  202. package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
  203. package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
  204. package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
  205. package/ts/integrations/sonos/sonos.config.ts +24 -2
  206. package/ts/integrations/sonos/sonos.mapper.ts +13 -8
  207. package/ts/integrations/sonos/sonos.matchers.ts +2 -2
  208. package/ts/integrations/sonos/sonos.types.ts +30 -1
  209. package/ts/integrations/tasmota/readme.md +16 -5
  210. package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
  211. package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
  212. package/ts/integrations/tasmota/tasmota.config.ts +17 -0
  213. package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
  214. package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
  215. package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
  216. package/ts/integrations/tasmota/tasmota.types.ts +43 -2
  217. package/ts/integrations/tuya/readme.md +52 -10
  218. package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
  219. package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
  220. package/ts/integrations/tuya/tuya.mapper.ts +1 -1
  221. package/ts/integrations/tuya/tuya.types.ts +4 -0
  222. package/ts/integrations/tuya/tuya.validation.ts +5 -8
  223. package/ts/integrations/webostv/readme.md +115 -22
  224. package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
  225. package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
  226. package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
  227. package/ts/integrations/webostv/webostv.mapper.ts +4 -4
  228. package/ts/integrations/webostv/webostv.types.ts +18 -3
  229. package/ts/integrations/webostv/webostv.validation.ts +22 -1
  230. package/ts/integrations/zigbee2mqtt/readme.md +22 -27
  231. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
  232. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
  233. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
  234. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
  235. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
  236. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
  237. package/ts/integrations/zwave_js/readme.md +42 -9
  238. package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
  239. package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
  240. package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
  241. package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
  242. package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
  243. package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
  244. package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
  245. package/ts/plugins.ts +2 -1
@@ -15,32 +15,70 @@ export class HueIntegration extends BaseIntegration<IHueConfig> {
15
15
  public readonly discoveryDescriptor = createHueDiscoveryDescriptor();
16
16
  public readonly configFlow = new HueConfigFlow();
17
17
  readonly #runtimes = new Set<HueIntegrationRuntime>();
18
+ readonly #setups = new Set<Promise<IIntegrationRuntime>>();
18
19
  #closed = false;
20
+ #destroyPromise?: Promise<void>;
21
+ #retryPromise?: Promise<void>;
19
22
 
20
23
  public async setup(configArg: IHueConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
21
24
  if (this.#closed) throw new Error('Hue integration is closed.');
22
- const client = new HueClient(configArg, contextArg.abortSignal);
25
+ const context = hueRecord(contextArg);
26
+ const signal = context.abortSignal;
27
+ if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('Hue abortSignal must be an AbortSignal.');
28
+ const client = new HueClient(configArg, signal);
23
29
  const runtime = new HueIntegrationRuntime(client, () => this.#runtimes.delete(runtime));
24
30
  this.#runtimes.add(runtime);
25
- try {
26
- await client.getResources();
27
- if (this.#closed || contextArg.abortSignal?.aborted) throw new Error('Hue setup was cancelled.');
28
- return runtime;
29
- } catch (error) {
30
- await runtime.destroy();
31
- throw error;
31
+ const setup = Promise.resolve().then(async () => {
32
+ try {
33
+ await client.getResources();
34
+ if (this.#closed || signal?.aborted) throw new Error('Hue setup was cancelled.');
35
+ return runtime;
36
+ } catch (error) {
37
+ try { await runtime.destroy(); }
38
+ catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Hue setup and cleanup failed.'); }
39
+ throw error;
40
+ }
41
+ });
42
+ this.#setups.add(setup);
43
+ void setup.then(() => this.#setups.delete(setup), () => this.#setups.delete(setup));
44
+ return setup;
45
+ }
46
+
47
+ public destroy(): Promise<void> {
48
+ if (!this.#destroyPromise) {
49
+ this.#closed = true;
50
+ this.#destroyPromise = Promise.resolve().then(() => this.drain(false));
51
+ }
52
+ return this.#destroyPromise;
53
+ }
54
+
55
+ public retryCleanup(): Promise<void> {
56
+ if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal integration.');
57
+ if (!this.#retryPromise) {
58
+ this.#retryPromise = Promise.resolve().then(async () => {
59
+ await this.#destroyPromise!.catch(() => undefined);
60
+ await this.drain(true);
61
+ });
62
+ const settled = (): void => { this.#retryPromise = undefined; };
63
+ void this.#retryPromise.then(settled, settled);
32
64
  }
65
+ return this.#retryPromise;
33
66
  }
34
67
 
35
- public async destroy(): Promise<void> {
36
- this.#closed = true;
37
- await Promise.all([...this.#runtimes].map((runtimeArg) => runtimeArg.destroy()));
68
+ private async drain(retryArg: boolean): Promise<void> {
69
+ const cleanup = Promise.allSettled([...this.#runtimes].map((runtimeArg) => retryArg ? runtimeArg.retryCleanup() : runtimeArg.destroy()));
70
+ const [results] = await Promise.all([cleanup, Promise.allSettled([...this.#setups])]);
71
+ const errors = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected').map((resultArg) => resultArg.reason as unknown);
72
+ if (errors.length) throw new AggregateError(errors, 'Hue integration cleanup failed; resources remain retained for explicit retry.');
38
73
  }
74
+
39
75
  }
40
76
 
41
77
  class HueIntegrationRuntime implements IIntegrationRuntime {
42
78
  public readonly domain = 'hue';
43
79
 
80
+ #destroyPromise?: Promise<void>;
81
+ #retryPromise?: Promise<void>;
44
82
  constructor(private readonly client: HueClient, private readonly released: () => void) {}
45
83
 
46
84
  public isHealthy(): boolean { return this.client.isHealthy(); }
@@ -59,24 +97,27 @@ class HueIntegrationRuntime implements IIntegrationRuntime {
59
97
  }
60
98
 
61
99
  public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
62
- if (requestArg.domain !== 'light' || !['turn_on', 'turn_off', 'set_percentage'].includes(requestArg.service)) {
100
+ const request = hueRecord(requestArg);
101
+ if (Reflect.ownKeys(request).some((keyArg) => !['domain', 'service', 'target', 'data'].includes(String(keyArg)))) return { success: false, error: 'Hue service contains unsupported fields.' };
102
+ if (request.domain !== 'light' || typeof request.service !== 'string' || !['turn_on', 'turn_off', 'set_percentage'].includes(request.service)) {
63
103
  return { success: false, error: 'Hue supports only light turn_on, turn_off and set_percentage.' };
64
104
  }
65
- const target = hueRecord(requestArg.target);
105
+ const target = hueRecord(request.target);
66
106
  if (Reflect.ownKeys(target).some((keyArg) => keyArg !== 'entityId' && keyArg !== 'deviceId')) {
67
107
  return { success: false, error: 'Hue service target contains unsupported fields.' };
68
108
  }
69
- const data = requestArg.data === undefined ? {} : hueRecord(requestArg.data);
70
- const allowedKey = requestArg.service === 'set_percentage' ? 'percentage' : requestArg.service === 'turn_on' ? 'brightness' : undefined;
109
+ if (Object.values(target).some((valueArg) => typeof valueArg !== 'string' || !valueArg)) return { success: false, error: 'Hue service targets must be identifiers.' };
110
+ const data = request.data === undefined ? {} : hueRecord(request.data);
111
+ const allowedKey = request.service === 'set_percentage' ? 'percentage' : request.service === 'turn_on' ? 'brightness' : undefined;
71
112
  if (Reflect.ownKeys(data).some((keyArg) => keyArg !== allowedKey)) return { success: false, error: 'Hue service data contains unsupported fields.' };
72
- const state: IHueLightStatePatch = { on: requestArg.service !== 'turn_off' };
73
- if (requestArg.service === 'set_percentage' || data.brightness !== undefined) {
74
- const brightness = data[requestArg.service === 'set_percentage' ? 'percentage' : 'brightness'];
113
+ const state: IHueLightStatePatch = { on: request.service !== 'turn_off' };
114
+ if (request.service === 'set_percentage' || data.brightness !== undefined) {
115
+ const brightness = data[request.service === 'set_percentage' ? 'percentage' : 'brightness'];
75
116
  if (typeof brightness !== 'number' || !Number.isFinite(brightness) || brightness < 0 || brightness > 100) {
76
117
  return { success: false, error: 'Hue brightness must be between 0 and 100 percent.' };
77
118
  }
78
119
  state.brightness = brightness;
79
- if (requestArg.service === 'set_percentage') state.on = brightness > 0;
120
+ if (request.service === 'set_percentage') state.on = brightness > 0;
80
121
  }
81
122
  const snapshot = await this.client.getResources();
82
123
  const lightId = this.resolveLight(snapshot, target.entityId, target.deviceId);
@@ -86,22 +127,47 @@ class HueIntegrationRuntime implements IIntegrationRuntime {
86
127
  }
87
128
 
88
129
  public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
130
+ const request = hueRecord(requestArg);
131
+ if (Reflect.ownKeys(request).some((keyArg) => !['device', 'feature', 'value'].includes(String(keyArg)))) return { success: false, error: 'Hue feature request contains unsupported fields.' };
132
+ const selectedDevice = hueRecord(request.device);
133
+ const selectedFeature = hueRecord(request.feature);
134
+ const deviceId = selectedDevice.id;
135
+ const featureId = selectedFeature.id;
136
+ const capability = selectedFeature.capability;
137
+ const value = request.value;
138
+ if (selectedDevice.integrationDomain !== this.domain || typeof deviceId !== 'string' || typeof featureId !== 'string') {
139
+ return { success: false, error: 'Hue feature requires one exact Hue device and feature.' };
140
+ }
89
141
  const snapshot = await this.client.getResources(abortSignalArg);
90
- const device = HueMapper.toDevices(snapshot).find((deviceArg) => deviceArg.id === requestArg.device.id);
91
- const feature = device?.features.find((featureArg) => featureArg.id === requestArg.feature.id && featureArg.writable);
142
+ const device = HueMapper.toDevices(snapshot).find((deviceArg) => deviceArg.id === deviceId);
143
+ const feature = device?.features.find((featureArg) => featureArg.id === featureId && featureArg.capability === capability && featureArg.writable);
92
144
  const lightId = this.resolveLight(snapshot, undefined, device?.id);
93
145
  if (!device || !device.online || !feature || !lightId) return { success: false, error: 'Hue feature target is unknown, unavailable or not writable.' };
94
146
  let state: IHueLightStatePatch;
95
- if (feature.id === 'on' && typeof requestArg.value === 'boolean') state = { on: requestArg.value };
96
- else if (feature.id === 'brightness' && typeof requestArg.value === 'number') state = { brightness: requestArg.value };
147
+ if (feature.id === 'on' && typeof value === 'boolean') state = { on: value };
148
+ else if (feature.id === 'brightness' && typeof value === 'number') state = { brightness: value };
97
149
  else return { success: false, error: 'Hue feature value has an invalid type.' };
98
150
  await this.client.setLightState(lightId, state, abortSignalArg);
99
151
  return { success: true };
100
152
  }
101
153
 
102
- public async destroy(): Promise<void> {
103
- await this.client.destroy();
104
- this.released();
154
+ public destroy(): Promise<void> {
155
+ this.#destroyPromise ??= Promise.resolve().then(async () => { await this.client.destroy(); this.released(); });
156
+ return this.#destroyPromise;
157
+ }
158
+
159
+ public retryCleanup(): Promise<void> {
160
+ if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal runtime.');
161
+ if (!this.#retryPromise) {
162
+ this.#retryPromise = Promise.resolve().then(async () => {
163
+ await this.#destroyPromise!.catch(() => undefined);
164
+ await this.client.retryCleanup();
165
+ this.released();
166
+ });
167
+ const settled = (): void => { this.#retryPromise = undefined; };
168
+ void this.#retryPromise.then(settled, settled);
169
+ }
170
+ return this.#retryPromise;
105
171
  }
106
172
 
107
173
  private resolveLight(snapshotArg: IHueResourceResponse, entityIdArg: unknown, deviceIdArg: unknown): string | undefined {
@@ -10,11 +10,21 @@ export const hueUuid = (valueArg: unknown): valueArg is string =>
10
10
  export const hueBridgeId = (valueArg: unknown): valueArg is string =>
11
11
  typeof valueArg === 'string' && /^[0-9a-f]{16}$/i.test(valueArg);
12
12
 
13
+ /** Capture plain data fields without invoking caller accessors or retaining mutable objects. */
13
14
  export const hueRecord = (valueArg: unknown): Record<string, unknown> => {
14
- if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
15
- throw new Error('Hue returned an invalid object.');
15
+ if (!valueArg || typeof valueArg !== 'object'
16
+ || (Object.getPrototypeOf(valueArg) !== Object.prototype && Object.getPrototypeOf(valueArg) !== null)) {
17
+ throw new Error('Hue requires a plain data object.');
16
18
  }
17
- return valueArg as Record<string, unknown>;
19
+ const record: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
20
+ for (const key of Reflect.ownKeys(valueArg)) {
21
+ const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
22
+ if (typeof key !== 'string' || !descriptor || !descriptor.enumerable || !('value' in descriptor)) {
23
+ throw new Error('Hue data contains an unsupported field or accessor.');
24
+ }
25
+ record[key] = descriptor.value as unknown;
26
+ }
27
+ return record;
18
28
  };
19
29
 
20
30
  export const normalizeHueHost = (valueArg: unknown): string => {
@@ -33,7 +33,7 @@ Setup checks both the TLS identity and the `bridge_id` returned by `/clip/v2/res
33
33
 
34
34
  Lights advertise brightness only when the bridge reports the `dimming` capability. Availability follows the owning device's Zigbee connectivity resource; missing or disconnected connectivity is unavailable. Manufacturer and model come from the owning device, including supported third-party Hue lights.
35
35
 
36
- Light services are `turn_on`, `turn_off` and `set_percentage`. `turn_on` accepts optional `brightness` from 0 to 100; `set_percentage` requires `percentage` in that same range. A target must match the current entity/device exactly; supplying both requires them to identify the same light. `writeFeature` supports boolean `on` and numeric `brightness` only for an available light's declared writable capabilities. Unsupported fields, unknown targets and values outside the protocol range are rejected.
36
+ Light services are `turn_on`, `turn_off` and `set_percentage`. `turn_on` accepts optional `brightness` from 0 to 100; `set_percentage` requires `percentage` in that same range. A target must match the current entity/device exactly; supplying both requires them to identify the same light. `writeFeature` supports boolean `on` and numeric `brightness` only for an available light's declared writable capabilities. Unsupported fields, unknown targets and values outside the protocol range are rejected. Controls capture plain-data target IDs, domain, capability and value before awaiting a fresh read; later caller mutation cannot retarget a write. Accessors, symbols, hidden fields and class instances are rejected without invoking getters. Setup cancellation and config-flow discovery endpoint hints are also captured before awaiting readiness or displaying a form.
37
37
 
38
38
  Writes use `PUT /clip/v2/resource/light/<UUID>` and require the bridge's matching resource acknowledgement. Success means the bridge accepted the write; it does not guarantee physical delivery. Accepted writes never change local read state. Each `devices()` or `entities()` call reads current authoritative resources, and writes are not automatically retried.
39
39
 
@@ -41,11 +41,11 @@ Writes use `PUT /clip/v2/resource/light/<UUID>` and require the bridge's matchin
41
41
 
42
42
  `subscribe()` opens authenticated HTTPS `/eventstream/clip/v2` and returns an asynchronous, idempotent unsubscribe function. Change notifications contain only relevant resource IDs/types; consumers reread authoritative state. Unknown resource payloads are omitted because they may contain credentials. Invalid frames, stream termination and transport failures produce an error event and mark the runtime unhealthy. Reads do not silently restart a failed subscription; the host may explicitly establish a new one or replace the runtime.
43
43
 
44
- Requests have whole-response deadlines and a 4 MiB body limit. Discovery uses the fixed documented `https://discovery.meethue.com/` broker with a five-second deadline and 256 KiB body limit. SSE frames are limited to 1 MiB and batches to 256 events. A client permits at most 32 outstanding connections, two event subscriptions, and three simultaneous HTTPS sockets. Caller cancellation and `destroy()` close owned requests/streams; destroy waits for them to close. The integration also owns pending setups and destroys their clients on failure or shutdown.
44
+ Requests have whole-response deadlines and a 4 MiB body limit. Discovery uses the fixed documented `https://discovery.meethue.com/` broker with a five-second deadline and 256 KiB body limit. SSE frames are limited to 1 MiB and batches to 256 events. A client permits at most 32 outstanding connections, two event subscriptions, and three simultaneous HTTPS sockets. Caller cancellation and `destroy()` close owned requests/streams; destroy waits for them and all admitted request/subscriber continuations to settle. Subscriber callbacks may return promises. Unsubscribe has stable promise identity and joins admitted callbacks; later events are fenced. Each stream permits at most 32 pending callbacks. Synchronous throws and async rejections are observed, mark the stream unhealthy, and use a redacted subscriber error rather than being relabelled as malformed stream data. Callback failures remain visible on terminal client cleanup until explicit `HueClient.retryCleanup()` or `HueIntegration.retryCleanup()` acknowledges them. Failed transport cleanup is retained for the same retry. Retry performs cleanup only; it never replays GETs, PUTs, subscriptions, or callbacks. `destroy()` publishes a stable terminal promise before aborting and continues to return that promise after a retry. Integration and runtime destroy also have stable terminal promise identity. Integration shutdown joins pending setups and all sibling runtimes before surfacing cleanup failures, retaining failed runtime handles for explicit cleanup-only retry. Shared native home/resource admission, flow ownership, and home-qualified IDs are pending a separate breaking config/identity decision. Raw `appKey` configuration remains the current contract; home-bound credential references and existing-config migration require a separate public-contract decision.
45
45
 
46
46
  ## Verification and limits
47
47
 
48
- Tests use disposable CA-signed local TLS bridges, including untrusted issuers, a wrong bridge certificate, resource identity mismatch, exact PUT payloads, non-optimistic reads, unsupported controls, unavailable devices, malformed/oversized bodies, cancellation and event-stream cleanup. No physical bridge or owner's home was accessed.
48
+ Tests use disposable CA-signed local TLS bridges, including untrusted issuers, a wrong bridge certificate, resource identity mismatch, exact PUT payloads, non-optimistic reads, unsupported controls, unavailable devices, malformed/oversized bodies, cancellation, plain-data/accessor validation, blocked async subscribers, sibling drainage, reentrant close, retained cleanup failures and cleanup-only retry without HTTPS replay. No physical bridge or owner's home was accessed.
49
49
 
50
50
  The public [Hue API announcement](https://developers.meethue.com/new-hue-api/) establishes API v2 HTTPS, local proactive events and the supported discovery methods. Detailed Hue API and HTTPS pages require a developer login and returned login/403 here. Implemented wire resource models and event paths were cross-checked against [aiohue's upstream v2 implementation](https://github.com/home-assistant-libs/aiohue/tree/main/aiohue/v2). Aiohue's disabled-TLS and geofence keepalive workaround are not used. Physical bridge firmware compatibility, operator CA provisioning and physical command delivery remain acceptance checks. Color, color temperature, scenes, entertainment streaming, grouped lights and accessory sensors are not exposed by this runtime.
51
51
 
@@ -1,6 +1,6 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { MqttMapper } from './mqtt.mapper.js';
3
- import type { IMqttConfig, IMqttConnectionState, IMqttMessage, IMqttPublishOptions, IMqttSnapshot } from './mqtt.types.js';
3
+ import type { IMqttConfig, IMqttConnectionState, IMqttMessage, IMqttPublishOptions, IMqttSnapshot, IMqttTransportCredentials } from './mqtt.types.js';
4
4
 
5
5
  type TMqttMessageHandler = (messageArg: IMqttMessage) => void;
6
6
  type TMqttStateHandler = (stateArg: IMqttConnectionState) => void;
@@ -10,6 +10,11 @@ interface IMqttSubscription {
10
10
  handler?: TMqttMessageHandler;
11
11
  }
12
12
 
13
+ interface IMqttTransportCleanup {
14
+ promise?: Promise<void>;
15
+ failed: boolean;
16
+ }
17
+
13
18
  export const mqttTopicMatches = (filterArg: string, topicArg: string): boolean => {
14
19
  if (topicArg.startsWith('$') && !filterArg.startsWith('$')) return false;
15
20
  const filter = filterArg.split('/');
@@ -37,17 +42,23 @@ export class MqttClient {
37
42
  private startPromise?: Promise<void>;
38
43
  private destroyPromise?: Promise<void>;
39
44
  private cleanupPromise = Promise.resolve();
45
+ private retryPromise?: Promise<void>;
46
+ private readonly transportCleanups = new Map<plugins.mqtt.MqttClient, IMqttTransportCleanup>();
40
47
  private destroyed = false;
41
48
  private ready = false;
42
49
  private hasStarted = false;
43
50
  private generation = 0;
51
+ private currentGeneration?: number;
52
+ private currentStream?: plugins.mqtt.MqttClient['stream'];
44
53
  private readonly subscriptions = new Set<IMqttSubscription>();
45
54
  private readonly messageHandlers = new Set<TMqttMessageHandler>();
46
55
  private readonly stateHandlers = new Set<TMqttStateHandler>();
47
56
  private readonly messages = new Map<string, IMqttMessage>();
57
+ private readonly currentMessageTopics = new Set<string>();
48
58
  private readonly persistentTopics = new Set<string>();
49
59
  private readonly brokerTopics = new Set<string>();
50
60
  private readonly pendingOperations = new Set<(errorArg: Error) => void>();
61
+ private readonly publications = new Map<plugins.mqtt.MqttClient, number>();
51
62
  private topicSync?: Promise<void>;
52
63
  private topicSyncRequested = false;
53
64
  private bufferedBytes = 0;
@@ -57,8 +68,9 @@ export class MqttClient {
57
68
  private readonly maxPayloadBytes: number;
58
69
  private readonly maxBufferedBytes: number;
59
70
 
60
- constructor(private readonly config: IMqttConfig) {
71
+ constructor(private readonly config: IMqttConfig, private readonly credentials: IMqttTransportCredentials = {}) {
61
72
  this.config = structuredClone(config);
73
+ this.credentials = structuredClone(credentials);
62
74
  this.connectTimeoutMs = this.integerOption(config.connectTimeoutMs, 7000, 1, 2147483647);
63
75
  this.operationTimeoutMs = this.integerOption(config.operationTimeoutMs, 7000, 1, 2147483647);
64
76
  this.maxMessageTopics = this.integerOption(config.maxMessageTopics, 4096, 1, 100000);
@@ -73,6 +85,13 @@ export class MqttClient {
73
85
  validateMqttTopic(topic, true);
74
86
  this.persistentTopics.add(topic);
75
87
  }
88
+ if (config.mode === 'selected-topics') {
89
+ for (const mapping of config.mappings ?? []) {
90
+ for (const topic of [mapping.stateTopic, ...(mapping.availabilityTopic ? [mapping.availabilityTopic] : [])]) {
91
+ validateMqttTopic(topic); this.persistentTopics.add(topic);
92
+ }
93
+ }
94
+ }
76
95
  if (config.discovery !== false) {
77
96
  const prefix = config.discoveryPrefix || 'homeassistant';
78
97
  validateMqttTopic(prefix);
@@ -106,6 +125,14 @@ export class MqttClient {
106
125
  return MqttMapper.toSnapshot({ ...this.config, retainedMessages: [], discoveryMessages: [] }, this.connected, [...this.messages.values()]);
107
126
  }
108
127
 
128
+ /** Live broker observations only, including arrivals before readiness; never starts a transport. */
129
+ public getCurrentSnapshot(): IMqttMessage[] {
130
+ if (this.destroyed || this.currentGeneration !== this.generation || !this.client?.connected ||
131
+ this.client.stream !== this.currentStream || this.currentStream?.destroyed) return [];
132
+ return [...this.messages.values()].filter((messageArg) => this.currentMessageTopics.has(messageArg.topic))
133
+ .map((messageArg) => structuredClone(messageArg));
134
+ }
135
+
109
136
  public async start(abortSignalArg?: AbortSignal): Promise<void> {
110
137
  this.throwIfAborted(abortSignalArg);
111
138
  if (this.destroyed) throw new Error('MQTT client has been destroyed.');
@@ -144,13 +171,18 @@ export class MqttClient {
144
171
  if (packetArg.cmd === 'publish' && packetArg.payload === payload) packetId = packetArg.messageId;
145
172
  };
146
173
  client.on('packetsend', observePacket);
174
+ this.publications.set(client, (this.publications.get(client) ?? 0) + 1);
147
175
  try {
148
176
  await this.bounded(client.publishAsync(optionsArg.topic, payload, {
149
177
  qos: optionsArg.qos ?? 0, retain: optionsArg.retain ?? false,
150
178
  }), this.operationTimeoutMs, abortSignalArg, () => {
151
179
  if (packetId !== undefined && optionsArg.qos) client.removeOutgoingMessage(packetId);
152
180
  });
153
- } finally { client.removeListener('packetsend', observePacket); }
181
+ } finally {
182
+ client.removeListener('packetsend', observePacket);
183
+ const remaining = (this.publications.get(client) ?? 1) - 1;
184
+ if (remaining) this.publications.set(client, remaining); else this.publications.delete(client);
185
+ }
154
186
  }
155
187
 
156
188
  public async subscribe(topicArg: string, handlerArg?: TMqttMessageHandler, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
@@ -175,10 +207,35 @@ export class MqttClient {
175
207
  }
176
208
 
177
209
  public destroy(): Promise<void> {
178
- if (!this.destroyPromise) this.destroyPromise = this.destroyConnection();
210
+ if (!this.destroyPromise) {
211
+ this.destroyed = true;
212
+ this.clearCurrentGeneration();
213
+ this.destroyPromise = Promise.resolve().then(() => this.destroyConnection());
214
+ }
179
215
  return this.destroyPromise;
180
216
  }
181
217
 
218
+ /** Retry retained close handles only; this never admits a connection or replays application effects. */
219
+ public retryCleanup(): Promise<void> {
220
+ if (!this.destroyPromise) return this.destroy();
221
+ if (!this.retryPromise) {
222
+ const retry = Promise.resolve().then(async () => {
223
+ await this.destroyPromise?.then(() => undefined, () => undefined);
224
+ const cleanup = [...this.transportCleanups].filter(([, recordArg]) => recordArg.failed).map(([clientArg, recordArg]) => {
225
+ recordArg.promise = undefined;
226
+ recordArg.failed = false;
227
+ return this.closeClient(clientArg);
228
+ });
229
+ const results = await Promise.allSettled(cleanup);
230
+ const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
231
+ if (failures.length) throw new AggregateError(failures, 'MQTT retained transport cleanup failed.');
232
+ });
233
+ this.retryPromise = retry;
234
+ void retry.then(() => { this.retryPromise = undefined; }, () => { this.retryPromise = undefined; });
235
+ }
236
+ return this.retryPromise;
237
+ }
238
+
182
239
  private async startConnection(): Promise<void> {
183
240
  let client = this.client;
184
241
  if (!client) {
@@ -191,6 +248,7 @@ export class MqttClient {
191
248
  protocolVersion: 4,
192
249
  clientId: this.config.clientId || 'shx-' + plugins.crypto.randomBytes(8).toString('hex'),
193
250
  username: this.config.username, password: this.config.password,
251
+ ...this.credentials,
194
252
  clean: this.config.clean ?? true,
195
253
  keepalive: this.config.keepalive ?? 60,
196
254
  rejectUnauthorized: this.config.rejectUnauthorized !== false,
@@ -202,9 +260,21 @@ export class MqttClient {
202
260
  });
203
261
  this.client = client;
204
262
  const ownedClient = client;
263
+ const receivedGenerations = new WeakMap<plugins.mqtt.IPublishPacket, number>();
264
+ client.on('packetreceive', (packetArg) => {
265
+ if (packetArg.cmd !== 'publish' || this.client !== ownedClient || this.destroyed ||
266
+ this.currentGeneration !== this.generation || !ownedClient.connected ||
267
+ ownedClient.stream !== this.currentStream || this.currentStream?.destroyed) return;
268
+ // MQTT.js delivers the same packet through message, including its default QoS 2 store.
269
+ if (!receivedGenerations.has(packetArg)) receivedGenerations.set(packetArg, this.generation);
270
+ });
205
271
  client.on('connect', () => {
272
+ if (this.client !== ownedClient || this.destroyed || !ownedClient.connected || ownedClient.stream.destroyed) return;
273
+ this.clearCurrentGeneration();
206
274
  this.brokerTopics.clear();
207
275
  const generation = ++this.generation;
276
+ this.currentGeneration = generation;
277
+ this.currentStream = ownedClient.stream;
208
278
  void this.bounded(this.initializeConnection(ownedClient), this.operationTimeoutMs).then(() => {
209
279
  if (this.client !== ownedClient || generation !== this.generation || !ownedClient.connected) return;
210
280
  this.ready = true;
@@ -214,7 +284,9 @@ export class MqttClient {
214
284
  });
215
285
  });
216
286
  client.on('message', (topicArg, payloadArg, packetArg) => {
217
- if (this.client !== ownedClient) return;
287
+ if (this.client !== ownedClient || this.destroyed || this.currentGeneration !== this.generation ||
288
+ receivedGenerations.get(packetArg) !== this.currentGeneration || !ownedClient.connected ||
289
+ ownedClient.stream !== this.currentStream || this.currentStream?.destroyed) return;
218
290
  try {
219
291
  this.handleMessage({ topic: topicArg, payload: payloadArg.toString('utf8'), qos: packetArg.qos, retain: packetArg.retain, timestamp: Date.now() });
220
292
  } catch (error) { void this.failConnection(this.asError(error), ownedClient); }
@@ -222,15 +294,22 @@ export class MqttClient {
222
294
  client.on('error', (errorArg) => {
223
295
  if (this.client === ownedClient) {
224
296
  this.ready = false;
225
- this.emitState({ connected: false, error: errorArg });
297
+ this.generation++;
298
+ this.clearCurrentGeneration();
299
+ try { this.emitState({ connected: false, error: errorArg }); }
300
+ catch (error) { void this.failConnection(this.asError(error), ownedClient); }
226
301
  }
227
302
  });
228
303
  client.on('close', () => {
229
304
  if (this.client !== ownedClient) return;
230
305
  this.ready = false;
231
306
  this.generation++;
307
+ this.clearCurrentGeneration();
232
308
  this.brokerTopics.clear();
233
- this.emitState({ connected: false });
309
+ try { this.emitState({ connected: false }); }
310
+ catch (error) { void this.failConnection(this.asError(error), ownedClient); }
311
+ // A new connection must never replay an unacknowledged application effect.
312
+ if (this.publications.has(ownedClient)) void this.failConnection(new Error('MQTT publication lost its connection before acknowledgement.'), ownedClient);
234
313
  });
235
314
  }
236
315
  if (this.connected) return;
@@ -313,14 +392,20 @@ export class MqttClient {
313
392
  }
314
393
 
315
394
  private handleMessage(messageArg: IMqttMessage): void {
395
+ if (this.config.mode === 'selected-topics' && !this.persistentTopics.has(messageArg.topic)) return;
316
396
  this.storeMessage(messageArg);
397
+ this.currentMessageTopics.add(messageArg.topic);
317
398
  if (this.config.discovery !== false && messageArg.topic.endsWith('/config') && mqttTopicMatches((this.config.discoveryPrefix || 'homeassistant') + '/#', messageArg.topic)) {
318
399
  void this.synchronizeTopics();
319
400
  }
401
+ const failures: unknown[] = [];
320
402
  for (const subscription of this.subscriptions) {
321
- if (subscription.handler && mqttTopicMatches(subscription.topic, messageArg.topic)) subscription.handler(messageArg);
403
+ if (subscription.handler && mqttTopicMatches(subscription.topic, messageArg.topic)) {
404
+ try { subscription.handler(messageArg); } catch (error) { failures.push(error); }
405
+ }
322
406
  }
323
- for (const handler of this.messageHandlers) handler(messageArg);
407
+ for (const handler of this.messageHandlers) { try { handler(messageArg); } catch (error) { failures.push(error); } }
408
+ if (failures.length) throw new AggregateError(failures, 'MQTT message callbacks failed.');
324
409
  }
325
410
 
326
411
  private storeMessage(messageArg: IMqttMessage): void {
@@ -337,6 +422,7 @@ export class MqttClient {
337
422
  }
338
423
 
339
424
  private removeMessage(topicArg: string): void {
425
+ this.currentMessageTopics.delete(topicArg);
340
426
  const message = this.messages.get(topicArg);
341
427
  if (message) {
342
428
  this.bufferedBytes -= this.payloadBuffer(message.payload).length + Buffer.byteLength(topicArg);
@@ -344,31 +430,47 @@ export class MqttClient {
344
430
  }
345
431
  }
346
432
 
433
+ private clearCurrentGeneration(): void {
434
+ this.currentGeneration = undefined;
435
+ this.currentStream = undefined;
436
+ this.currentMessageTopics.clear();
437
+ }
438
+
347
439
  private async destroyConnection(): Promise<void> {
348
440
  this.destroyed = true;
349
441
  this.ready = false;
442
+ this.clearCurrentGeneration();
350
443
  const client = this.client;
351
444
  this.client = undefined;
352
445
  this.rejectOperations(new Error('MQTT client has been destroyed.'));
353
- this.emitState({ connected: false });
446
+ const stateHandlers = [...this.stateHandlers];
447
+ const notify = Promise.resolve().then(() => this.emitState({ connected: false }, stateHandlers));
354
448
  this.subscriptions.clear();
355
449
  this.messageHandlers.clear();
356
450
  this.stateHandlers.clear();
357
451
  this.messages.clear();
358
452
  this.bufferedBytes = 0;
359
453
  this.brokerTopics.clear();
360
- if (client) await this.closeClient(client);
361
- await this.cleanupPromise;
454
+ const results = await Promise.allSettled([notify, ...(client ? [this.closeClient(client)] : []), this.cleanupPromise,
455
+ ...(this.startPromise ? [this.startPromise.catch(() => undefined)] : []), ...(this.topicSync ? [this.topicSync.catch(() => undefined)] : [])]);
456
+ const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
457
+ if (failures.length) throw new AggregateError(failures, 'MQTT client cleanup failed.');
362
458
  }
363
459
 
364
460
  private failConnection(errorArg: Error, clientArg: plugins.mqtt.MqttClient): Promise<void> {
365
461
  if (this.client !== clientArg) return this.cleanupPromise;
366
462
  this.ready = false;
367
463
  this.generation++;
464
+ this.clearCurrentGeneration();
368
465
  this.client = undefined;
369
466
  this.brokerTopics.clear();
370
- this.emitState({ connected: false, error: errorArg });
371
- const cleanup = this.closeClient(clientArg);
467
+ const cleanup = Promise.resolve().then(async () => {
468
+ const results = await Promise.allSettled([
469
+ Promise.resolve().then(() => this.emitState({ connected: false, error: errorArg })), this.closeClient(clientArg),
470
+ ]);
471
+ const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
472
+ if (failures.length) throw new AggregateError(failures, 'MQTT cancellation cleanup failed.');
473
+ });
372
474
  this.cleanupPromise = cleanup;
373
475
  void cleanup.then(() => this.rejectOperations(errorArg), (error: unknown) => {
374
476
  this.rejectOperations(new AggregateError([errorArg, error], 'MQTT cancellation cleanup failed.'));
@@ -376,10 +478,23 @@ export class MqttClient {
376
478
  return cleanup;
377
479
  }
378
480
 
379
- private async closeClient(clientArg: plugins.mqtt.MqttClient): Promise<void> {
481
+ private closeClient(clientArg: plugins.mqtt.MqttClient): Promise<void> {
482
+ let record = this.transportCleanups.get(clientArg);
483
+ if (!record) { record = { failed: false }; this.transportCleanups.set(clientArg, record); }
484
+ if (!record.promise) {
485
+ const captured = record;
486
+ record.promise = Promise.resolve().then(() => this.performClientClose(clientArg)).then(() => {
487
+ this.transportCleanups.delete(clientArg);
488
+ }, (error: unknown) => { captured.failed = true; throw error; });
489
+ }
490
+ return record.promise;
491
+ }
492
+
493
+ private async performClientClose(clientArg: plugins.mqtt.MqttClient): Promise<void> {
380
494
  const closed = clientArg.stream.closed ? Promise.resolve() : new Promise<void>((resolve) => clientArg.once('close', resolve));
381
- await clientArg.endAsync(true);
382
- await closed;
495
+ const results = await Promise.allSettled([clientArg.endAsync(true), closed]);
496
+ const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : []);
497
+ if (failures.length) throw new AggregateError(failures, 'MQTT transport cleanup failed.');
383
498
  }
384
499
 
385
500
  private bounded<TValue>(promiseArg: Promise<TValue>, timeoutMsArg: number, abortSignalArg?: AbortSignal, cancelArg?: () => void): Promise<TValue> {
@@ -419,8 +534,10 @@ export class MqttClient {
419
534
  this.pendingOperations.clear();
420
535
  }
421
536
 
422
- private emitState(stateArg: IMqttConnectionState): void {
423
- for (const handler of this.stateHandlers) handler(stateArg);
537
+ private emitState(stateArg: IMqttConnectionState, handlersArg: Iterable<TMqttStateHandler> = this.stateHandlers): void {
538
+ const failures: unknown[] = [];
539
+ for (const handler of handlersArg) { try { handler(stateArg); } catch (error) { failures.push(error); } }
540
+ if (failures.length) throw new AggregateError(failures, 'MQTT connection callbacks failed.');
424
541
  }
425
542
 
426
543
  private requireClient(): plugins.mqtt.MqttClient {