@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,7 +1,9 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { NoSqlConfigStore } from '../../core/classes.configstore.js';
3
- import type { IWebostvConfig, IWebostvInput, IWebostvIntegrationOptions, IWebostvSnapshot, TWebostvClientEvent, TWebostvCommand } from './webostv.types.js';
4
- import { normalizeWebostvConfig, webostvInteger, webostvObject, webostvString } from './webostv.validation.js';
3
+ import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
4
+ import type { INativeIntegrationSetupContext } from '../../core/types.js';
5
+ import type { IWebostvCommandResult, IWebostvConfig, IWebostvInput, IWebostvIntegrationOptions, IWebostvSnapshot, TWebostvClientEvent, TWebostvClientHandler, TWebostvCommand } from './webostv.types.js';
6
+ import { normalizeWebostvConfig, normalizeWebostvOptions, webostvInteger, webostvObject, webostvString } from './webostv.validation.js';
5
7
 
6
8
  interface IPendingRequest {
7
9
  resolve(valueArg: Record<string, unknown>): void;
@@ -26,38 +28,47 @@ export class WebostvClient {
26
28
  readonly #config: Readonly<IWebostvConfig>;
27
29
  readonly #store: NoSqlConfigStore;
28
30
  readonly #ownsStore: boolean;
31
+ readonly #context: INativeAdmissionContext;
32
+ readonly #allowedCommands: readonly TWebostvCommand[];
29
33
  readonly #lifetime = new AbortController();
30
34
  readonly #pending = new Map<string, IPendingRequest>();
31
35
  readonly #subscriptions = new Map<string, (payloadArg: Record<string, unknown>) => void>();
32
- readonly #handlers = new Set<(eventArg: TWebostvClientEvent) => void>();
36
+ readonly #handlers = new Set<TWebostvClientHandler>();
37
+ readonly #operations = new Set<Promise<unknown>>();
38
+ readonly #handlerOperations = new Map<TWebostvClientHandler, Set<Promise<void>>>();
33
39
  #socket?: plugins.ws.WebSocket;
34
40
  #snapshot?: IWebostvSnapshot;
35
41
  #sequence = 0;
36
42
  #start?: Promise<IWebostvSnapshot>;
37
43
  #destroy?: Promise<void>;
44
+ #retry?: Promise<void>;
38
45
  #heartbeat?: ReturnType<typeof setInterval>;
39
46
  #awaitingPong = false;
40
47
  #ready = false;
41
48
  #failure?: Error;
42
- #storage?: Promise<unknown>;
43
- readonly #handlerCleanups = new Map<(eventArg: TWebostvClientEvent) => void, () => void>();
49
+ readonly #handlerCleanups = new Map<TWebostvClientHandler, () => void>();
44
50
  #removeExternalAbort: () => void = () => undefined;
45
51
 
46
- constructor(configArg: IWebostvConfig, optionsArg?: IWebostvIntegrationOptions | NoSqlConfigStore, signalArg?: AbortSignal) {
52
+ constructor(configArg: IWebostvConfig, contextArg: INativeIntegrationSetupContext, optionsArg: IWebostvIntegrationOptions = {}) {
47
53
  this.#config = normalizeWebostvConfig(configArg);
48
- if (optionsArg instanceof NoSqlConfigStore) {
49
- const scope = optionsArg.getScope();
50
- if (scope.homeId !== this.#config.homeId || scope.integrationDomain !== 'webostv') throw new TypeError('webOS requires an encrypted config store scoped to the exact configured home and webostv domain.');
51
- this.#store = optionsArg;
54
+ const options = normalizeWebostvOptions(optionsArg);
55
+ this.#context = new NativeHomeAdmission('webostv', { homeId: options.homeId }).admit(contextArg, this.#config.homeId);
56
+ this.#allowedCommands = options.allowedCommands!;
57
+ if (this.#context.configStore) {
58
+ this.#store = this.#context.configStore;
52
59
  this.#ownsStore = false;
53
60
  } else {
54
- if (!optionsArg?.db || !optionsArg.keyProvider) throw new TypeError('webOS requires an owner-supplied database and external encryption key provider.');
55
- this.#store = new NoSqlConfigStore({ db: optionsArg.db, keyProvider: optionsArg.keyProvider, homeId: this.#config.homeId, integrationDomain: 'webostv' });
61
+ if (!options.db || !options.keyProvider) throw new TypeError('webOS requires an owner-supplied database and external encryption key provider.');
62
+ this.#store = new NoSqlConfigStore({ db: options.db, keyProvider: options.keyProvider, homeId: this.#context.homeId, integrationDomain: 'webostv' });
56
63
  this.#ownsStore = true;
57
64
  }
65
+ }
66
+
67
+ #startAbortListener(): void {
68
+ const signalArg = this.#context.abortSignal;
58
69
  if (signalArg) {
59
- const abort = (): void => { void this.destroy(); };
60
- if (signalArg.aborted) this.#lifetime.abort(new Error('webOS setup was aborted.'));
70
+ const abort = (): void => { void this.destroy().catch(() => undefined); };
71
+ if (signalArg.aborted) abort();
61
72
  else { signalArg.addEventListener('abort', abort, { once: true }); this.#removeExternalAbort = () => signalArg.removeEventListener('abort', abort); }
62
73
  }
63
74
  }
@@ -66,55 +77,99 @@ export class WebostvClient {
66
77
 
67
78
  public async getSnapshot(signalArg?: AbortSignal): Promise<IWebostvSnapshot> {
68
79
  if (signalArg?.aborted) throw new Error('webOS snapshot was aborted.');
69
- if (!this.#start) this.#start = this.#initialize().catch(async (errorArg: unknown) => { await this.destroy(); throw errorArg; });
80
+ if (!this.#start) {
81
+ if (this.#destroy) throw new Error('webOS client is closed.');
82
+ // Track raw startup before foreign work; its rejection handler joins close only after settlement.
83
+ this.#start = this.#track(() => this.#initialize()).catch(async (errorArg: unknown) => {
84
+ try { await this.destroy(); }
85
+ catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'webOS setup and cleanup failed.'); }
86
+ throw errorArg;
87
+ });
88
+ this.#startAbortListener();
89
+ }
70
90
  await this.#start;
71
91
  if (signalArg?.aborted) throw new Error('webOS snapshot was aborted.');
72
92
  if (!this.#snapshot) throw new Error('webOS state is unavailable.');
73
93
  return structuredClone({ ...this.#snapshot, connected: this.isHealthy() });
74
94
  }
75
95
 
76
- public async execute(commandArg: TWebostvCommand, argumentArg?: unknown, signalArg?: AbortSignal): Promise<void> {
77
- if (!this.isHealthy()) throw new Error('webOS is disconnected; command was not sent.');
78
- let uri: string;
79
- let payload: Record<string, unknown> = {};
80
- if (commandArg === 'volume_set') { uri = 'audio/setVolume'; payload = { volume: webostvInteger(argumentArg, 'volume', 0, 100) }; }
81
- else if (commandArg === 'volume_mute') {
82
- if (typeof argumentArg !== 'boolean') throw new TypeError('webOS mute must be boolean.');
83
- uri = 'audio/setMute'; payload = { mute: argumentArg };
84
- } else if (commandArg === 'select_source') {
85
- const inputId = webostvString(argumentArg, 'inputId');
86
- if (!this.#snapshot?.inputs.some((inputArg) => inputArg.id === inputId)) throw new TypeError('webOS input must match an advertised input id.');
87
- uri = 'tv/switchInput'; payload = { inputId };
88
- } else {
89
- if (!Object.hasOwn(commandEndpoints, commandArg) || argumentArg !== undefined) throw new TypeError('Unsupported webOS command or extra arguments.');
90
- uri = commandEndpoints[commandArg];
91
- }
92
- await this.#request('request', uri, payload, undefined, signalArg);
96
+ public execute(commandArg: TWebostvCommand, argumentArg?: unknown, signalArg?: AbortSignal): Promise<IWebostvCommandResult> {
97
+ return this.#track(async () => {
98
+ if (!this.#allowedCommands.includes(commandArg)) throw new Error('webOS command is not authorized by its owner.');
99
+ if (!this.isHealthy()) throw new Error('webOS is disconnected; command was not sent.');
100
+ let uri: string;
101
+ let payload: Record<string, unknown> = {};
102
+ if (commandArg === 'volume_set') { uri = 'audio/setVolume'; payload = { volume: webostvInteger(argumentArg, 'volume', 0, 100) }; }
103
+ else if (commandArg === 'volume_mute') {
104
+ if (typeof argumentArg !== 'boolean') throw new TypeError('webOS mute must be boolean.');
105
+ uri = 'audio/setMute'; payload = { mute: argumentArg };
106
+ } else if (commandArg === 'select_source') {
107
+ const inputId = webostvString(argumentArg, 'inputId');
108
+ if (!this.#snapshot?.inputs.some((inputArg) => inputArg.id === inputId)) throw new TypeError('webOS input must match an advertised input id.');
109
+ uri = 'tv/switchInput'; payload = { inputId };
110
+ } else {
111
+ if (!Object.hasOwn(commandEndpoints, commandArg) || argumentArg !== undefined) throw new TypeError('Unsupported webOS command or extra arguments.');
112
+ uri = commandEndpoints[commandArg];
113
+ }
114
+ await this.#request('request', uri, payload, undefined, signalArg);
115
+ // Correlated acceptance is not proof of physical completion. Read state independently.
116
+ try {
117
+ const observed: Partial<IWebostvSnapshot> = {};
118
+ for (const [field, endpoint] of Object.entries(endpoints)) {
119
+ this.#applyState(observed, field as keyof typeof endpoints, await this.#request('request', endpoint, {}, undefined, signalArg));
120
+ }
121
+ if (!this.isHealthy() || !this.#snapshot) throw new Error('webOS observation is unavailable.');
122
+ const observation = { ...this.#snapshot, ...observed, observedAt: new Date().toISOString() };
123
+ return { acknowledgement: 'tv-response', physicalCompletion: 'unverified', observationStatus: 'observed', observation: structuredClone(observation) };
124
+ } catch {
125
+ return { acknowledgement: 'tv-response', physicalCompletion: 'unverified', observationStatus: 'unavailable' };
126
+ }
127
+ });
93
128
  }
94
129
 
95
- public async subscribe(handlerArg: (eventArg: TWebostvClientEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
130
+ public async subscribe(handlerArg: TWebostvClientHandler, signalArg?: AbortSignal): Promise<() => Promise<void>> {
96
131
  await this.getSnapshot(signalArg);
97
132
  if (signalArg?.aborted || this.#lifetime.signal.aborted) throw new Error('webOS subscription was aborted.');
98
133
  if (this.#handlers.size >= 128) throw new Error('webOS subscriber capacity reached.');
99
- const subscriber = (eventArg: TWebostvClientEvent): void => handlerArg(eventArg);
134
+ if (typeof handlerArg !== 'function') throw new TypeError('webOS subscriber must be a function.');
135
+ const subscriber: TWebostvClientHandler = (eventArg) => handlerArg(eventArg);
100
136
  this.#handlers.add(subscriber);
101
137
  const remove = (): void => { this.#handlers.delete(subscriber); this.#handlerCleanups.delete(subscriber); signalArg?.removeEventListener('abort', remove); };
102
138
  this.#handlerCleanups.set(subscriber, remove);
103
139
  signalArg?.addEventListener('abort', remove, { once: true });
104
- return async () => remove();
140
+ return async () => {
141
+ remove();
142
+ await Promise.allSettled([...(this.#handlerOperations.get(subscriber) ?? [])]);
143
+ };
105
144
  }
106
145
 
107
146
  public destroy(): Promise<void> {
108
147
  if (this.#destroy) return this.#destroy;
109
- this.#destroy = this.#close();
148
+ this.#destroy = Promise.resolve().then(() => this.#close());
149
+ this.#ready = false;
150
+ this.#lifetime.abort(new Error('webOS client was closed.'));
110
151
  return this.#destroy;
111
152
  }
112
153
 
154
+ public retryCleanup(): Promise<void> {
155
+ if (!this.#destroy) return Promise.reject(new Error('webOS cleanup retry requires terminal close.'));
156
+ if (!this.#retry) {
157
+ const retry = Promise.resolve().then(async () => {
158
+ await this.#destroy!.catch(() => undefined);
159
+ if (this.#ownsStore) await this.#store.retryCleanup();
160
+ });
161
+ this.#retry = retry;
162
+ const settled = (): void => { this.#retry = undefined; };
163
+ void retry.then(settled, settled);
164
+ }
165
+ return this.#retry;
166
+ }
167
+
113
168
  async #close(): Promise<void> {
114
- this.#ready = false;
115
- this.#lifetime.abort(new Error('webOS client was closed.'));
116
169
  this.#removeExternalAbort();
117
170
  const closeStore = this.#ownsStore ? this.#store.close() : Promise.resolve();
171
+ // Install a rejection handler immediately; socket shutdown and callback drainage run concurrently.
172
+ const storeResult = Promise.allSettled([closeStore]);
118
173
  clearInterval(this.#heartbeat);
119
174
  for (const request of this.#pending.values()) { clearTimeout(request.timer); request.cleanup(); request.reject(new Error('webOS connection closed; effects already sent have an indeterminate outcome.')); }
120
175
  this.#pending.clear(); this.#subscriptions.clear();
@@ -122,23 +177,21 @@ export class WebostvClient {
122
177
  for (const cleanup of [...this.#handlerCleanups.values()]) cleanup();
123
178
  this.#handlers.clear();
124
179
  const socket = this.#socket;
125
- if (!socket || socket.readyState === plugins.ws.WebSocket.CLOSED) { await Promise.all([closeStore, this.#storage?.catch(() => undefined)]); return; }
126
- await new Promise<void>((resolveArg) => {
180
+ if (socket && socket.readyState !== plugins.ws.WebSocket.CLOSED) await new Promise<void>((resolveArg) => {
127
181
  const timer = setTimeout(() => { socket.terminate(); }, 1000);
128
182
  socket.once('close', () => { clearTimeout(timer); socket.removeAllListeners(); resolveArg(); });
129
183
  if (socket.readyState === plugins.ws.WebSocket.CONNECTING) socket.terminate();
130
184
  else socket.close(1000);
131
185
  });
132
- await Promise.all([closeStore, this.#storage?.catch(() => undefined)]);
186
+ await Promise.allSettled([...this.#operations, ...[...this.#handlerOperations.values()].flatMap((operationsArg) => [...operationsArg])]);
187
+ const [result] = await storeResult;
188
+ if (result.status === 'rejected') throw new Error('webOS encrypted pairing cleanup failed; explicit retryCleanup is required.');
133
189
  }
134
190
 
135
191
  async #persist<TResult>(operationArg: () => Promise<TResult>): Promise<TResult> {
136
192
  if (this.#lifetime.signal.aborted) throw new Error('webOS persistence was aborted.');
137
- const operation = operationArg();
138
- this.#storage = operation;
139
- try { return await operation; }
193
+ try { return await operationArg(); }
140
194
  catch { throw new Error(this.#lifetime.signal.aborted ? 'webOS persistence was aborted.' : 'webOS encrypted pairing storage failed.'); }
141
- finally { if (this.#storage === operation) this.#storage = undefined; }
142
195
  }
143
196
 
144
197
  async #initialize(): Promise<IWebostvSnapshot> {
@@ -157,7 +210,8 @@ export class WebostvClient {
157
210
  const hello = await this.#request('hello', undefined, { appId: 'exchange.smarthome', appName: 'smarthome.exchange' });
158
211
  if (hello.deviceUUID !== this.#config.expectedDeviceUUID) throw new Error('webOS hello device UUID does not match the verified TV.');
159
212
  // New webOS requires this request before registration; permission denial fails setup explicitly.
160
- await this.#request('request', 'system/getSystemInfo', {});
213
+ const systemInfo = await this.#request('request', 'system/getSystemInfo', {});
214
+ const model = systemInfo.modelName === undefined ? undefined : webostvString(systemInfo.modelName, 'reported model');
161
215
  const registered = await this.#request('register', undefined, {
162
216
  pairingType: 'PROMPT', ...(clientKey ? { 'client-key': clientKey } : {}),
163
217
  manifest: { manifestVersion: 1, appVersion: '1.0', permissions },
@@ -178,7 +232,7 @@ export class WebostvClient {
178
232
  await this.#request('subscribe', uri, {}, (payloadArg) => update(field as keyof typeof endpoints, payloadArg));
179
233
  }
180
234
  if (initial.powerState === undefined || initial.volume === undefined || initial.muted === undefined || initial.currentAppId === undefined || initial.inputs === undefined) throw new Error('webOS did not supply a complete initial state.');
181
- this.#snapshot = { homeId: this.#config.homeId, deviceUUID: this.#config.expectedDeviceUUID, name: this.#config.name!, connected: true, powerState: initial.powerState, volume: initial.volume, muted: initial.muted, currentAppId: initial.currentAppId, inputs: initial.inputs, observedAt: new Date().toISOString() };
235
+ this.#snapshot = { homeId: this.#context.homeId, deviceUUID: this.#config.expectedDeviceUUID, name: this.#config.name!, model, authorizedCommands: this.#allowedCommands, connected: true, powerState: initial.powerState, volume: initial.volume, muted: initial.muted, currentAppId: initial.currentAppId, inputs: initial.inputs, observedAt: new Date().toISOString() };
182
236
  if (this.#lifetime.signal.aborted || this.#socket?.readyState !== plugins.ws.WebSocket.OPEN) throw new Error('webOS connection closed during setup.');
183
237
  this.#ready = true;
184
238
  this.#heartbeat = setInterval(() => {
@@ -288,13 +342,32 @@ export class WebostvClient {
288
342
  request.reject(request.type === 'request' ? new Error(`${errorArg.message} Effects already sent have an indeterminate outcome.`) : errorArg);
289
343
  }
290
344
  this.#pending.clear();
291
- void this.destroy();
345
+ void this.destroy().catch(() => undefined);
292
346
  }
293
347
 
294
348
  #emit(eventArg: TWebostvClientEvent): void {
295
349
  for (const handler of [...this.#handlers]) {
296
- try { handler(eventArg.type === 'state' ? { ...eventArg, snapshot: structuredClone(eventArg.snapshot) } : eventArg); }
297
- catch { this.#handlerCleanups.get(handler)?.(); }
350
+ const operations = this.#handlerOperations.get(handler) ?? new Set<Promise<void>>();
351
+ this.#handlerOperations.set(handler, operations);
352
+ const event = eventArg.type === 'state' ? { ...eventArg, snapshot: structuredClone(eventArg.snapshot) } : eventArg;
353
+ // Publish callback ownership before invoking user code, including close reentry.
354
+ const operation = Promise.resolve().then(() => {
355
+ const result = handler(event);
356
+ // A terminal callback may return the very close it triggered; never make close await itself.
357
+ return result === this.#destroy ? undefined : result;
358
+ }).catch(() => { this.#handlerCleanups.get(handler)?.(); });
359
+ operations.add(operation);
360
+ void operation.then(() => {
361
+ operations.delete(operation);
362
+ if (!operations.size) this.#handlerOperations.delete(handler);
363
+ });
298
364
  }
299
365
  }
366
+
367
+ #track<TResult>(operationArg: () => Promise<TResult>): Promise<TResult> {
368
+ const operation = Promise.resolve().then(operationArg);
369
+ this.#operations.add(operation);
370
+ void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
371
+ return operation;
372
+ }
300
373
  }
@@ -1,22 +1,42 @@
1
- import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
1
+ import { NativeIntegrationOwner, type INativeIntegrationOwnerOptions, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
2
+ import type { IConfigFlow, IConfigFlowStep, IDiscoveryCandidate, INativeConfigFlowContext } from '../../core/types.js';
2
3
  import { createWebostvDiscoveryDescriptor } from './webostv.discovery.js';
3
4
  import type { IWebostvConfig } from './webostv.types.js';
4
5
  import { normalizeWebostvConfig, webostvKeys, webostvObject } from './webostv.validation.js';
5
6
 
6
7
  export class WebostvConfigFlow implements IConfigFlow<IWebostvConfig> {
7
- public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IWebostvConfig>> {
8
- const result = await createWebostvDiscoveryDescriptor().getValidators()[0].validate(candidateArg, contextArg);
9
- if (!result.matched || !result.candidate) return { kind: 'error', error: result.reason };
10
- const candidate = result.candidate;
11
- return { kind: 'form', title: 'Pair verified LG webOS TV', description: 'The host must supply an encrypted database and external key provider. Prompt mode requires confirmation on the TV.',
12
- fields: [{ name: 'homeId', label: 'Home id', type: 'text', required: true }, { name: 'pairingMode', label: 'Pairing mode', type: 'select', required: true, options: [{ label: 'Use durable existing pairing', value: 'existing' }, { label: 'Confirm new pairing on TV', value: 'prompt' }] }, { name: 'allowUnencryptedConnection', label: 'Allow explicit cleartext local endpoint', type: 'boolean' }],
13
- submit: async (valuesArg) => {
14
- try {
15
- const values = webostvObject(valuesArg, 'webOS config flow values'); webostvKeys(values, ['homeId', 'pairingMode', 'allowUnencryptedConnection', 'certificateAuthority']);
16
- const config = normalizeWebostvConfig({ ...values, endpoint: candidate.metadata!.endpoint, expectedDeviceUUID: candidate.metadata!.expectedDeviceUUID, name: candidate.name } as IWebostvConfig);
17
- return { kind: 'done', config };
18
- } catch (errorArg) { return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : 'Invalid webOS config.' }; }
19
- },
20
- };
8
+ readonly #owner: NativeIntegrationOwner;
9
+ readonly #scope: NativeIntegrationScope;
10
+ readonly #ownsOwner: boolean;
11
+
12
+ constructor(optionsArg: INativeIntegrationOwnerOptions = {}, ownerArg?: NativeIntegrationOwner) {
13
+ this.#owner = ownerArg ?? new NativeIntegrationOwner('webostv', optionsArg);
14
+ this.#ownsOwner = ownerArg === undefined;
15
+ this.#scope = this.#owner.createFlowScope();
16
+ }
17
+
18
+ public start(candidateArg: IDiscoveryCandidate, contextArg: INativeConfigFlowContext): Promise<IConfigFlowStep<IWebostvConfig>> {
19
+ const context = this.#scope.admit(contextArg);
20
+ return this.#scope.run(context, undefined, async () => {
21
+ const result = await createWebostvDiscoveryDescriptor().getValidators()[0].validate(candidateArg, context);
22
+ if (!result.matched || !result.candidate) return { kind: 'error', error: result.reason };
23
+ const candidate = result.candidate;
24
+ return { kind: 'form', title: 'Pair verified LG webOS TV', description: 'Pairing uses the selected home. Prompt mode requires confirmation on the TV.',
25
+ fields: [{ name: 'pairingMode', label: 'Pairing mode', type: 'select', required: true, options: [{ label: 'Use durable existing pairing', value: 'existing' }, { label: 'Confirm new pairing on TV', value: 'prompt' }] }, { name: 'allowUnencryptedConnection', label: 'Allow explicit cleartext local endpoint', type: 'boolean' }],
26
+ submit: async (valuesArg) => {
27
+ try {
28
+ return await this.#scope.run(context, context.homeId, async () => {
29
+ const values = webostvObject(valuesArg, 'webOS config flow values');
30
+ webostvKeys(values, ['pairingMode', 'allowUnencryptedConnection', 'certificateAuthority']);
31
+ const config = normalizeWebostvConfig({ ...values, homeId: context.homeId, endpoint: candidate.metadata!.endpoint, expectedDeviceUUID: candidate.metadata!.expectedDeviceUUID, name: candidate.name } as IWebostvConfig);
32
+ return { kind: 'done', config };
33
+ });
34
+ } catch (errorArg) { return { kind: 'error', error: errorArg instanceof Error ? errorArg.message : 'Invalid webOS config.' }; }
35
+ },
36
+ };
37
+ });
21
38
  }
39
+
40
+ public close(): Promise<void> { return this.#ownsOwner ? this.#owner.close() : this.#scope.close(); }
41
+ public retryCleanup(): Promise<void> { return this.#ownsOwner ? this.#owner.retryCleanup() : this.#scope.retryCleanup(); }
22
42
  }
@@ -1,14 +1,14 @@
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';
4
- import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
3
+ import { NativeIntegrationOwner } from '../../core/classes.nativeintegrationowner.js';
4
+ import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, INativeIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
5
5
  import { WebostvClient } from './webostv.classes.client.js';
6
6
  import { WebostvConfigFlow } from './webostv.classes.configflow.js';
7
7
  import { createWebostvDiscoveryDescriptor } from './webostv.discovery.js';
8
8
  import { WebostvMapper } from './webostv.mapper.js';
9
9
  import type { IWebostvConfig, IWebostvIntegrationOptions, IWebostvSnapshot, TWebostvCommand } from './webostv.types.js';
10
10
  import { webostvProfile } from './webostv.types.js';
11
- import { webostvKeys, webostvObject } from './webostv.validation.js';
11
+ import { normalizeWebostvConfig, normalizeWebostvOptions, webostvKeys, webostvObject } from './webostv.validation.js';
12
12
 
13
13
  export class WebostvIntegration extends BaseIntegration<IWebostvConfig> {
14
14
  public readonly domain = 'webostv';
@@ -16,51 +16,58 @@ export class WebostvIntegration extends BaseIntegration<IWebostvConfig> {
16
16
  public readonly status = 'control-runtime' as const;
17
17
  public readonly metadata = webostvProfile.metadata;
18
18
  public readonly discoveryDescriptor = createWebostvDiscoveryDescriptor();
19
- public readonly configFlow = new WebostvConfigFlow();
20
- readonly #runtimes = new Set<WebostvRuntime>();
21
- readonly #pending = new Set<WebostvClient>();
22
- #destroyed = false;
19
+ public readonly configFlow: WebostvConfigFlow;
20
+ readonly #owner: NativeIntegrationOwner;
21
+ readonly #options: Readonly<IWebostvIntegrationOptions>;
23
22
  #destroy?: Promise<void>;
24
23
 
25
- constructor(private readonly options?: IWebostvIntegrationOptions) { super(); }
26
-
27
- public async setup(configArg: IWebostvConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
28
- if (this.#destroyed) throw new Error('webOS integration is destroyed.');
29
- if (contextArg.configStore !== undefined && !(contextArg.configStore instanceof NoSqlConfigStore)) throw new TypeError('webOS runtime context requires an encrypted NoSqlConfigStore.');
30
- const client = new WebostvClient(configArg, contextArg.configStore ?? this.options, contextArg.abortSignal);
31
- this.#pending.add(client);
32
- try {
33
- const snapshot = await client.getSnapshot(contextArg.abortSignal);
34
- if (this.#destroyed) throw new Error('webOS integration was destroyed during setup.');
35
- const runtime = new WebostvRuntime(client, snapshot, () => this.#runtimes.delete(runtime));
36
- this.#runtimes.add(runtime);
37
- return runtime;
38
- } catch (errorArg) { await client.destroy(); throw errorArg; }
39
- finally { this.#pending.delete(client); }
24
+ constructor(optionsArg: IWebostvIntegrationOptions = {}) {
25
+ super();
26
+ this.#options = normalizeWebostvOptions(optionsArg);
27
+ this.#owner = new NativeIntegrationOwner(this.domain, { homeId: this.#options.homeId, capacity: this.#options.capacity });
28
+ this.configFlow = new WebostvConfigFlow({}, this.#owner);
40
29
  }
41
30
 
42
- public destroy(): Promise<void> {
43
- if (this.#destroy) return this.#destroy;
44
- this.#destroyed = true;
45
- this.#destroy = Promise.all([...this.#runtimes].map((runtimeArg) => runtimeArg.destroy()).concat([...this.#pending].map((clientArg) => clientArg.destroy()))).then(() => undefined);
46
- return this.#destroy;
31
+ public setup(configArg: IWebostvConfig, contextArg: INativeIntegrationSetupContext): Promise<IIntegrationRuntime> {
32
+ const config = normalizeWebostvConfig(configArg);
33
+ return this.#owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
34
+ const client = new WebostvClient(config, leaseArg.context, this.#options);
35
+ let cleanupStarted = false;
36
+ leaseArg.own(() => {
37
+ if (cleanupStarted) return client.retryCleanup();
38
+ cleanupStarted = true;
39
+ return client.destroy();
40
+ });
41
+ const snapshot = await client.getSnapshot();
42
+ leaseArg.promoteToRuntime();
43
+ return new WebostvRuntime(client, snapshot, () => leaseArg.close(), (promiseArg) => promiseArg === this.#destroy);
44
+ });
47
45
  }
46
+
47
+ public destroy(): Promise<void> { return this.#destroy ??= this.#owner.close(); }
48
+ public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
48
49
  }
49
50
 
50
51
  export class HomeAssistantWebostvIntegration extends WebostvIntegration {}
51
52
 
52
53
  class WebostvRuntime implements IIntegrationRuntime {
53
54
  public readonly domain = 'webostv';
54
- constructor(private readonly client: WebostvClient, private readonly identity: IWebostvSnapshot, private readonly onDestroyed: () => void) {}
55
+ #destroy?: Promise<void>;
56
+ constructor(private readonly client: WebostvClient, private readonly identity: IWebostvSnapshot,
57
+ private readonly close: () => Promise<void>, private readonly isOwnerClose: (promiseArg: unknown) => boolean) {}
55
58
  public isHealthy(): boolean { return this.client.isHealthy(); }
56
59
  public async devices(signalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> { return WebostvMapper.toDevices(await this.client.getSnapshot(signalArg)); }
57
60
  public async entities(): Promise<IIntegrationEntity[]> { return WebostvMapper.toEntities(await this.client.getSnapshot()); }
58
61
  public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
62
+ const handler = handlerArg as (eventArg: IIntegrationEvent) => void | Promise<void>;
59
63
  return this.client.subscribe((eventArg) => {
60
64
  const base = { integrationDomain: this.domain, deviceId: WebostvMapper.deviceId(this.identity), timestamp: Date.now() };
61
- if (eventArg.type === 'state') handlerArg({ ...base, type: 'state_changed', entityId: `${base.deviceId}:media_player`, data: { entity: WebostvMapper.toEntities(eventArg.snapshot)[0] } });
62
- else if (eventArg.type === 'availability') handlerArg({ ...base, type: 'availability_changed', data: { available: eventArg.available } });
63
- else handlerArg({ ...base, type: 'error', data: { message: eventArg.error.message } });
65
+ const result = eventArg.type === 'state'
66
+ ? handler({ ...base, type: 'state_changed', entityId: `${base.deviceId}:media_player`, data: { entity: WebostvMapper.toEntities(eventArg.snapshot)[0] } })
67
+ : eventArg.type === 'availability'
68
+ ? handler({ ...base, type: 'availability_changed', data: { available: eventArg.available } })
69
+ : handler({ ...base, type: 'error', data: { message: eventArg.error.message } });
70
+ return result === this.#destroy || this.isOwnerClose(result) ? undefined : result;
64
71
  }, signalArg);
65
72
  }
66
73
 
@@ -81,8 +88,7 @@ class WebostvRuntime implements IIntegrationRuntime {
81
88
  else if (service === 'volume_mute') { webostvKeys(data, ['mute']); argument = data.mute; }
82
89
  else if (service === 'select_source') { webostvKeys(data, ['inputId']); argument = data.inputId; }
83
90
  else webostvKeys(data, []);
84
- await this.client.execute(service as TWebostvCommand, argument);
85
- return { success: true, data: { acknowledgement: 'tv-response', state: 'observe-subscriptions' } };
91
+ return { success: true, data: await this.client.execute(service as TWebostvCommand, argument) };
86
92
  } catch (errorArg) { return { success: false, error: errorArg instanceof Error ? errorArg.message : 'webOS operation failed.' }; }
87
93
  }
88
94
 
@@ -92,11 +98,12 @@ class WebostvRuntime implements IIntegrationRuntime {
92
98
  const device = webostvObject(request.device, 'feature device');
93
99
  const feature = webostvObject(request.feature, 'feature');
94
100
  if (device.id !== WebostvMapper.deviceId(this.identity) || device.integrationDomain !== this.domain) throw new Error('webOS feature device is not owned by this runtime.');
95
- if (feature.id === 'volume') await this.client.execute('volume_set', request.value, signalArg);
96
- else if (feature.id === 'muted') await this.client.execute('volume_mute', request.value, signalArg);
101
+ let result;
102
+ if (feature.id === 'volume') result = await this.client.execute('volume_set', request.value, signalArg);
103
+ else if (feature.id === 'muted') result = await this.client.execute('volume_mute', request.value, signalArg);
97
104
  else throw new Error('webOS feature is not writable.');
98
- return { success: true, data: { acknowledgement: 'tv-response', state: 'observe-subscriptions' } };
105
+ return { success: true, data: result };
99
106
  } catch (errorArg) { return { success: false, error: errorArg instanceof Error ? errorArg.message : 'webOS feature write failed.' }; }
100
107
  }
101
- public async destroy(): Promise<void> { await this.client.destroy(); this.onDestroyed(); }
108
+ public destroy(): Promise<void> { return this.#destroy ??= this.close(); }
102
109
  }
@@ -13,15 +13,15 @@ export class WebostvMapper {
13
13
  ];
14
14
  return [{
15
15
  id: this.deviceId(snapshotArg), integrationDomain: 'webostv', name: snapshotArg.name,
16
- protocol: 'http', manufacturer: 'LG', model: 'webOS TV', online: snapshotArg.connected,
16
+ protocol: 'http', manufacturer: 'LG', model: snapshotArg.model, online: snapshotArg.connected,
17
17
  features: [
18
18
  { id: 'power', capability: 'media', name: 'Power state', readable: true, writable: false },
19
- { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true, unit: '%' },
20
- { id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: true },
19
+ { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: snapshotArg.connected && snapshotArg.authorizedCommands.includes('volume_set'), unit: '%' },
20
+ { id: 'muted', capability: 'media', name: 'Muted', readable: true, writable: snapshotArg.connected && snapshotArg.authorizedCommands.includes('volume_mute') },
21
21
  { id: 'current_app', capability: 'media', name: 'Foreground application', readable: true, writable: false },
22
22
  ],
23
23
  state: states.map(([featureId, value]) => ({ featureId, value, updatedAt: snapshotArg.observedAt })),
24
- metadata: { homeId: snapshotArg.homeId, deviceUUID: snapshotArg.deviceUUID, transport: 'ssap-websocket', inputs: structuredClone(snapshotArg.inputs) },
24
+ metadata: { homeId: snapshotArg.homeId, deviceUUID: snapshotArg.deviceUUID, transport: 'ssap-websocket', inputs: structuredClone(snapshotArg.inputs), authorizedCommands: [...snapshotArg.authorizedCommands], modelKnown: snapshotArg.model !== undefined },
25
25
  }];
26
26
  }
27
27
 
@@ -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 const webostvDomain = 'webostv';
5
6
  export const webostvDefaultName = 'LG webOS TV';
@@ -19,9 +20,11 @@ export interface IWebostvConfig {
19
20
  maxPendingRequests?: number;
20
21
  }
21
22
 
22
- export interface IWebostvIntegrationOptions {
23
- db: plugins.nosqldb.SmartdataDb;
24
- keyProvider: IConfigEncryptionKeyProvider;
23
+ export interface IWebostvIntegrationOptions extends INativeIntegrationOwnerOptions {
24
+ db?: plugins.nosqldb.SmartdataDb;
25
+ keyProvider?: IConfigEncryptionKeyProvider;
26
+ /** Runtime-only owner permission. An omitted or empty list authorizes no effects. */
27
+ allowedCommands?: readonly TWebostvCommand[];
25
28
  }
26
29
 
27
30
  export interface IHomeAssistantWebostvConfig extends IWebostvConfig {}
@@ -30,7 +33,10 @@ export interface IWebostvSnapshot {
30
33
  homeId: string;
31
34
  deviceUUID: string;
32
35
  name: string;
36
+ /** Actual system/getSystemInfo modelName, absent when the TV did not report one. */
37
+ model?: string;
33
38
  connected: boolean;
39
+ authorizedCommands: readonly TWebostvCommand[];
34
40
  powerState: string;
35
41
  volume: number;
36
42
  muted: boolean;
@@ -45,6 +51,15 @@ export type TWebostvClientEvent =
45
51
  | { type: 'error'; error: Error };
46
52
  export type TWebostvCommand = 'turn_on' | 'turn_off' | 'volume_set' | 'volume_up' | 'volume_down' | 'volume_mute' | 'select_source' | 'media_play' | 'media_pause' | 'media_stop';
47
53
 
54
+ export interface IWebostvCommandResult {
55
+ acknowledgement: 'tv-response';
56
+ physicalCompletion: 'unverified';
57
+ observationStatus: 'observed' | 'unavailable';
58
+ observation?: IWebostvSnapshot;
59
+ }
60
+
61
+ export type TWebostvClientHandler = (eventArg: TWebostvClientEvent) => void | Promise<void>;
62
+
48
63
  export const webostvProfile = {
49
64
  domain: webostvDomain, displayName: webostvDefaultName, status: 'control-runtime',
50
65
  metadata: { source: 'home-assistant/core', upstreamDomain: webostvDomain, localApi: { implemented: ['verified SSAP registration', 'encrypted durable pairing keys', 'authoritative state subscriptions'], explicitUnsupported: ['fixture or injected-client production configs', 'deep standby wake-on-LAN', 'next/previous media track', 'automatic command replay'] } },
@@ -1,4 +1,25 @@
1
- import type { IWebostvConfig } from './webostv.types.js';
1
+ import type { IWebostvConfig, IWebostvIntegrationOptions, TWebostvCommand } from './webostv.types.js';
2
+
3
+ export const webostvCommands: readonly TWebostvCommand[] = Object.freeze(['turn_on', 'turn_off', 'volume_set', 'volume_up', 'volume_down', 'volume_mute', 'select_source', 'media_play', 'media_pause', 'media_stop']);
4
+
5
+ export const normalizeWebostvOptions = (valueArg: IWebostvIntegrationOptions = {}): Readonly<IWebostvIntegrationOptions> => {
6
+ const value = webostvObject(valueArg, 'webOS owner options');
7
+ webostvKeys(value, ['homeId', 'capacity', 'db', 'keyProvider', 'allowedCommands']);
8
+ if ((value.db === undefined) !== (value.keyProvider === undefined)) throw new TypeError('webOS persistence requires both database and keyProvider.');
9
+ const commands = value.allowedCommands ?? [];
10
+ if (!Array.isArray(commands) || commands.length > webostvCommands.length || Reflect.ownKeys(commands).some((keyArg) => {
11
+ const descriptor = Object.getOwnPropertyDescriptor(commands, keyArg);
12
+ return typeof keyArg !== 'string' || !descriptor || !('value' in descriptor) || (keyArg !== 'length' && !/^(0|[1-9][0-9]*)$/.test(keyArg));
13
+ })) throw new TypeError('webOS allowedCommands must be a finite data array.');
14
+ const captured: TWebostvCommand[] = [];
15
+ for (let index = 0; index < commands.length; index += 1) {
16
+ const command: unknown = Object.getOwnPropertyDescriptor(commands, String(index))?.value;
17
+ if (typeof command !== 'string' || !webostvCommands.includes(command as TWebostvCommand) || captured.includes(command as TWebostvCommand)) throw new TypeError('webOS allowedCommands contains an unsupported or duplicate command.');
18
+ captured.push(command as TWebostvCommand);
19
+ }
20
+ return Object.freeze({ homeId: value.homeId as string | undefined, capacity: value.capacity as number | undefined,
21
+ db: value.db as IWebostvIntegrationOptions['db'], keyProvider: value.keyProvider as IWebostvIntegrationOptions['keyProvider'], allowedCommands: Object.freeze(captured) });
22
+ };
2
23
 
3
24
  export const webostvObject = (valueArg: unknown, nameArg: string): Record<string, unknown> => {
4
25
  if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg) || ![Object.prototype, null].includes(Object.getPrototypeOf(valueArg))) throw new TypeError(`${nameArg} must be a plain object.`);