@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,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 {
@@ -1,48 +1,61 @@
1
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
2
+ import type { INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
1
3
  import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
2
4
  import type { IMqttConfig } from './mqtt.types.js';
5
+ import { mqttRecord, normalizeMqttIntegrationConfig } from './mqtt.validation.js';
3
6
 
4
7
  export class MqttConfigFlow implements IConfigFlow<IMqttConfig> {
8
+ readonly #scope: NativeIntegrationScope;
9
+
10
+ constructor(ownerArg: NativeIntegrationOwner = new NativeIntegrationOwner('mqtt')) {
11
+ this.#scope = ownerArg.createFlowScope();
12
+ }
13
+
14
+ public destroy(): Promise<void> { return this.#scope.close(); }
15
+ public retryCleanup(): Promise<void> { return this.#scope.retryCleanup(); }
16
+
5
17
  public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMqttConfig>> {
6
- void contextArg;
18
+ if (candidateArg.integrationDomain !== undefined && candidateArg.integrationDomain !== 'mqtt') return { kind: 'error', error: 'MQTT requires an MQTT candidate.' };
19
+ let context: INativeAdmissionContext;
20
+ try { context = this.#scope.admit(contextArg); }
21
+ catch { return { kind: 'error', error: 'MQTT configuration requires the explicit matching host home and an open flow.' }; }
22
+ const host = typeof candidateArg.host === 'string' ? candidateArg.host : undefined;
7
23
  return {
8
- kind: 'form',
9
- title: 'Connect MQTT broker',
10
- description: 'Configure a local MQTT 3.1.1 broker and Home Assistant-style MQTT discovery.',
24
+ kind: 'form', title: 'Connect selected MQTT broker',
25
+ description: 'Use a caller-owned credential reference. Selected topic observations are read-only; discovery mode uses the configured MQTT discovery schema.',
11
26
  fields: [
12
- { name: 'host', label: 'Broker host', type: 'text', required: true },
27
+ { name: 'host', label: 'Selected broker host', type: 'text', required: true },
13
28
  { name: 'port', label: 'Broker port', type: 'number' },
14
- { name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'MQTT', value: 'mqtt' }, { label: 'MQTTS', value: 'mqtts' }] },
15
- { name: 'username', label: 'Username', type: 'text' },
16
- { name: 'password', label: 'Password', type: 'password' },
29
+ { name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'MQTTS', value: 'mqtts' }, { label: 'MQTT', value: 'mqtt' }] },
30
+ { name: 'mode', label: 'Mapping mode', type: 'select', options: [{ label: 'Selected topics', value: 'selected-topics' }, { label: 'MQTT discovery', value: 'discovery' }] },
31
+ { name: 'mappings', label: 'Explicit observation mappings', type: 'text' },
32
+ { name: 'credentialKey', label: 'Caller credential reference', type: 'text' },
33
+ { name: 'allowUnencryptedLan', label: 'Consent to unencrypted local MQTT', type: 'boolean' },
17
34
  { name: 'clientId', label: 'Client ID', type: 'text' },
18
35
  { name: 'discoveryPrefix', label: 'Discovery prefix', type: 'text' },
19
36
  ],
20
- submit: async (valuesArg) => {
21
- const host = this.stringValue(valuesArg.host) || candidateArg.host;
22
- const protocol = valuesArg.protocol === 'mqtts' ? 'mqtts' : 'mqtt';
23
- const port = valuesArg.port ?? candidateArg.port ?? (protocol === 'mqtts' ? 8883 : 1883);
24
- if (!host || typeof port !== 'number' || !Number.isSafeInteger(port) || port < 1 || port > 65535) {
25
- return { kind: 'error', error: 'A broker host and integer port from 1 through 65535 are required.' };
26
- }
27
- return {
28
- kind: 'done',
29
- title: 'MQTT configured',
30
- config: {
31
- host,
32
- port,
33
- protocol,
34
- username: this.stringValue(valuesArg.username),
35
- password: typeof valuesArg.password === 'string' ? valuesArg.password : undefined,
36
- clientId: this.stringValue(valuesArg.clientId),
37
- discovery: true,
38
- discoveryPrefix: this.stringValue(valuesArg.discoveryPrefix) || 'homeassistant',
39
- },
40
- };
41
- },
37
+ submit: (valuesArg) => this.#submit(valuesArg, context, host),
42
38
  };
43
39
  }
44
40
 
45
- private stringValue(valueArg: unknown): string | undefined {
46
- return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
41
+ async #submit(valuesArg: Record<string, unknown>, contextArg: INativeAdmissionContext, candidateHostArg?: string): Promise<IConfigFlowStep<IMqttConfig>> {
42
+ let config: IMqttConfig & { homeId: string };
43
+ try {
44
+ const values = mqttRecord(valuesArg, ['homeId', 'host', 'port', 'protocol', 'mode', 'mappings', 'credentialKey',
45
+ 'allowUnencryptedLan', 'clientId', 'discoveryPrefix', 'connectTimeoutMs', 'operationTimeoutMs']);
46
+ if (values.homeId !== undefined && values.homeId !== contextArg.homeId) throw new TypeError('MQTT form home conflicts with the host home.');
47
+ const mappings = typeof values.mappings === 'string' ? JSON.parse(values.mappings) as unknown : values.mappings;
48
+ config = normalizeMqttIntegrationConfig({ ...values, mappings, host: values.host ?? candidateHostArg,
49
+ protocol: values.protocol ?? 'mqtts', mode: values.mode ?? 'discovery', homeId: contextArg.homeId });
50
+ } catch { return { kind: 'error', error: 'MQTT values, exact topic mappings or host home are invalid. Supply credential references rather than secrets.' }; }
51
+ try {
52
+ return await this.#scope.run(contextArg, config.homeId, async (leaseArg): Promise<IConfigFlowStep<IMqttConfig>> => {
53
+ leaseArg.assertOpen();
54
+ return { kind: 'done', title: 'MQTT reference configuration prepared', config };
55
+ });
56
+ } catch (error) {
57
+ if (error instanceof AggregateError) throw error;
58
+ return { kind: 'error', error: 'MQTT configuration flow is closed or cancelled.' };
59
+ }
47
60
  }
48
61
  }
@@ -1,18 +1,23 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
3
4
  import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
4
5
  import { MqttClient } from './mqtt.classes.client.js';
5
6
  import { MqttConfigFlow } from './mqtt.classes.configflow.js';
6
7
  import { createMqttDiscoveryDescriptor } from './mqtt.discovery.js';
7
8
  import { MqttMapper } from './mqtt.mapper.js';
8
- import type { IMqttConfig, IMqttPublishOptions } from './mqtt.types.js';
9
+ import { MqttSession } from './mqtt.classes.session.js';
10
+ import { normalizeMqttIntegrationConfig } from './mqtt.validation.js';
11
+ import type { IMqttConfig, IMqttIntegrationOptions, IMqttPublishOptions } from './mqtt.types.js';
9
12
 
10
13
  export class MqttIntegration extends BaseIntegration<IMqttConfig> {
11
14
  public readonly domain = 'mqtt';
12
15
  public readonly displayName = 'MQTT';
13
16
  public readonly status = 'control-runtime' as const;
14
17
  public readonly discoveryDescriptor = createMqttDiscoveryDescriptor();
15
- public readonly configFlow = new MqttConfigFlow();
18
+ public readonly configFlow: MqttConfigFlow;
19
+ readonly #owner: NativeIntegrationOwner;
20
+ readonly #options: IMqttIntegrationOptions;
16
21
  public readonly metadata = {
17
22
  source: 'home-assistant/core',
18
23
  upstreamPath: 'homeassistant/components/mqtt',
@@ -22,20 +27,31 @@ export class MqttIntegration extends BaseIntegration<IMqttConfig> {
22
27
  qualityScale: 'platinum',
23
28
  };
24
29
 
30
+ constructor(optionsArg: IMqttIntegrationOptions = {}) {
31
+ super();
32
+ this.#owner = new NativeIntegrationOwner('mqtt', { homeId: optionsArg.homeId, capacity: optionsArg.capacity });
33
+ this.#options = Object.freeze({ resolveCredentials: optionsArg.resolveCredentials });
34
+ this.configFlow = new MqttConfigFlow(this.#owner);
35
+ }
36
+
25
37
  public async setup(configArg: IMqttConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
26
- const client = new MqttClient(configArg);
27
- const runtime = new MqttRuntime(client);
28
- try {
29
- await client.start(contextArg.abortSignal);
38
+ const config = normalizeMqttIntegrationConfig(configArg);
39
+ return this.#owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
40
+ const session = new MqttSession(config, leaseArg, this.#options.resolveCredentials);
41
+ let runtime: MqttRuntime | undefined;
42
+ let cleanupAttempts = 0;
43
+ const closeSession = (): Promise<void> => cleanupAttempts++ === 0 ? session.close() : session.retryCleanup();
44
+ leaseArg.own(() => runtime ? runtime.cleanup() : closeSession());
45
+ const client = await session.start();
46
+ leaseArg.assertOpen();
47
+ runtime = new MqttRuntime(client, config, leaseArg, closeSession);
48
+ leaseArg.promoteToRuntime();
30
49
  return runtime;
31
- } catch (error) {
32
- try { await runtime.destroy(); }
33
- catch (cleanupError) { throw new AggregateError([error, cleanupError], 'MQTT setup and cleanup failed.'); }
34
- throw error;
35
- }
50
+ });
36
51
  }
37
52
 
38
- public async destroy(): Promise<void> {}
53
+ public destroy(): Promise<void> { return this.#owner.close(); }
54
+ public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
39
55
  }
40
56
 
41
57
  export class HomeAssistantMqttIntegration extends MqttIntegration {}
@@ -51,25 +67,30 @@ class MqttRuntime implements IIntegrationRuntime {
51
67
  private updateRequested = false;
52
68
  private updating = false;
53
69
  private destroyed = false;
70
+ private readonly operations = new Set<Promise<unknown>>();
71
+ private readonly failures = new Set<unknown>();
72
+ private readonly ownerAbort = (): void => { this.destroyed = true; };
54
73
 
55
- constructor(private readonly client: MqttClient) {
74
+ constructor(private readonly client: MqttClient, private readonly config: IMqttConfig,
75
+ private readonly lease: INativeSetupLease, private readonly closeSession: () => Promise<void>) {
56
76
  this.removeMessageHandler = client.onMessage(() => this.queueUpdate());
57
77
  this.removeConnectionHandler = client.onConnectionState((stateArg) => {
58
78
  if (stateArg.error) this.emit({ type: 'error', integrationDomain: this.domain, data: { message: stateArg.error.message }, timestamp: Date.now() });
59
79
  this.queueUpdate();
60
80
  });
81
+ lease.signal.addEventListener('abort', this.ownerAbort, { once: true });
61
82
  }
62
83
 
63
84
  public isHealthy(): boolean {
64
- return this.client.isHealthy();
85
+ return !this.destroyed && this.client.isHealthy();
65
86
  }
66
87
 
67
88
  public async devices(abortSignalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
68
- return MqttMapper.toDevices(await this.client.getSnapshot(abortSignalArg));
89
+ return this.operation(async () => MqttMapper.toDevices(await this.client.getSnapshot(this.signal(abortSignalArg))));
69
90
  }
70
91
 
71
92
  public async entities(): Promise<IIntegrationEntity[]> {
72
- return MqttMapper.toEntities(await this.client.getSnapshot());
93
+ return this.operation(async () => MqttMapper.toEntities(await this.client.getSnapshot(this.signal())));
73
94
  }
74
95
 
75
96
  public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
@@ -92,35 +113,64 @@ class MqttRuntime implements IIntegrationRuntime {
92
113
  }
93
114
 
94
115
  public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
95
- try {
96
- let command: IMqttPublishOptions | undefined;
97
- if (requestArg.domain === 'mqtt') {
98
- if (requestArg.service !== 'publish') return { success: false, error: 'Unsupported MQTT broker service.' };
99
- command = this.publishOptionsFromRequest(requestArg);
100
- } else command = MqttMapper.commandForService(await this.client.getSnapshot(), requestArg);
101
- if (!command) return { success: false, error: 'MQTT service requires an available, unambiguous supported target and valid input.' };
102
- await this.client.publish(command);
103
- return { success: true };
104
- } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
116
+ if (this.config.mode === 'selected-topics') return { success: false, error: 'Selected MQTT topic mappings are read-only.' };
117
+ return this.operation(async () => {
118
+ try {
119
+ let command: IMqttPublishOptions | undefined;
120
+ if (requestArg.domain === 'mqtt') {
121
+ if (requestArg.service !== 'publish') return { success: false, error: 'Unsupported MQTT broker service.' };
122
+ command = this.publishOptionsFromRequest(requestArg);
123
+ } else command = MqttMapper.commandForService(await this.client.getSnapshot(), requestArg);
124
+ if (!command) return { success: false, error: 'MQTT service requires an available, unambiguous supported target and valid input.' };
125
+ await this.client.publish(command, this.signal());
126
+ return { success: true, data: { transportAccepted: true, brokerAcknowledged: command.qos === 1 || command.qos === 2, physicalOutcomeConfirmed: false } };
127
+ } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
128
+ });
105
129
  }
106
130
 
107
131
  public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
108
- try {
109
- const command = MqttMapper.commandForFeature(await this.client.getSnapshot(abortSignalArg), requestArg);
110
- if (!command) return { success: false, error: 'MQTT feature requires an available, unambiguous supported target and valid value.' };
111
- await this.client.publish(command, abortSignalArg);
112
- return { success: true };
113
- } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
132
+ if (this.config.mode === 'selected-topics') return { success: false, error: 'Selected MQTT topic mappings are read-only.' };
133
+ return this.operation(async () => {
134
+ try {
135
+ const signal = this.signal(abortSignalArg);
136
+ const command = MqttMapper.commandForFeature(await this.client.getSnapshot(signal), requestArg);
137
+ if (!command) return { success: false, error: 'MQTT feature requires an available, unambiguous supported target and valid value.' };
138
+ await this.client.publish(command, signal);
139
+ return { success: true, data: { transportAccepted: true, brokerAcknowledged: command.qos === 1 || command.qos === 2, physicalOutcomeConfirmed: false } };
140
+ } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error) }; }
141
+ });
114
142
  }
115
143
 
116
- public async destroy(): Promise<void> {
144
+ public destroy(): Promise<void> { return this.lease.close(); }
145
+
146
+ public async cleanup(): Promise<void> {
117
147
  this.destroyed = true;
118
148
  this.removeMessageHandler();
119
149
  this.removeConnectionHandler();
120
- for (const cleanup of [...this.subscriptionCleanups]) await cleanup();
150
+ const cleanups = [...this.subscriptionCleanups];
121
151
  this.handlers.clear();
122
- await this.client.destroy();
123
- await this.updatePromise;
152
+ this.lease.signal.removeEventListener('abort', this.ownerAbort);
153
+ const results = await Promise.allSettled([
154
+ ...cleanups.map((cleanup) => Promise.resolve().then(cleanup)), this.closeSession(), this.updatePromise,
155
+ ...[...this.operations].map((operation) => operation.then(() => undefined, () => undefined)),
156
+ ]);
157
+ const failures = [...this.failures, ...results.flatMap((result) => result.status === 'rejected' ? [result.reason as unknown] : [])];
158
+ this.failures.clear();
159
+ if (failures.length) throw new AggregateError(failures, 'MQTT runtime cleanup failed; retry cleanup explicitly.');
160
+ }
161
+
162
+ private signal(signalArg?: AbortSignal): AbortSignal {
163
+ this.lease.assertOpen();
164
+ if (this.destroyed) throw new Error('MQTT runtime has been destroyed.');
165
+ return signalArg ? AbortSignal.any([this.lease.signal, signalArg]) : this.lease.signal;
166
+ }
167
+
168
+ private operation<TValue>(workArg: () => Promise<TValue>): Promise<TValue> {
169
+ this.signal();
170
+ const work = Promise.resolve().then(workArg);
171
+ this.operations.add(work);
172
+ void work.then(() => { this.operations.delete(work); }, () => { this.operations.delete(work); });
173
+ return work;
124
174
  }
125
175
 
126
176
  private queueUpdate(): void {
@@ -129,7 +179,16 @@ class MqttRuntime implements IIntegrationRuntime {
129
179
  if (this.updating) return;
130
180
  this.updating = true;
131
181
  this.updatePromise = this.updateSnapshots().catch((error: unknown) => {
132
- if (!this.destroyed) this.emit({ type: 'error', integrationDomain: this.domain, data: { message: error instanceof Error ? error.message : String(error) }, timestamp: Date.now() });
182
+ if (!this.destroyed || error instanceof AggregateError) this.failures.add(error);
183
+ if (!this.destroyed) {
184
+ try { this.emit({ type: 'error', integrationDomain: this.domain, data: { message: error instanceof Error ? error.message : String(error) }, timestamp: Date.now() }); }
185
+ catch (callbackError) { this.failures.add(callbackError); }
186
+ }
187
+ if (this.failures.size) {
188
+ this.destroyed = true;
189
+ // Stop admitting updates after a delivery failure; the retained lease owns reporting/retry.
190
+ void this.lease.close().catch(() => undefined);
191
+ }
133
192
  }).finally(() => {
134
193
  this.updating = false;
135
194
  if (this.updateRequested && !this.destroyed) this.queueUpdate();
@@ -158,7 +217,9 @@ class MqttRuntime implements IIntegrationRuntime {
158
217
  }
159
218
 
160
219
  private emit(eventArg: IIntegrationEvent): void {
161
- for (const handler of this.handlers) handler(eventArg);
220
+ const failures: unknown[] = [];
221
+ for (const handler of this.handlers) { try { handler(eventArg); } catch (error) { failures.push(error); } }
222
+ if (failures.length) throw new AggregateError(failures, 'MQTT event callbacks failed.');
162
223
  }
163
224
 
164
225
  private publishOptionsFromRequest(requestArg: IServiceCallRequest): IMqttPublishOptions | undefined {