@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
@@ -0,0 +1,157 @@
1
+ import { validateMqttTopic } from './mqtt.classes.client.js';
2
+ import type { IMqttConfig, IMqttTopicMapping, IMqttTransportCredentials } from './mqtt.types.js';
3
+
4
+ export const mqttRecord = (valueArg: unknown, allowedArg: readonly string[]): Record<string, unknown> => {
5
+ if (!valueArg || typeof valueArg !== 'object'
6
+ || (Object.getPrototypeOf(valueArg) !== Object.prototype && Object.getPrototypeOf(valueArg) !== null)) {
7
+ throw new TypeError('MQTT configuration must be a plain data object.');
8
+ }
9
+ const result: Record<string, unknown> = {};
10
+ for (const key of Reflect.ownKeys(valueArg)) {
11
+ const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
12
+ if (typeof key !== 'string' || !allowedArg.includes(key) || !descriptor?.enumerable || !('value' in descriptor)) {
13
+ throw new TypeError('MQTT configuration contains an unsupported field or accessor.');
14
+ }
15
+ result[key] = descriptor.value as unknown;
16
+ }
17
+ return result;
18
+ };
19
+
20
+ export const mqttText = (valueArg: unknown, maximumArg = 256): string => {
21
+ if (typeof valueArg !== 'string' || !valueArg || valueArg.trim() !== valueArg
22
+ || valueArg.length > maximumArg || /[\p{Cc}\p{Cs}]/u.test(valueArg)) {
23
+ throw new TypeError('MQTT identifier must be a bounded non-empty string without whitespace or control characters.');
24
+ }
25
+ return valueArg;
26
+ };
27
+
28
+ const integer = (valueArg: unknown, maximumArg: number, minimumArg = 1): number | undefined => {
29
+ if (valueArg === undefined) return undefined;
30
+ if (typeof valueArg !== 'number' || !Number.isSafeInteger(valueArg) || valueArg < minimumArg || valueArg > maximumArg) {
31
+ throw new TypeError('MQTT numeric configuration is out of range.');
32
+ }
33
+ return valueArg;
34
+ };
35
+
36
+ const mapping = (valueArg: unknown): IMqttTopicMapping => {
37
+ const value = mqttRecord(valueArg, ['deviceId', 'entityId', 'featureId', 'name', 'deviceName', 'stateTopic', 'availabilityTopic', 'payloadAvailable']);
38
+ const stateTopic = mqttText(value.stateTopic, 65535);
39
+ const availabilityTopic = value.availabilityTopic === undefined ? undefined : mqttText(value.availabilityTopic, 65535);
40
+ validateMqttTopic(stateTopic);
41
+ if (availabilityTopic) validateMqttTopic(availabilityTopic);
42
+ if (value.payloadAvailable !== undefined && (typeof value.payloadAvailable !== 'string' || Buffer.byteLength(value.payloadAvailable) > 4096)) {
43
+ throw new TypeError('MQTT availability payload must be a bounded UTF-8 string.');
44
+ }
45
+ const payloadAvailable = typeof value.payloadAvailable === 'string' ? value.payloadAvailable : undefined;
46
+ if (payloadAvailable !== undefined && availabilityTopic === undefined) throw new TypeError('MQTT availability payload requires its exact topic.');
47
+ return Object.freeze({ deviceId: mqttText(value.deviceId), entityId: mqttText(value.entityId), featureId: mqttText(value.featureId),
48
+ name: mqttText(value.name), deviceName: mqttText(value.deviceName), stateTopic, availabilityTopic, payloadAvailable });
49
+ };
50
+
51
+ /** Capture data without invoking nested accessors before host admission. */
52
+ const mqttData = (valueArg: unknown): unknown => {
53
+ const seen = new Set<object>();
54
+ let entries = 0;
55
+ const copy = (value: unknown, depth: number): unknown => {
56
+ if (++entries > 200000 || depth > 32) throw new TypeError('MQTT configuration data exceeds its bounds.');
57
+ if (value === null || value === undefined || typeof value === 'string' || typeof value === 'boolean') return value;
58
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
59
+ if (typeof value !== 'object' || seen.has(value)) throw new TypeError('MQTT configuration requires finite acyclic data.');
60
+ seen.add(value);
61
+ try {
62
+ if (Array.isArray(value)) {
63
+ if (Reflect.ownKeys(value).length !== value.length + 1) throw new TypeError('MQTT configuration arrays require exact data entries.');
64
+ const result: unknown[] = [];
65
+ for (let index = 0; index < value.length; index++) {
66
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
67
+ if (!descriptor?.enumerable || !('value' in descriptor)) throw new TypeError('MQTT configuration arrays cannot contain accessors or holes.');
68
+ result.push(copy(descriptor.value as unknown, depth + 1));
69
+ }
70
+ return result;
71
+ }
72
+ if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) {
73
+ throw new TypeError('MQTT configuration requires plain data objects.');
74
+ }
75
+ const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
76
+ for (const key of Reflect.ownKeys(value)) {
77
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
78
+ if (typeof key !== 'string' || !descriptor?.enumerable || !('value' in descriptor)) throw new TypeError('MQTT configuration cannot contain accessors.');
79
+ result[key] = copy(descriptor.value as unknown, depth + 1);
80
+ }
81
+ return result;
82
+ } finally { seen.delete(value); }
83
+ };
84
+ return copy(valueArg, 0);
85
+ };
86
+
87
+ export const normalizeMqttIntegrationConfig = (valueArg: unknown): IMqttConfig & { homeId: string } => {
88
+ const fields = mqttRecord(valueArg, ['homeId', 'mode', 'host', 'port', 'protocol', 'credentialKey', 'allowUnencryptedLan', 'mappings',
89
+ 'clientId', 'keepalive', 'clean', 'discovery', 'discoveryPrefix', 'birthMessage', 'willMessage', 'subscriptions',
90
+ 'discoveryMessages', 'retainedMessages', 'entities', 'connectTimeoutMs', 'operationTimeoutMs', 'reconnectPeriodMs',
91
+ 'maxMessageTopics', 'maxPayloadBytes', 'maxBufferedBytes']);
92
+ const value = mqttData(fields) as Record<string, unknown>;
93
+ const homeId = mqttText(value.homeId);
94
+ if (value.mode !== undefined && value.mode !== 'discovery' && value.mode !== 'selected-topics') throw new TypeError('Unsupported MQTT mapping mode.');
95
+ if (value.protocol !== undefined && value.protocol !== 'mqtt' && value.protocol !== 'mqtts') throw new TypeError('Unsupported MQTT transport.');
96
+ const protocol = value.protocol === 'mqtts' ? 'mqtts' : 'mqtt';
97
+ const host = value.host === undefined ? undefined : mqttText(value.host, 253);
98
+ if (host && (/[\s/@?#\\:]/u.test(host) || host === '.')) throw new TypeError('MQTT host must be a selected hostname or IPv4 address, without URL or credentials.');
99
+ if (value.allowUnencryptedLan !== undefined && typeof value.allowUnencryptedLan !== 'boolean') throw new TypeError('MQTT LAN consent must be explicit.');
100
+ if (host && protocol === 'mqtt' && value.allowUnencryptedLan !== true) throw new TypeError('Unencrypted MQTT requires explicit LAN consent.');
101
+ const credentialKey = value.credentialKey === undefined ? undefined : mqttText(value.credentialKey);
102
+ const port = integer(value.port, 65535) ?? (protocol === 'mqtts' ? 8883 : 1883);
103
+ integer(value.connectTimeoutMs, 2147483647);
104
+ integer(value.operationTimeoutMs, 2147483647);
105
+ integer(value.reconnectPeriodMs, 2147483647, 0);
106
+ integer(value.keepalive, 65535, 0);
107
+ const topicLimit = integer(value.maxMessageTopics, 100000) ?? 4096;
108
+ integer(value.maxPayloadBytes, 16777216);
109
+ integer(value.maxBufferedBytes, 1073741824);
110
+ if (value.clean !== undefined && typeof value.clean !== 'boolean') throw new TypeError('MQTT clean session must be a boolean.');
111
+ if (value.discovery !== undefined && typeof value.discovery !== 'boolean') throw new TypeError('MQTT discovery must be a boolean.');
112
+ if (value.clientId !== undefined) mqttText(value.clientId);
113
+ if (value.clean === false && value.clientId === undefined) throw new TypeError('Persistent MQTT sessions require an explicit client ID.');
114
+ if (value.mode === 'selected-topics') {
115
+ if (!host || !Array.isArray(value.mappings) || !value.mappings.length || value.mappings.length > topicLimit) {
116
+ throw new TypeError('Selected MQTT requires a broker and bounded explicit mappings.');
117
+ }
118
+ if (value.discovery === true || value.birthMessage !== undefined && value.birthMessage !== false
119
+ || value.willMessage !== undefined && value.willMessage !== false || value.clean === false
120
+ || value.reconnectPeriodMs !== undefined && value.reconnectPeriodMs !== 0
121
+ || ['subscriptions', 'discoveryMessages', 'retainedMessages', 'entities', 'discoveryPrefix'].some((key) => value[key] !== undefined)) {
122
+ throw new TypeError('Selected MQTT mappings cannot enable discovery, broker effects, fixtures or persistent sessions.');
123
+ }
124
+ const mappings = value.mappings.map((itemArg: unknown) => mapping(itemArg));
125
+ const entities = new Set<string>();
126
+ const features = new Set<string>();
127
+ const devices = new Map<string, string>();
128
+ for (const item of mappings) {
129
+ const feature = JSON.stringify([item.deviceId, item.featureId]);
130
+ if (entities.has(item.entityId) || features.has(feature) || devices.has(item.deviceId) && devices.get(item.deviceId) !== item.deviceName) {
131
+ throw new TypeError('Selected MQTT mappings require unique entities/features and consistent device names.');
132
+ }
133
+ entities.add(item.entityId); features.add(feature); devices.set(item.deviceId, item.deviceName);
134
+ }
135
+ const subscriptions = [...new Set(mappings.flatMap((item) => [item.stateTopic, ...(item.availabilityTopic ? [item.availabilityTopic] : [])]))];
136
+ if (subscriptions.length > topicLimit) throw new TypeError('Selected MQTT topic limit exceeded.');
137
+ return Object.freeze({ ...structuredClone(value) as IMqttConfig, homeId, host, port, protocol, credentialKey,
138
+ mode: 'selected-topics', mappings, discovery: false, birthMessage: false, willMessage: false,
139
+ clean: true, reconnectPeriodMs: 0 });
140
+ }
141
+ if (value.mappings !== undefined) throw new TypeError('MQTT mappings require selected-topics mode.');
142
+ return Object.freeze({ ...structuredClone(value) as IMqttConfig, homeId, host, port, protocol, credentialKey, mode: 'discovery' });
143
+ };
144
+
145
+ export const mqttCredentials = (valueArg: unknown): IMqttTransportCredentials => {
146
+ const value = mqttRecord(valueArg, ['username', 'password', 'ca', 'cert', 'key', 'servername']);
147
+ const result: IMqttTransportCredentials = {};
148
+ for (const key of ['username', 'password', 'ca', 'cert', 'key', 'servername'] as const) {
149
+ const item = value[key];
150
+ if (item === undefined) continue;
151
+ if (typeof item !== 'string' || item.length > 1048576 || item.includes('\u0000')) throw new TypeError('MQTT caller credentials are invalid.');
152
+ result[key] = item;
153
+ }
154
+ if ((result.cert === undefined) !== (result.key === undefined)) throw new TypeError('MQTT client certificate and key must be supplied together.');
155
+ if (result.servername !== undefined) mqttText(result.servername, 253);
156
+ return result;
157
+ };
@@ -2,7 +2,9 @@
2
2
 
3
3
  `MqttIntegration` connects directly to a broker over MQTT 3.1.1 TCP or TLS through MQTT.js. No Home Assistant server or access token is involved. Setup completes after broker connection and owned subscription acknowledgements. MQTT has no end marker for retained discovery replay; newly received discovery produces normalized events and later snapshots.
4
4
 
5
- Configuration accepts `host`, `port`, `protocol: 'mqtt' | 'mqtts'`, `username`, `password`, `clientId`, `clean`, and `keepalive`. TLS verifies certificates by default. Persistent sessions require an explicit client ID. Credentials remain in runtime configuration; this integration does not persist them.
5
+ Construct `MqttIntegration` with an optional host-home consistency binding. A selected `credentialKey` requires an explicit constructor `resolveCredentials` callback; a connection without credentials needs neither resolver nor store. Setup and config-flow start require an independently selected `context.homeId`; configuration `homeId` must match it. Configuration accepts endpoint, `credentialKey`, mapping and transport limits, with an explicit `allowUnencryptedLan: true` acknowledgment for plain MQTT. MQTTS is the flow default. Persist credential references rather than usernames, passwords, private CA or client key material. The borrowed resolver receives immutable `{ homeId, credentialKey, signal, deadline }`, with an absolute `performance.now()` deadline, and returns runtime-only `{ username?, password?, ca?, cert?, key?, servername? }` strings. Certificate and key must be supplied together. The caller resolver can retrieve an encrypted credential record through its own actual `NoSqlConfigStore` public API. Any store supplied in native context must match the admitted home and MQTT domain. The integration does not read credentials from context storage automatically or substitute it for a missing resolver. TLS verifies the certificate and hostname. No secret files are read, and provider, database and store remain caller-owned.
6
+
7
+ Construction is inert. Setup, config-flow work, live runtimes and failed cleanup share the native owner capacity, default 16. Terminal destruction publishes a stable promise before abort and joins actual provider callbacks even after caller cancellation or deadline. A provider that never settles keeps cleanup pending; a late unrelated rejection remains observable through sanitized cleanup. Explicit `retryCleanup()` retries cleanup only, without resolving credentials, reconnecting, subscribing or publishing again.
6
8
 
7
9
  Discovery defaults to `homeassistant/#`. Both single-component and device discovery messages are supported, including abbreviations and topic bases. The latest configuration for each discovery topic replaces its predecessor; an empty payload removes it. State, availability, and attribute topics are subscribed dynamically and removed when no longer referenced. Availability lists support `all`, `any`, and `latest`. A disconnected broker marks its devices unavailable immediately.
8
10
 
@@ -12,17 +14,24 @@ The adapter reads supported canonical platforms. Writes support default-schema p
12
14
 
13
15
  Snapshots retain one latest message per topic, bounded by `maxMessageTopics` (4096), `maxPayloadBytes` (262144), and `maxBufferedBytes` (8388608). A limit violation closes the owned connection. These settings bound application snapshots, not broker retention or the protocol library's frame parser.
14
16
 
17
+ The reusable low-level `MqttClient` preserves its first-argument username/password configuration API. Its optional second argument accepts runtime-only `IMqttTransportCredentials` for CA/mTLS use; the integration configuration migration above does not remove that raw-client API.
18
+
15
19
  `MqttClient` is reusable by native integrations:
16
20
 
17
21
  - `start(signal?)`, `publish(options, signal?)`, and `subscribe(filter, handler?, signal?)` are bounded operations.
18
22
  - `subscribe` callbacks receive only matching topics. Its returned asynchronous cleanup sends UNSUBSCRIBE after the final owner releases the filter.
19
23
  - `connected`, `isHealthy()`, and `onConnectionState(handler)` expose subscription readiness and connection loss.
20
24
  - `onMessage(handler)` observes messages on owned subscriptions without subscribing to the entire broker.
21
- - `getSnapshot(signal?)` returns current discovery and state.
22
- - `destroy()` closes resources and is terminal and idempotent.
25
+ - `getSnapshot(signal?)` preserves historical discovery and state metadata.
26
+ - Synchronous `getCurrentSnapshot()` clones only observations from the current live broker connection. Register state/message handlers before seeding readiness from it; fixtures and earlier connections are excluded, while current retained packets received before SUBACK are included. Eligibility clears before disconnect/error callbacks.
27
+ - `destroy()` closes resources and keeps its terminal promise, including its original failure. Explicit `retryCleanup()` retries retained transport cleanup without replaying connection setup, subscriptions or publishes.
23
28
 
24
29
  Abort or timeout closes the physical connection that owns the operation before rejection; an explicit subsequent `start()` can establish a fresh connection. A stale operation cannot close a later connection generation. Subscriber cleanup is independent of transport destruction.
25
30
 
26
31
  For an ecosystem that owns its own MQTT topics, set `discovery: false`, `birthMessage: false`, and `willMessage: false`, then subscribe only its selected topics. Fixture snapshots remain supported through `entities`, `discoveryMessages`, and `retainedMessages`; they do not claim a healthy broker connection.
27
32
 
33
+ Selected-topic mode uses `mode: 'selected-topics'` and explicit non-empty mappings with owner-selected device, entity and feature IDs, names and one exact `stateTopic`; optional exact `availabilityTopic` uses `payloadAvailable`, default `online`. Payloads remain raw UTF-8 strings and all mapped features are read-only sensors. Only the mapping topic union is subscribed. Wildcards, caller subscription arrays, discovery, birth/will, fixture snapshots, persistent sessions, reconnect and generic publish are refused in this mode. Unselected messages are ignored before caching. Disconnect immediately marks observations unavailable and requires a newly admitted setup. Mapping IDs and broker observations do not attest physical device identity.
34
+
35
+ Discovery mode remains the default and preserves the existing discovery/control surface. Connection loss during an admitted publish closes that owning connection generation before MQTT.js can replay an unacknowledged effect. Subscription-only reconnection remains supported; old-generation cancellation and cleanup cannot close a successor. No broker acknowledgment proves physical execution or application-level deduplication.
36
+
28
37
  References: [MQTT.js 5.16.0](https://github.com/mqttjs/MQTT.js/tree/v5.16.0), [MQTT discovery](https://www.home-assistant.io/integrations/mqtt/), and [MQTT 3.1.1](https://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html).
@@ -1,11 +1,11 @@
1
1
  # Samsung Smart TV
2
2
 
3
- The native integration consumes `SamsungTvProtocol` from published `@ecobridge.xyz/devicemanager`. It owns configuration, encrypted pairing and smarthome mapping; the dependency owns HTTP/TLS/WebSocket parsing, identity verification, operation deadlines and transport cleanup.
3
+ The native integration consumes `SamsungTvProtocol` from published `@ecobridge.xyz/devicemanager` 4.11.1. It owns configuration, encrypted pairing, home admission and smarthome mapping; the dependency owns HTTP/TLS/WebSocket parsing, identity verification, operation deadlines and transport cleanup.
4
4
 
5
5
  Configure an exact `homeId`, the UUID reported by the TV, explicit `httpOrigin` and `websocketOrigin`, and `pairingMode: 'prompt' | 'existing'`. WSS certificate and hostname verification are mandatory. `certificateAuthority` adds an explicitly trusted PEM CA chain; it never disables verification. REST and WSS must use the same host. Local HTTP discovery requires `allowUnencryptedLocalDiscovery: true` and is restricted by the protocol to literal private/link-local/loopback IPv4 or ULA/loopback IPv6. HTTP carries no token or credentials and follows no redirects.
6
6
 
7
7
  ```typescript
8
- const integration = new SamsungtvIntegration({ db, keyProvider });
8
+ const integration = new SamsungtvIntegration({ homeId: 'home-one', db, keyProvider });
9
9
  const runtime = await integration.setup({
10
10
  homeId: 'home-one',
11
11
  expectedDeviceId: '2ff68485-c4cb-48c7-a6ac-9c50e44d7084',
@@ -14,14 +14,20 @@ const runtime = await integration.setup({
14
14
  certificateAuthority: trustedTvCa,
15
15
  allowUnencryptedLocalDiscovery: true,
16
16
  pairingMode: 'prompt',
17
- }, {});
17
+ }, { homeId: 'home-one' });
18
18
  ```
19
19
 
20
- The database and external encryption key provider are caller-owned. The default registry constructor instead accepts a context `NoSqlConfigStore` scoped to the exact configured home and `samsungtv` domain. Generic stores and incorrect scopes are rejected. Pairing credentials never appear in config flow results, snapshots, entities or logs. Confirmed tokens are saved through encrypted document models before controls become ready, and are bound to the exact UUID and WSS origin. Existing mode requires prior pairing in the same home. Runtime destroy leaves caller-owned stores/databases open, drains accepted persistence and transports, clears the protocol reference and is terminal. No filesystem application state is written. Clearing JavaScript references does not guarantee physical string zeroization.
20
+ Every setup and config flow requires an independently host-selected context `homeId`. Configuration must match that selection. An optional constructor `homeId` prebinds the integration; otherwise the first valid admission binds it for its lifetime. Another home requires another integration instance. Constructor options, configuration, context, discovery hints and controls are captured as data fields before asynchronous work; accessors cannot supply authority. Config forms derive their home from the captured host context, and integration or flow destruction invalidates outstanding forms.
21
+
22
+ The standalone `SamsungtvClient` accepts `ISamsungtvClientOptions` containing `{ db, keyProvider }`, or an actual scoped `NoSqlConfigStore`. The integration's `homeId` and `capacity` options belong to `ISamsungtvIntegrationOptions`.
23
+
24
+ The database and external encryption key provider are caller-owned. The default registry constructor instead accepts a context `NoSqlConfigStore` scoped to the exact configured home and `samsungtv` domain. Generic stores, incorrect scopes and overridden scope claims are rejected. Pairing credentials never appear in config flow results, snapshots, entities or logs. Confirmed tokens are saved through encrypted document models before controls become ready, and are bound to the exact UUID and WSS origin. Existing mode requires prior pairing in the same home. Runtime destroy leaves caller-owned stores/databases open, drains accepted persistence and transports, clears the protocol reference after successful cleanup and is terminal. No filesystem application state is written. Clearing JavaScript references does not guarantee physical string zeroization.
25
+
26
+ The native owner tracks accepted setup operations, runtimes and failed cleanup under one `capacity` limit (default 16). Cleanup is registered before startup and transferred to the runtime only after successful setup. Integration destruction joins pending setups and every runtime; a failed cleanup retains its handle and capacity until explicit `integration.retryCleanup()`. Client and protocol close promises are published before synchronous abort callbacks and preserve their original result. A late pairing-persistence failure becomes a safe `PERSISTENCE_FAILED` cleanup error. The client also drains its owned encrypted store even when protocol cleanup fails. `client.retryCleanup()` requires terminal destruction and acknowledges settled cleanup through the published protocol/store APIs. Neither cleanup API repeats pairing, token persistence, startup or commands, and neither reopens a destroyed owner. A failed setup can free retained capacity through the integration's cleanup API while an otherwise open integration remains available for an explicitly requested new setup.
21
27
 
22
28
  Every snapshot reads the current device information and observed power. Failed reads propagate; no manual fallback, guessed playback/source/volume, optimistic state or synthetic availability is produced. Device and entity IDs include the escaped home and exact TV UUID. `power` is read-only; `remote_key` admits one exact currently supported `KEY_` code. Directional keys additionally require the TV's current capability declaration.
23
29
 
24
- Services require the exact owned `deviceId` or `entityId`. Supported media-player services are `turn_off`, `media_play`, `media_pause`, `media_stop`, `volume_up` and `volume_down`. `remote.send_command` accepts exactly `{ command: 'KEY_HOME' }`; `samsungtv.send_key` accepts exactly `{ key: 'KEY_HOME' }`. `samsungtv.snapshot` accepts no data fields. Commands return `{ key, transport: 'sent', effectOutcome: 'indeterminate' }`: Samsung's remote channel does not acknowledge physical effects. Errors retain the protocol's safe code and effect outcome. No command is retried, repeated or replayed and concurrent effects are rejected.
30
+ Services require the exact owned `deviceId` or `entityId`; when both are present, both must match. Feature writes require the exact owned device and writable `remote_key` media feature, with current key capability rechecked by the protocol. Destruction permanently refuses observations and controls. Supported media-player services are `turn_off`, `media_play`, `media_pause`, `media_stop`, `volume_up` and `volume_down`. `remote.send_command` accepts exactly `{ command: 'KEY_HOME' }`; `samsungtv.send_key` accepts exactly `{ key: 'KEY_HOME' }`. `samsungtv.snapshot` accepts no data fields. Commands return `{ key, transport: 'sent', effectOutcome: 'indeterminate' }`: Samsung's remote channel does not acknowledge physical effects. Errors retain the protocol's safe code and effect outcome. No command is retried, repeated or replayed and concurrent effects are rejected.
25
31
 
26
32
  The previous fixture config, plaintext token/host/port config, arbitrary WebSocket command, repeated keys and optimistic media fields have been removed. Reconfigure using the explicit native config above. `play_pause`, absolute mute/volume assignment, source/app selection, channel keys as track controls, power-on/Wake-on-LAN, press/hold/release, legacy TCP/PIN and Frame Art Mode remain unsupported. An explicit `KEY_MUTE` command is a toggle and is never presented as a boolean mute assignment.
27
33
 
@@ -1,6 +1,6 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { NoSqlConfigStore } from '../../core/classes.configstore.js';
3
- import type { ISamsungtvConfig, ISamsungtvIntegrationOptions, ISamsungtvSnapshot, TSamsungtvRemoteKey } from './samsungtv.types.js';
3
+ import type { ISamsungtvConfig, ISamsungtvClientOptions, ISamsungtvSnapshot, TSamsungtvRemoteKey } from './samsungtv.types.js';
4
4
  import { normalizeSamsungtvConfig, samsungtvKeys, samsungtvObject, samsungtvString } from './samsungtv.validation.js';
5
5
 
6
6
  interface IStoredPairing {
@@ -18,24 +18,27 @@ export class SamsungtvClient {
18
18
  #protocol?: plugins.ecobridgeDeviceManager.SamsungTvProtocol;
19
19
  #start?: Promise<void>;
20
20
  #destroy?: Promise<void>;
21
+ #retryCleanup?: Promise<void>;
21
22
  #removeAbort: () => void = () => undefined;
22
23
 
23
- constructor(configArg: ISamsungtvConfig, optionsArg?: ISamsungtvIntegrationOptions | NoSqlConfigStore, signalArg?: AbortSignal) {
24
+ constructor(configArg: ISamsungtvConfig, optionsArg?: ISamsungtvClientOptions | NoSqlConfigStore, signalArg?: AbortSignal) {
24
25
  this.#config = normalizeSamsungtvConfig(configArg);
25
26
  if (optionsArg instanceof NoSqlConfigStore) {
26
- const scope = optionsArg.getScope();
27
+ const scope = NoSqlConfigStore.prototype.getScope.call(optionsArg);
27
28
  if (scope.homeId !== this.#config.homeId || scope.integrationDomain !== 'samsungtv') {
28
29
  throw new TypeError('Samsung TV requires an encrypted NoSqlConfigStore scoped to the exact configured home and samsungtv domain.');
29
30
  }
30
31
  this.#store = optionsArg;
31
32
  this.#ownsStore = false;
32
33
  } else {
33
- if (!optionsArg?.db || !optionsArg.keyProvider) throw new TypeError('Samsung TV requires an owner-supplied database and external encryption key provider.');
34
- this.#store = new NoSqlConfigStore({ db: optionsArg.db, keyProvider: optionsArg.keyProvider, homeId: this.#config.homeId, integrationDomain: 'samsungtv' });
34
+ const options = samsungtvObject(optionsArg ?? {}, 'Samsung TV client persistence options');
35
+ samsungtvKeys(options, ['db', 'keyProvider']);
36
+ if (!options.db || !options.keyProvider) throw new TypeError('Samsung TV requires an owner-supplied database and external encryption key provider.');
37
+ this.#store = new NoSqlConfigStore({ db: options.db as ISamsungtvClientOptions['db'], keyProvider: options.keyProvider as ISamsungtvClientOptions['keyProvider'], homeId: this.#config.homeId, integrationDomain: 'samsungtv' });
35
38
  this.#ownsStore = true;
36
39
  }
37
40
  if (signalArg) {
38
- const aborted = (): void => { void this.destroy(); };
41
+ const aborted = (): void => { void this.destroy().catch(() => undefined); };
39
42
  if (signalArg.aborted) this.#lifetime.abort(new Error('Samsung TV setup was cancelled.'));
40
43
  else {
41
44
  signalArg.addEventListener('abort', aborted, { once: true });
@@ -48,7 +51,7 @@ export class SamsungtvClient {
48
51
 
49
52
  public async start(signalArg?: AbortSignal): Promise<void> {
50
53
  this.#assertOpen(signalArg);
51
- if (!this.#start) this.#start = this.#initialize(signalArg);
54
+ if (!this.#start) this.#start = Promise.resolve().then(() => this.#initialize(signalArg));
52
55
  await this.#start;
53
56
  this.#assertOpen(signalArg);
54
57
  }
@@ -67,20 +70,42 @@ export class SamsungtvClient {
67
70
 
68
71
  public destroy(): Promise<void> {
69
72
  if (!this.#destroy) {
73
+ // Publish before aborting foreign callbacks; retain failed cleanup for explicit acknowledgement.
74
+ this.#destroy = Promise.resolve().then(() => this.#drain(false));
70
75
  this.#lifetime.abort(new Error('Samsung TV client is destroyed.'));
71
76
  this.#removeAbort();
72
- this.#destroy = (async () => {
73
- await this.#protocol?.close();
74
- if (this.#start) await Promise.allSettled([this.#start]);
75
- await this.#protocol?.close();
76
- this.#protocol = undefined;
77
- if (this.#ownsStore) await this.#store?.close();
78
- this.#store = undefined;
79
- })();
77
+ this.#removeAbort = () => undefined;
80
78
  }
81
79
  return this.#destroy;
82
80
  }
83
81
 
82
+ /** Terminal cleanup only; never repeats pairing persistence, startup or commands. */
83
+ public retryCleanup(): Promise<void> {
84
+ if (!this.#destroy) return Promise.reject(new Error('Samsung TV cleanup requires terminal destroy.'));
85
+ this.#retryCleanup ??= Promise.resolve().then(async () => {
86
+ await this.#destroy!.catch(() => undefined);
87
+ await this.#drain(true);
88
+ });
89
+ return this.#retryCleanup;
90
+ }
91
+
92
+ async #drain(retryArg: boolean): Promise<void> {
93
+ const protocol = this.#protocol;
94
+ const store = this.#store;
95
+ // Close both owners before joining either: persistence providers may await store cancellation.
96
+ const cleanup = Promise.allSettled([
97
+ Promise.resolve().then(() => retryArg ? protocol?.retryCleanup() : protocol?.close()),
98
+ Promise.resolve().then(() => this.#ownsStore ? (retryArg ? store?.retryCleanup() : store?.close()) : undefined),
99
+ ]);
100
+ // Startup errors belong to the start caller; protocol close owns late persistence failures.
101
+ const [results] = await Promise.all([cleanup, Promise.allSettled(this.#start ? [this.#start] : [])]);
102
+ if (results[0].status === 'fulfilled') this.#protocol = undefined;
103
+ if (results[1].status === 'fulfilled') this.#store = undefined;
104
+ const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected').map((result) => result.reason as unknown);
105
+ if (errors.length) throw new AggregateError(errors, 'Samsung TV client cleanup failed; resources remain retained.');
106
+ this.#start = undefined;
107
+ }
108
+
84
109
  async #initialize(signalArg?: AbortSignal): Promise<void> {
85
110
  const signal = this.#signal(signalArg);
86
111
  const store = this.#store;
@@ -1,20 +1,28 @@
1
1
  import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
2
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
2
3
  import type { ISamsungtvConfig } from './samsungtv.types.js';
3
4
  import { normalizeSamsungtvConfig, samsungtvKeys, samsungtvObject } from './samsungtv.validation.js';
4
5
 
5
6
  export class SamsungtvConfigFlow implements IConfigFlow<ISamsungtvConfig> {
7
+ readonly #scope: NativeIntegrationScope;
8
+
9
+ constructor(ownerArg: NativeIntegrationOwner = new NativeIntegrationOwner('samsungtv')) {
10
+ this.#scope = ownerArg.createFlowScope();
11
+ }
12
+
6
13
  public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISamsungtvConfig>> {
7
- void contextArg;
8
- const metadata = candidateArg.metadata === undefined ? {} : samsungtvObject(candidateArg.metadata, 'Samsung TV candidate metadata');
14
+ const context = this.#scope.admit(contextArg);
15
+ const candidate = samsungtvObject(candidateArg, 'Samsung TV discovery candidate');
16
+ const metadata = candidate.metadata === undefined ? {} : samsungtvObject(candidate.metadata, 'Samsung TV candidate metadata');
9
17
  const hints: Record<string, unknown> = {};
10
18
  for (const field of ['httpOrigin', 'websocketOrigin', 'expectedDeviceId']) {
11
19
  if (typeof metadata[field] === 'string') hints[field] = metadata[field];
12
20
  }
21
+ Object.freeze(hints);
13
22
  return {
14
23
  kind: 'form', title: 'Pair Samsung Smart TV',
15
- description: 'Confirm this home, the exact TV UUID, explicit REST/WSS origins and trusted TV CA. Confirm the TV pairing prompt when using prompt mode. Pairing credentials are stored only in encrypted home-scoped persistence.',
24
+ description: 'Confirm the exact TV UUID, explicit REST/WSS origins and trusted TV CA for the selected home. Confirm the TV pairing prompt when using prompt mode. Pairing credentials are stored only in encrypted home-scoped persistence.',
16
25
  fields: [
17
- { name: 'homeId', label: 'Home ID', type: 'text', required: true },
18
26
  { name: 'expectedDeviceId', label: 'Exact TV UUID', type: 'text', required: true },
19
27
  { name: 'httpOrigin', label: 'REST origin', type: 'text', required: true },
20
28
  { name: 'websocketOrigin', label: 'Verified WSS origin', type: 'text', required: true },
@@ -27,17 +35,24 @@ export class SamsungtvConfigFlow implements IConfigFlow<ISamsungtvConfig> {
27
35
  try {
28
36
  const values = samsungtvObject(valuesArg, 'Samsung TV config form');
29
37
  samsungtvKeys(values, ['homeId', 'expectedDeviceId', 'httpOrigin', 'websocketOrigin', 'pairingMode', 'certificateAuthority', 'allowUnencryptedLocalDiscovery']);
38
+ if (values.homeId !== undefined && values.homeId !== context.homeId) throw new Error('Samsung TV form home does not match the selected host home.');
30
39
  const config = normalizeSamsungtvConfig({
31
- homeId: values.homeId, expectedDeviceId: values.expectedDeviceId ?? hints.expectedDeviceId,
40
+ homeId: context.homeId, expectedDeviceId: values.expectedDeviceId ?? hints.expectedDeviceId,
32
41
  httpOrigin: values.httpOrigin ?? hints.httpOrigin, websocketOrigin: values.websocketOrigin ?? hints.websocketOrigin,
33
42
  pairingMode: values.pairingMode, certificateAuthority: values.certificateAuthority,
34
43
  allowUnencryptedLocalDiscovery: values.allowUnencryptedLocalDiscovery,
35
44
  });
36
- return { kind: 'done', title: 'Samsung Smart TV configuration accepted', config };
45
+ return await this.#scope.run(context, config.homeId, async (leaseArg) => {
46
+ leaseArg.assertOpen();
47
+ return { kind: 'done', title: 'Samsung Smart TV configuration accepted', config };
48
+ });
37
49
  } catch (errorArg) {
38
50
  return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : 'Samsung TV configuration is invalid.' };
39
51
  }
40
52
  },
41
53
  };
42
54
  }
55
+
56
+ public destroy(): Promise<void> { return this.#scope.close(); }
57
+ public retryCleanup(): Promise<void> { return this.#scope.retryCleanup(); }
43
58
  }
@@ -1,13 +1,13 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
- import { NoSqlConfigStore } from '../../core/classes.configstore.js';
3
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
4
4
  import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
5
5
  import { SamsungtvClient } from './samsungtv.classes.client.js';
6
6
  import { SamsungtvConfigFlow } from './samsungtv.classes.configflow.js';
7
7
  import { createSamsungtvDiscoveryDescriptor } from './samsungtv.discovery.js';
8
8
  import { SamsungtvMapper } from './samsungtv.mapper.js';
9
- import type { ISamsungtvConfig, ISamsungtvIntegrationOptions, ISamsungtvSnapshot, TSamsungtvRemoteKey } from './samsungtv.types.js';
10
- import { samsungtvKeys, samsungtvObject } from './samsungtv.validation.js';
9
+ import type { ISamsungtvConfig, ISamsungtvClientOptions, ISamsungtvIntegrationOptions, ISamsungtvSnapshot, TSamsungtvRemoteKey } from './samsungtv.types.js';
10
+ import { normalizeSamsungtvConfig, samsungtvKeys, samsungtvObject } from './samsungtv.validation.js';
11
11
 
12
12
  const serviceKeys = {
13
13
  turn_off: 'KEY_POWEROFF', media_play: 'KEY_PLAY', media_pause: 'KEY_PAUSE', media_stop: 'KEY_STOP',
@@ -32,65 +32,77 @@ export class SamsungtvIntegration extends BaseIntegration<ISamsungtvConfig> {
32
32
  public readonly displayName = 'Samsung Smart TV';
33
33
  public readonly status = 'control-runtime' as const;
34
34
  public readonly discoveryDescriptor = createSamsungtvDiscoveryDescriptor();
35
- public readonly configFlow = new SamsungtvConfigFlow();
35
+ public readonly configFlow: SamsungtvConfigFlow;
36
36
  public readonly metadata = {
37
37
  source: 'home-assistant/core', upstreamDomain: 'samsungtv', integrationType: 'device', iotClass: 'local_poll',
38
38
  localApi: { implemented: ['verified UUID and WSS pairing', 'encrypted home-scoped pairing', 'fresh REST observations', 'single supported remote Click'],
39
39
  explicitUnsupported: ['optimistic state', 'automatic retries or replay', 'legacy TCP/PIN', 'play_pause', 'app launch', 'absolute volume/mute assignment', 'wake-on-LAN', 'hold/release/repeated keys'] },
40
40
  };
41
- readonly #runtimes = new Set<SamsungtvRuntime>();
42
- readonly #pending = new Set<SamsungtvClient>();
43
- #options?: ISamsungtvIntegrationOptions;
44
- #destroyed = false;
45
- #destroy?: Promise<void>;
41
+ readonly #owner: NativeIntegrationOwner;
42
+ #options?: ISamsungtvClientOptions;
46
43
 
47
- constructor(optionsArg?: ISamsungtvIntegrationOptions) {
44
+ constructor(optionsArg: ISamsungtvIntegrationOptions = {}) {
48
45
  super();
49
- this.#options = optionsArg ? { db: optionsArg.db, keyProvider: optionsArg.keyProvider } : undefined;
46
+ const options = samsungtvObject(optionsArg, 'Samsung TV integration options');
47
+ samsungtvKeys(options, ['homeId', 'capacity', 'db', 'keyProvider']);
48
+ if ((options.db === undefined) !== (options.keyProvider === undefined)) {
49
+ throw new TypeError('Samsung TV database and key provider must be supplied together.');
50
+ }
51
+ this.#options = options.db === undefined ? undefined : {
52
+ db: options.db as ISamsungtvClientOptions['db'], keyProvider: options.keyProvider as ISamsungtvClientOptions['keyProvider'],
53
+ };
54
+ this.#owner = new NativeIntegrationOwner(this.domain, { homeId: options.homeId as string | undefined, capacity: options.capacity as number | undefined });
55
+ this.configFlow = new SamsungtvConfigFlow(this.#owner);
50
56
  }
51
57
 
52
58
  public async setup(configArg: ISamsungtvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
53
- if (this.#destroyed) throw new Error('Samsung TV integration is destroyed.');
54
- if (contextArg.configStore !== undefined && !(contextArg.configStore instanceof NoSqlConfigStore)) {
55
- throw new TypeError('Samsung TV runtime requires an encrypted NoSqlConfigStore.');
56
- }
57
- const client = new SamsungtvClient(configArg, contextArg.configStore ?? this.#options, contextArg.abortSignal);
58
- this.#pending.add(client);
59
- try {
60
- const snapshot = await client.getSnapshot(contextArg.abortSignal);
61
- if (this.#destroyed) throw new Error('Samsung TV integration was destroyed during setup.');
62
- const runtime = new SamsungtvRuntime(client, snapshot, () => this.#runtimes.delete(runtime));
63
- this.#runtimes.add(runtime);
59
+ const config = normalizeSamsungtvConfig(configArg);
60
+ return this.#owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
61
+ const client = new SamsungtvClient(config, leaseArg.context.configStore ?? this.#options, leaseArg.signal);
62
+ let cleanupAttempted = false;
63
+ leaseArg.own(() => {
64
+ if (cleanupAttempted) return client.retryCleanup();
65
+ cleanupAttempted = true;
66
+ return client.destroy();
67
+ });
68
+ const snapshot = await client.getSnapshot(leaseArg.signal);
69
+ leaseArg.assertOpen();
70
+ const runtime = new SamsungtvRuntime(client, snapshot, leaseArg);
71
+ leaseArg.promoteToRuntime();
64
72
  return runtime;
65
- } catch (errorArg) { await client.destroy(); throw errorArg; }
66
- finally { this.#pending.delete(client); }
73
+ });
67
74
  }
68
75
 
69
76
  public destroy(): Promise<void> {
70
- if (!this.#destroy) {
71
- this.#destroyed = true;
72
- this.#destroy = Promise.all([
73
- ...[...this.#runtimes].map((runtimeArg) => runtimeArg.destroy()),
74
- ...[...this.#pending].map((clientArg) => clientArg.destroy()),
75
- ]).then(() => { this.#options = undefined; });
76
- }
77
- return this.#destroy;
77
+ const closed = this.#owner.close();
78
+ this.#options = undefined;
79
+ return closed;
78
80
  }
81
+
82
+ public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
79
83
  }
80
84
 
81
85
  export class HomeAssistantSamsungtvIntegration extends SamsungtvIntegration {}
82
86
 
83
87
  class SamsungtvRuntime implements IIntegrationRuntime {
84
88
  public readonly domain = 'samsungtv';
85
- constructor(private readonly client: SamsungtvClient, private readonly identity: ISamsungtvSnapshot, private readonly onDestroyed: () => void) {}
89
+ readonly #deviceId: string;
90
+ constructor(private readonly client: SamsungtvClient, identityArg: ISamsungtvSnapshot, private readonly lease: INativeSetupLease) {
91
+ this.#deviceId = SamsungtvMapper.deviceId(identityArg);
92
+ }
86
93
  public isHealthy(): boolean { return this.client.isHealthy(); }
87
94
  public async devices(signalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
95
+ this.lease.assertOpen();
88
96
  return SamsungtvMapper.toDevices(await this.client.getSnapshot(signalArg));
89
97
  }
90
- public async entities(): Promise<IIntegrationEntity[]> { return SamsungtvMapper.toEntities(await this.client.getSnapshot()); }
98
+ public async entities(): Promise<IIntegrationEntity[]> {
99
+ this.lease.assertOpen();
100
+ return SamsungtvMapper.toEntities(await this.client.getSnapshot());
101
+ }
91
102
 
92
103
  public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
93
104
  try {
105
+ this.lease.assertOpen();
94
106
  const request = samsungtvObject(requestArg, 'Samsung TV service request');
95
107
  samsungtvKeys(request, ['domain', 'service', 'target', 'data']);
96
108
  this.#assertTarget(request.target);
@@ -117,24 +129,27 @@ class SamsungtvRuntime implements IIntegrationRuntime {
117
129
 
118
130
  public async writeFeature(requestArg: IDeviceFeatureWriteRequest, signalArg?: AbortSignal): Promise<IServiceCallResult> {
119
131
  try {
132
+ this.lease.assertOpen();
120
133
  const request = samsungtvObject(requestArg, 'Samsung TV feature write');
121
134
  samsungtvKeys(request, ['device', 'feature', 'value']);
122
135
  const device = samsungtvObject(request.device, 'Samsung TV feature device');
123
136
  const feature = samsungtvObject(request.feature, 'Samsung TV feature');
124
- if (device.id !== SamsungtvMapper.deviceId(this.identity) || device.integrationDomain !== this.domain) {
137
+ if (device.id !== this.#deviceId || device.integrationDomain !== this.domain) {
125
138
  throw new TypeError('Samsung TV feature device is not owned by this runtime.');
126
139
  }
127
- if (feature.id !== 'remote_key') throw new TypeError('Samsung TV feature is not writable.');
140
+ if (feature.id !== 'remote_key' || feature.capability !== 'media' || feature.readable !== false || feature.writable !== true) {
141
+ throw new TypeError('Samsung TV feature is not the owned writable remote key.');
142
+ }
128
143
  return { success: true, data: await this.client.sendKey(remoteKey(request.value), signalArg) };
129
144
  } catch (errorArg) { return failure(errorArg); }
130
145
  }
131
146
 
132
- public async destroy(): Promise<void> { await this.client.destroy(); this.onDestroyed(); }
147
+ public destroy(): Promise<void> { return this.lease.close(); }
133
148
 
134
149
  #assertTarget(valueArg: unknown): void {
135
150
  const target = samsungtvObject(valueArg, 'Samsung TV service target');
136
151
  samsungtvKeys(target, ['deviceId', 'entityId']);
137
- const deviceId = SamsungtvMapper.deviceId(this.identity);
152
+ const deviceId = this.#deviceId;
138
153
  if ((target.deviceId === undefined && target.entityId === undefined) ||
139
154
  (target.deviceId !== undefined && target.deviceId !== deviceId) ||
140
155
  (target.entityId !== undefined && target.entityId !== `${deviceId}:media_player`)) {
@@ -1,5 +1,6 @@
1
1
  import type * as plugins from '../../plugins.js';
2
2
  import type { IConfigEncryptionKeyProvider } from '../../core/classes.configstore.js';
3
+ import type { INativeIntegrationOwnerOptions } from '../../core/classes.nativeintegrationowner.js';
3
4
 
4
5
  export interface ISamsungtvConfig {
5
6
  homeId: string;
@@ -16,11 +17,13 @@ export interface ISamsungtvConfig {
16
17
 
17
18
  export interface IHomeAssistantSamsungtvConfig extends ISamsungtvConfig {}
18
19
 
19
- export interface ISamsungtvIntegrationOptions {
20
+ export interface ISamsungtvClientOptions {
20
21
  db: plugins.nosqldb.SmartdataDb;
21
22
  keyProvider: IConfigEncryptionKeyProvider;
22
23
  }
23
24
 
25
+ export interface ISamsungtvIntegrationOptions extends INativeIntegrationOwnerOptions, Partial<ISamsungtvClientOptions> {}
26
+
24
27
  export interface ISamsungtvSnapshot {
25
28
  readonly homeId: string;
26
29
  readonly device: plugins.ecobridgeDeviceManager.ISamsungTvDeviceInfo;