@smarthome.exchange/integrations 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/changelog.md +46 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
  4. package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
  5. package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
  6. package/dist_ts/core/classes.runtimemanager.js +11 -3
  7. package/dist_ts/core/types.d.ts +2 -0
  8. package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
  9. package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
  10. package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
  11. package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
  12. package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
  13. package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
  14. package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
  15. package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
  16. package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
  17. package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
  18. package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
  19. package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
  20. package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
  21. package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
  22. package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
  23. package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
  24. package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
  25. package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
  26. package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
  27. package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
  28. package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
  29. package/dist_ts/integrations/esphome/esphome.types.js +1 -1
  30. package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
  31. package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
  32. package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
  33. package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
  34. package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
  35. package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
  36. package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
  37. package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
  38. package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
  39. package/dist_ts/integrations/hue/hue.classes.client.d.ts +32 -4
  40. package/dist_ts/integrations/hue/hue.classes.client.js +331 -57
  41. package/dist_ts/integrations/hue/hue.classes.configflow.d.ts +6 -1
  42. package/dist_ts/integrations/hue/hue.classes.configflow.js +79 -27
  43. package/dist_ts/integrations/hue/hue.classes.integration.d.ts +3 -1
  44. package/dist_ts/integrations/hue/hue.classes.integration.js +83 -62
  45. package/dist_ts/integrations/hue/hue.mapper.d.ts +2 -0
  46. package/dist_ts/integrations/hue/hue.mapper.js +8 -6
  47. package/dist_ts/integrations/hue/hue.types.d.ts +32 -1
  48. package/dist_ts/integrations/hue/hue.validation.d.ts +9 -3
  49. package/dist_ts/integrations/hue/hue.validation.js +112 -11
  50. package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
  51. package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
  52. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
  53. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
  54. package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
  55. package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
  56. package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
  57. package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
  58. package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
  59. package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
  60. package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
  61. package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
  62. package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
  63. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
  64. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
  65. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
  66. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
  67. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
  68. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
  69. package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
  70. package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
  71. package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
  72. package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
  73. package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
  74. package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
  75. package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
  76. package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
  77. package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
  78. package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
  79. package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
  80. package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
  81. package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
  82. package/dist_ts/integrations/shelly/shelly.types.js +11 -2
  83. package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
  84. package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
  85. package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
  86. package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
  87. package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
  88. package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
  89. package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
  90. package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
  91. package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
  92. package/dist_ts/integrations/sonos/sonos.config.js +23 -2
  93. package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
  94. package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
  95. package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
  96. package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
  97. package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
  98. package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
  99. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
  100. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
  101. package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
  102. package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
  103. package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
  104. package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
  105. package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
  106. package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
  107. package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
  108. package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
  109. package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
  110. package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
  111. package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
  112. package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
  113. package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
  114. package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
  115. package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
  116. package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
  117. package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
  118. package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
  119. package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
  120. package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
  121. package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
  122. package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
  123. package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
  124. package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
  125. package/dist_ts/integrations/webostv/webostv.types.js +1 -1
  126. package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
  127. package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
  128. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
  129. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
  130. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
  131. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
  132. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
  133. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
  134. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
  135. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
  136. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
  137. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
  138. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
  139. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
  140. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
  141. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
  142. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
  143. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
  144. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
  145. package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
  146. package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
  147. package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
  148. package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
  149. package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
  150. package/dist_ts/plugins.d.ts +2 -1
  151. package/dist_ts/plugins.js +3 -2
  152. package/package.json +10 -10
  153. package/readme.md +25 -6
  154. package/ts/00_commitinfo_data.ts +1 -1
  155. package/ts/core/classes.nativeintegrationowner.ts +5 -0
  156. package/ts/core/classes.runtimemanager.ts +11 -2
  157. package/ts/core/types.ts +2 -0
  158. package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
  159. package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
  160. package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
  161. package/ts/integrations/deconz/deconz.mapper.ts +22 -5
  162. package/ts/integrations/deconz/deconz.types.ts +49 -6
  163. package/ts/integrations/deconz/deconz.validation.ts +177 -4
  164. package/ts/integrations/deconz/readme.md +15 -1
  165. package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
  166. package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
  167. package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
  168. package/ts/integrations/esphome/esphome.discovery.ts +3 -12
  169. package/ts/integrations/esphome/esphome.mapper.ts +9 -7
  170. package/ts/integrations/esphome/esphome.types.ts +35 -6
  171. package/ts/integrations/esphome/esphome.validation.ts +66 -0
  172. package/ts/integrations/esphome/readme.md +20 -6
  173. package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
  174. package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
  175. package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
  176. package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
  177. package/ts/integrations/homeassistant/readme.md +22 -6
  178. package/ts/integrations/hue/hue.classes.client.ts +288 -55
  179. package/ts/integrations/hue/hue.classes.configflow.ts +68 -25
  180. package/ts/integrations/hue/hue.classes.integration.ts +75 -54
  181. package/ts/integrations/hue/hue.mapper.ts +8 -5
  182. package/ts/integrations/hue/hue.types.ts +37 -1
  183. package/ts/integrations/hue/hue.validation.ts +102 -13
  184. package/ts/integrations/hue/readme.md +49 -12
  185. package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
  186. package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
  187. package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
  188. package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
  189. package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
  190. package/ts/integrations/mqtt/mqtt.types.ts +52 -0
  191. package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
  192. package/ts/integrations/mqtt/readme.md +12 -3
  193. package/ts/integrations/samsungtv/readme.md +11 -5
  194. package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
  195. package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
  196. package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
  197. package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
  198. package/ts/integrations/shelly/readme.md +22 -7
  199. package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
  200. package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
  201. package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
  202. package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
  203. package/ts/integrations/shelly/shelly.mapper.ts +19 -20
  204. package/ts/integrations/shelly/shelly.rpc.ts +2 -0
  205. package/ts/integrations/shelly/shelly.types.ts +50 -2
  206. package/ts/integrations/shelly/shelly.validation.ts +76 -0
  207. package/ts/integrations/sonos/readme.md +117 -38
  208. package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
  209. package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
  210. package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
  211. package/ts/integrations/sonos/sonos.config.ts +24 -2
  212. package/ts/integrations/sonos/sonos.mapper.ts +13 -8
  213. package/ts/integrations/sonos/sonos.matchers.ts +2 -2
  214. package/ts/integrations/sonos/sonos.types.ts +30 -1
  215. package/ts/integrations/tasmota/readme.md +16 -5
  216. package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
  217. package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
  218. package/ts/integrations/tasmota/tasmota.config.ts +17 -0
  219. package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
  220. package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
  221. package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
  222. package/ts/integrations/tasmota/tasmota.types.ts +43 -2
  223. package/ts/integrations/tuya/readme.md +52 -10
  224. package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
  225. package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
  226. package/ts/integrations/tuya/tuya.mapper.ts +1 -1
  227. package/ts/integrations/tuya/tuya.types.ts +4 -0
  228. package/ts/integrations/tuya/tuya.validation.ts +5 -8
  229. package/ts/integrations/webostv/readme.md +115 -22
  230. package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
  231. package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
  232. package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
  233. package/ts/integrations/webostv/webostv.mapper.ts +4 -4
  234. package/ts/integrations/webostv/webostv.types.ts +18 -3
  235. package/ts/integrations/webostv/webostv.validation.ts +22 -1
  236. package/ts/integrations/zigbee2mqtt/readme.md +22 -27
  237. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
  238. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
  239. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
  240. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
  241. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
  242. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
  243. package/ts/integrations/zwave_js/readme.md +42 -9
  244. package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
  245. package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
  246. package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
  247. package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
  248. package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
  249. package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
  250. package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
  251. package/ts/plugins.ts +2 -1
@@ -1,6 +1,10 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import type { IHueConfig, IHueEvent, IHueLightStatePatch, IHueResourceResponse } from './hue.types.js';
3
- import { hueContentType, hueUuid, normalizeHueConfig, parseHueEnvelope, parseHueEvents, parseHueResources, validateHuePatch } from './hue.validation.js';
2
+ import { NoSqlConfigStore } from '../../core/classes.configstore.js';
3
+ import { NativeIntegrationOwner, type INativeOperationLease } from '../../core/classes.nativeintegrationowner.js';
4
+ import type { INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
5
+ import type { IIntegrationSetupContext } from '../../core/types.js';
6
+ import type { IHueConfig, IHueEvent, IHueLightStatePatch, IHueResourceResponse, IHueCredentialRequest, IHueIntegrationOptions } from './hue.types.js';
7
+ import { captureHueOptions, hueContentType, hueData, hueKey, hueOrigin, hueRecord, hueUuid, normalizeHueConfig, parseHueEnvelope, parseHueEvents, parseHueResources, validateHuePatch } from './hue.validation.js';
4
8
 
5
9
  interface IHueConnection {
6
10
  cancel(): void;
@@ -44,68 +48,211 @@ class HueEventDecoder {
44
48
  }
45
49
  }
46
50
 
51
+ export class HuePairingError extends Error {
52
+ constructor(public readonly linkButtonRequired = false) { super(linkButtonRequired ? 'Press the Hue bridge link button, then retry.' : 'Hue pairing or credential creation was dispatched; do not replay it.'); }
53
+ }
54
+
55
+ interface IHueOperation {
56
+ readonly lease: INativeOperationLease;
57
+ readonly signal: AbortSignal;
58
+ readonly deadline: number;
59
+ readonly actual: Set<Promise<unknown>>;
60
+ }
61
+
62
+ export interface IHueClientOperation {
63
+ getResources(signalArg?: AbortSignal): Promise<IHueResourceResponse>;
64
+ setLightState(idArg: string, stateArg: IHueLightStatePatch, signalArg?: AbortSignal): Promise<void>;
65
+ }
66
+
47
67
  export class HueClient {
48
68
  readonly #config: ReturnType<typeof normalizeHueConfig>;
69
+ readonly #context: INativeAdmissionContext;
70
+ readonly #options: Readonly<IHueIntegrationOptions>;
71
+ readonly #owner: NativeIntegrationOwner;
72
+ readonly #ownsOwner: boolean;
73
+ readonly #store?: NoSqlConfigStore;
74
+ readonly #ownsStore: boolean;
49
75
  readonly #agent: plugins.https.Agent;
50
76
  readonly #lifetime = new AbortController();
51
77
  readonly #connections = new Set<IHueConnection>();
78
+ readonly #operations = new Set<Promise<unknown>>();
79
+ readonly #callbackFailures = new Set<Error>();
52
80
  #destroyPromise?: Promise<void>;
81
+ #retryPromise?: Promise<void>;
82
+ #agentClosed = false;
53
83
  #streamCount = 0;
54
84
  #healthy = false;
55
85
  #eventFailure = false;
56
86
 
57
- constructor(configArg: IHueConfig, private readonly setupSignal?: AbortSignal) {
87
+ constructor(configArg: IHueConfig, contextArg: IIntegrationSetupContext, optionsArg: IHueIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
58
88
  this.#config = normalizeHueConfig(configArg);
89
+ this.#options = captureHueOptions(optionsArg);
90
+ this.#ownsOwner = !ownerArg;
91
+ this.#owner = ownerArg ?? new NativeIntegrationOwner('hue', { homeId: this.#options.homeId, capacity: this.#options.capacity });
92
+ this.#context = this.#owner.admission.admit(contextArg, this.#config.homeId);
93
+ if (this.#options.homeId !== undefined && this.#options.homeId !== this.#context.homeId) throw new Error('Hue constructor home does not match the host home.');
94
+ if (this.#context.configStore && this.#options.db) throw new Error('Hue requires one selected credential store.');
95
+ if (this.#options.db && !(this.#options.db instanceof plugins.nosqldb.SmartdataDb)) throw new TypeError('Hue requires an actual caller-owned database.');
96
+ this.#ownsStore = !this.#context.configStore && Boolean(this.#options.db);
97
+ this.#store = this.#context.configStore ?? (this.#options.db && this.#options.keyProvider ? new NoSqlConfigStore({ db: this.#options.db, keyProvider: this.#options.keyProvider, homeId: this.#context.homeId, integrationDomain: 'hue', timeoutMs: this.#config.requestTimeoutMs }) : undefined);
98
+ if (!this.#options.credentialProvider && !this.#store) throw new Error('Hue requires an explicit credential provider or an actual encrypted config store.');
59
99
  this.#agent = new plugins.https.Agent({ keepAlive: false, maxSockets: 3, maxTotalSockets: 3 });
60
- if (setupSignal?.aborted) this.#lifetime.abort();
61
- else setupSignal?.addEventListener('abort', this.abortSetup, { once: true });
100
+ this.#context.abortSignal?.addEventListener('abort', this.abortSetup, { once: true });
62
101
  }
63
102
 
103
+ public get canCreateCredential(): boolean { return Boolean(this.#options.credentialProvider?.create || !this.#options.credentialProvider && this.#store); }
104
+
64
105
  public isHealthy(): boolean {
65
- return !this.#lifetime.signal.aborted && this.#healthy && !this.#eventFailure;
106
+ return !this.#destroyPromise && !this.#lifetime.signal.aborted && !this.#owner.signal.aborted && !this.#context.abortSignal?.aborted && this.#healthy && !this.#eventFailure && this.#callbackFailures.size === 0;
107
+ }
108
+
109
+ public getResources(abortSignalArg?: AbortSignal): Promise<IHueResourceResponse> {
110
+ return this.run((operation) => this.readResources(operation), abortSignalArg);
66
111
  }
67
112
 
68
- public async getResources(abortSignalArg?: AbortSignal): Promise<IHueResourceResponse> {
113
+ private async readResources(operationArg: IHueOperation, keyArg?: string): Promise<IHueResourceResponse> {
69
114
  try {
70
- const data = parseHueEnvelope(await this.request('GET', '/clip/v2/resource', undefined, abortSignalArg));
71
- const resources = parseHueResources(data, this.#config.bridgeId);
115
+ const key = keyArg ?? await this.credential(operationArg);
116
+ const data = parseHueEnvelope(await this.request('GET', '/clip/v2/resource', undefined, operationArg.signal, key));
117
+ this.active(operationArg);
118
+ const resources = parseHueResources(data, this.#config.bridgeId, this.#context.homeId);
72
119
  this.#healthy = true;
73
120
  return resources;
74
121
  } catch (error) { this.#healthy = false; throw error; }
75
122
  }
76
123
 
77
- public async setLightState(lightIdArg: string, stateArg: IHueLightStatePatch, abortSignalArg?: AbortSignal): Promise<void> {
78
- if (!hueUuid(lightIdArg)) throw new Error('Hue light target must be a resource UUID.');
79
- validateHuePatch(stateArg);
80
- const lightId = lightIdArg.toLowerCase();
81
- const state = { ...stateArg };
82
- const snapshot = await this.getResources(abortSignalArg);
124
+ public setLightState(lightIdArg: string, stateArg: IHueLightStatePatch, abortSignalArg?: AbortSignal): Promise<void> {
125
+ const { lightId, state } = this.captureWrite(lightIdArg, stateArg);
126
+ return this.run((operation) => this.write(operation, lightId, state), abortSignalArg);
127
+ }
128
+
129
+ /** A captured runtime command shares one operation lease, provider deadline and one write attempt. */
130
+ public execute<TResult>(workArg: (clientArg: IHueClientOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
131
+ return this.run(async (operation) => {
132
+ let open = true;
133
+ let written = false;
134
+ const check = (signal?: AbortSignal): void => { if (!open) throw new Error('Hue command session is closed.'); signal?.throwIfAborted(); this.active(operation); };
135
+ const session: IHueClientOperation = Object.freeze({
136
+ getResources: async (signal?: AbortSignal) => { check(signal); return this.readResources(operation); },
137
+ setLightState: async (id: string, patch: IHueLightStatePatch, signal?: AbortSignal) => {
138
+ check(signal);
139
+ if (written) throw new Error('Hue command permits one write attempt.');
140
+ const { lightId, state } = this.captureWrite(id, patch);
141
+ written = true;
142
+ await this.write(operation, lightId, state);
143
+ },
144
+ });
145
+ try { return await this.foreign(operation, () => workArg(session)); }
146
+ finally { open = false; }
147
+ }, signalArg);
148
+ }
149
+
150
+ private captureWrite(idArg: string, patchArg: IHueLightStatePatch): { lightId: string; state: IHueLightStatePatch } {
151
+ if (!hueUuid(idArg)) throw new Error('Hue light target must be a resource UUID.');
152
+ const captured = hueRecord(patchArg);
153
+ validateHuePatch(captured as IHueLightStatePatch);
154
+ return { lightId: idArg.toLowerCase(), state: { ...(captured.on === undefined ? {} : { on: captured.on as boolean }), ...(captured.brightness === undefined ? {} : { brightness: captured.brightness as number }) } };
155
+ }
156
+
157
+ private async write(operationArg: IHueOperation, lightId: string, state: IHueLightStatePatch): Promise<void> {
158
+ this.permission('lights', lightId, Object.keys(state));
159
+ const key = await this.credential(operationArg);
160
+ const snapshot = await this.readResources(operationArg, key);
83
161
  const light = snapshot.lights.find((lightArg) => lightArg.id === lightId);
84
162
  if (!light || !light.available) throw new Error('Hue light target is unknown or unavailable.');
85
163
  if (state.brightness !== undefined && !light.dimming) throw new Error('Hue light does not support brightness.');
86
- const body = { ...(state.on === undefined ? {} : { on: { on: state.on } }),
87
- ...(state.brightness === undefined ? {} : { dimming: { brightness: state.brightness } }) };
88
- const data = parseHueEnvelope(await this.request('PUT', `/clip/v2/resource/light/${lightId}`, body, abortSignalArg));
164
+ const body = { ...(state.on === undefined ? {} : { on: { on: state.on } }), ...(state.brightness === undefined ? {} : { dimming: { brightness: state.brightness } }) };
165
+ this.active(operationArg);
166
+ const data = parseHueEnvelope(await this.request('PUT', `/clip/v2/resource/light/${lightId}`, body, operationArg.signal, key));
89
167
  if (data.length !== 1) throw new Error('Hue write acknowledgement does not identify the target light.');
90
- const acknowledgement = data[0];
91
- if (!acknowledgement || typeof acknowledgement !== 'object' || Array.isArray(acknowledgement)
92
- || !('rid' in acknowledgement) || typeof acknowledgement.rid !== 'string' || acknowledgement.rid.toLowerCase() !== lightId
93
- || !('rtype' in acknowledgement) || acknowledgement.rtype !== 'light') {
94
- throw new Error('Hue write acknowledgement does not identify the target light.');
95
- }
168
+ const acknowledgement = hueRecord(data[0]);
169
+ if (typeof acknowledgement.rid !== 'string' || acknowledgement.rid.toLowerCase() !== lightId || acknowledgement.rtype !== 'light') throw new Error('Hue write acknowledgement does not identify the target light.');
170
+ }
171
+
172
+ public acquireAppKey(deviceTypeArg = 'smarthome.exchange', signalArg?: AbortSignal): Promise<string> {
173
+ if (typeof deviceTypeArg !== 'string' || !/^[a-z0-9_.#-]{1,40}$/i.test(deviceTypeArg)) throw new Error('Invalid Hue pairing client name.');
174
+ return this.run(async (operation) => {
175
+ this.permission('config', this.#config.bridgeId, ['pair', 'credentials:create']);
176
+ if (!this.canCreateCredential) throw new Error('Hue pairing requires credential creation by the selected provider or encrypted store.');
177
+ let dispatched = false;
178
+ try {
179
+ const result = await this.request('POST', '/api', { devicetype: deviceTypeArg }, operation.signal, undefined, () => { dispatched = true; });
180
+ if (!Array.isArray(result) || result.length !== 1) throw new Error('Invalid Hue pairing acknowledgement.');
181
+ const entry = hueRecord(result[0]);
182
+ if (entry.error !== undefined && hueRecord(entry.error).type === 101) throw new HuePairingError(true);
183
+ return hueKey(hueRecord(entry.success).username);
184
+ } catch (error) { if (error instanceof HuePairingError && error.linkButtonRequired) throw error; if (dispatched) throw new HuePairingError(); throw error; }
185
+ }, signalArg);
186
+ }
187
+
188
+ public createCredential(appKeyArg: string, signalArg?: AbortSignal): Promise<void> {
189
+ const key = hueKey(appKeyArg);
190
+ return this.run(async (operation) => {
191
+ this.permission('config', this.#config.bridgeId, ['credentials:create']);
192
+ if (!this.canCreateCredential) throw new Error('Hue credential creation is unavailable.');
193
+ await this.readResources(operation, key);
194
+ const request = this.credentialRequest(operation);
195
+ let dispatched = false;
196
+ try {
197
+ if (this.#options.credentialProvider?.create) await this.foreign(operation, () => { dispatched = true; return this.#options.credentialProvider!.create!(request, key); });
198
+ else {
199
+ await this.foreign(operation, () => this.#store!.initialize({ signal: operation.signal }));
200
+ await this.foreign(operation, () => { dispatched = true; return this.#store!.create(this.storeKey(), { version: 1, homeId: request.homeId, origin: request.origin, bridgeId: request.bridgeId, credentialReference: request.credentialReference, appKey: key }); });
201
+ }
202
+ this.active(operation);
203
+ } catch (error) { if (dispatched) throw new HuePairingError(); throw error; }
204
+ }, signalArg);
96
205
  }
97
206
 
98
- public async subscribeToEvents(handlerArg: (eventArg: IHueEvent) => void,
99
- onErrorArg: (errorArg: Error) => void, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
207
+ public subscribeToEvents(handlerArg: (eventArg: IHueEvent) => unknown,
208
+ onErrorArg: (errorArg: Error) => unknown, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
209
+ if (typeof handlerArg !== 'function' || typeof onErrorArg !== 'function') throw new TypeError('Hue subscribers must be functions.');
210
+ let resolveSubscription: (closeArg: () => Promise<void>) => void = () => {};
211
+ let rejectSubscription: (errorArg: unknown) => void = () => {};
212
+ let issued = false;
213
+ const subscribed = new Promise<() => Promise<void>>((resolve, reject) => { resolveSubscription = resolve; rejectSubscription = reject; });
214
+ let admitted: Promise<void>;
215
+ try {
216
+ admitted = this.run(async (operation) => {
217
+ const key = await this.credential(operation);
218
+ const close = await this.subscribe(operation, key, handlerArg, onErrorArg, abortSignalArg);
219
+ this.active(operation);
220
+ issued = true;
221
+ let closing: Promise<void> | undefined;
222
+ resolveSubscription(() => closing ??= close().then(() => admitted));
223
+ }, abortSignalArg);
224
+ } catch (error) { rejectSubscription(error); return subscribed; }
225
+ void admitted.then(() => { if (!issued) rejectSubscription(new Error('Hue subscription failed.')); }, (error: unknown) => {
226
+ if (!issued) rejectSubscription(error);
227
+ else { this.#callbackFailures.add(new Error('Hue subscription lifecycle failed.')); this.#eventFailure = true; }
228
+ });
229
+ return subscribed;
230
+ }
231
+
232
+ private async subscribe(operationArg: IHueOperation, keyArg: string, handlerArg: (eventArg: IHueEvent) => unknown,
233
+ onErrorArg: (errorArg: Error) => unknown, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
100
234
  this.assertOpen(abortSignalArg);
101
235
  if (this.#streamCount >= 2) throw new Error('Hue event subscription limit reached.');
102
236
  this.#streamCount++;
103
237
  let resolveClosed: () => void = () => {};
104
238
  const closed = new Promise<void>((resolveArg) => { resolveClosed = resolveArg; });
239
+ operationArg.actual.add(closed);
240
+ void closed.then(() => operationArg.actual.delete(closed));
105
241
  const abort = new AbortController();
106
- const signal = AbortSignal.any([this.#lifetime.signal, abort.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
242
+ const signal = AbortSignal.any([this.#lifetime.signal, this.#owner.signal, abort.signal, ...(this.#context.abortSignal ? [this.#context.abortSignal] : []), ...(abortSignalArg ? [abortSignalArg] : [])]);
107
243
  let active = true;
108
244
  signal.addEventListener('abort', () => { active = false; }, { once: true });
245
+ const callbacks = new Set<Promise<void>>();
246
+ const invoke = (workArg: () => unknown, reportArg: boolean): void => {
247
+ const callback = Promise.resolve().then(workArg).then(() => undefined, () => {
248
+ const error = new Error('Hue subscriber callback failed.');
249
+ if (!this.#callbackFailures.size) this.#callbackFailures.add(error);
250
+ this.#eventFailure = true;
251
+ if (reportArg) fail(error);
252
+ });
253
+ callbacks.add(callback);
254
+ void callback.then(() => callbacks.delete(callback));
255
+ };
109
256
  let established = false;
110
257
  let request: ReturnType<typeof plugins.https.request> | undefined;
111
258
  const connection: IHueConnection = { closed, cancel: () => { active = false; abort.abort(); request?.destroy(); } };
@@ -119,7 +266,7 @@ export class HueClient {
119
266
  active = false;
120
267
  abort.abort();
121
268
  request?.destroy();
122
- if (established) onErrorArg(errorArg);
269
+ if (established) invoke(() => onErrorArg(errorArg), false);
123
270
  };
124
271
  const ready = new Promise<void>((resolveArg, rejectArg) => {
125
272
  const deadline = setTimeout(() => {
@@ -133,7 +280,7 @@ export class HueClient {
133
280
  };
134
281
  request = plugins.https.request(this.requestOptions('GET', '/eventstream/clip/v2', signal, {
135
282
  accept: 'text/event-stream', 'cache-control': 'no-cache',
136
- }), (responseArg) => {
283
+ }, keyArg), (responseArg) => {
137
284
  if (responseArg.statusCode !== 200 || !hueContentType(responseArg.headers['content-type'], 'text/event-stream')) {
138
285
  responseArg.destroy();
139
286
  settleFailure(new Error('Hue event stream rejected or returned an invalid content type.'));
@@ -145,12 +292,16 @@ export class HueClient {
145
292
  resolveArg();
146
293
  responseArg.on('data', (chunkArg: Buffer) => {
147
294
  if (!active) return;
148
- try {
149
- for (const event of decoder.feed(chunkArg)) {
150
- if (active) handlerArg(event);
295
+ let events: IHueEvent[];
296
+ try { events = decoder.feed(chunkArg); }
297
+ catch { settleFailure(new Error('Hue event stream contains invalid or oversized data.')); return; }
298
+ for (const event of events) {
299
+ if (!active) break;
300
+ if (callbacks.size >= 32) {
301
+ settleFailure(new Error('Hue subscriber callback capacity reached.'));
302
+ break;
151
303
  }
152
- } catch {
153
- settleFailure(new Error('Hue event stream contains invalid or oversized data.'));
304
+ invoke(() => handlerArg(event), true);
154
305
  }
155
306
  });
156
307
  responseArg.once('error', () => settleFailure(new Error('Hue event stream transport failed.')));
@@ -160,58 +311,139 @@ export class HueClient {
160
311
  request.once('error', () => settleFailure(new Error('Hue event stream transport failed.')));
161
312
  request.once('close', () => {
162
313
  clearTimeout(deadline);
163
- this.#connections.delete(connection);
164
- this.#streamCount--;
165
- resolveClosed();
314
+ active = false;
315
+ void Promise.allSettled([...callbacks]).then(() => {
316
+ this.#connections.delete(connection);
317
+ this.#streamCount--;
318
+ resolveClosed();
319
+ });
166
320
  if (!established) rejectArg(new Error('Hue event subscription was cancelled.'));
167
321
  });
168
322
  request.end();
169
323
  });
170
324
  try {
171
- await ready;
325
+ await this.foreign(operationArg, () => ready);
172
326
  } catch (error) {
173
327
  connection.cancel();
174
328
  if (!request) { this.#connections.delete(connection); this.#streamCount--; resolveClosed(); }
175
329
  await closed;
176
330
  throw error;
177
331
  }
178
- return async () => { connection.cancel(); await closed; };
332
+ let closePromise: Promise<void> | undefined;
333
+ return () => {
334
+ closePromise ??= Promise.resolve().then(async () => { connection.cancel(); await closed; });
335
+ return closePromise;
336
+ };
179
337
  }
180
338
 
181
339
  public destroy(): Promise<void> {
182
- if (this.#destroyPromise) return this.#destroyPromise;
183
- this.setupSignal?.removeEventListener('abort', this.abortSetup);
184
- this.#lifetime.abort();
185
- this.#destroyPromise = this.drain();
340
+ if (!this.#destroyPromise) {
341
+ this.#destroyPromise = Promise.resolve().then(() => this.drain());
342
+ this.#context.abortSignal?.removeEventListener('abort', this.abortSetup);
343
+ this.#lifetime.abort();
344
+ }
186
345
  return this.#destroyPromise;
187
346
  }
188
347
 
189
- private readonly abortSetup = (): void => { void this.destroy(); };
348
+ /** Retries only failed cleanup and acknowledges settled callback failures; never replays callbacks or HTTPS work. */
349
+ public retryCleanup(): Promise<void> {
350
+ if (!this.#destroyPromise) throw new Error('Hue cleanup retry requires a terminal client.');
351
+ if (!this.#retryPromise) {
352
+ this.#retryPromise = Promise.resolve().then(async () => {
353
+ await this.#destroyPromise!.catch(() => undefined);
354
+ this.#callbackFailures.clear();
355
+ await this.drain(true);
356
+ });
357
+ const settled = (): void => { this.#retryPromise = undefined; };
358
+ void this.#retryPromise.then(settled, settled);
359
+ }
360
+ return this.#retryPromise;
361
+ }
362
+
363
+ private readonly abortSetup = (): void => { void this.destroy().catch(() => undefined); };
364
+
365
+ private async drain(retryArg = false): Promise<void> {
366
+ const results = await Promise.allSettled([
367
+ ...[...this.#connections].map(async (connectionArg) => { connectionArg.cancel(); await connectionArg.closed; }),
368
+ Promise.resolve().then(() => { if (!this.#agentClosed) { this.#agent.destroy(); this.#agentClosed = true; } }),
369
+ ]);
370
+ await Promise.allSettled([...this.#operations]);
371
+ const failures = results.filter((resultArg): resultArg is PromiseRejectedResult => resultArg.status === 'rejected')
372
+ .map(() => new Error('Hue transport cleanup failed.'));
373
+ const other = await Promise.allSettled([
374
+ ...(this.#ownsStore && this.#store ? [retryArg ? this.#store.retryCleanup() : this.#store.close()] : []),
375
+ ...(this.#ownsOwner ? [retryArg ? this.#owner.retryCleanup() : this.#owner.close()] : []),
376
+ ]);
377
+ failures.push(...other.filter((result) => result.status === 'rejected').map(() => new Error('Hue owned cleanup failed.')), ...this.#callbackFailures);
378
+ if (failures.length) throw new AggregateError(failures, 'Hue cleanup failed; explicit cleanup retry is required.');
379
+ }
190
380
 
191
- private async drain(): Promise<void> {
192
- const connections = [...this.#connections];
193
- for (const connection of connections) connection.cancel();
194
- this.#agent.destroy();
195
- await Promise.all(connections.map((connectionArg) => connectionArg.closed));
381
+ private run<TResult>(workArg: (operationArg: IHueOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
382
+ this.assertOpen(signalArg);
383
+ const operation = this.#owner.runOperation(this.#context, this.#config.homeId, async (lease) => {
384
+ const deadlineAbort = new AbortController();
385
+ const deadline = performance.now() + this.#config.requestTimeoutMs;
386
+ const signal = AbortSignal.any([lease.signal, this.#lifetime.signal, deadlineAbort.signal, ...(signalArg ? [signalArg] : [])]);
387
+ const actual = new Set<Promise<unknown>>();
388
+ lease.own(async () => { while (actual.size) await Promise.allSettled([...actual]); });
389
+ const timer = setTimeout(() => deadlineAbort.abort(new Error('Hue request timed out.')), this.#config.requestTimeoutMs);
390
+ try { return await workArg({ lease, signal, deadline, actual }); }
391
+ finally { clearTimeout(timer); }
392
+ });
393
+ this.#operations.add(operation);
394
+ void operation.then(() => this.#operations.delete(operation), () => this.#operations.delete(operation));
395
+ return operation;
396
+ }
397
+
398
+ private async foreign<TResult>(operationArg: IHueOperation, workArg: () => Promise<TResult>): Promise<TResult> {
399
+ this.active(operationArg);
400
+ if (operationArg.actual.size >= 16) throw new Error('Hue actual callback capacity reached.');
401
+ const actual = Promise.resolve().then(() => { this.active(operationArg); return workArg(); });
402
+ operationArg.actual.add(actual);
403
+ void actual.then(() => operationArg.actual.delete(actual), () => operationArg.actual.delete(actual));
404
+ let abort: () => void = () => {};
405
+ const cancelled = new Promise<never>((_resolve, reject) => { abort = () => reject(this.cancelled(operationArg)); operationArg.signal.addEventListener('abort', abort, { once: true }); if (operationArg.signal.aborted) abort(); });
406
+ try { const value = await Promise.race([actual, cancelled]); this.active(operationArg); return value; }
407
+ catch { if (operationArg.signal.aborted || performance.now() >= operationArg.deadline) throw this.cancelled(operationArg); throw new Error('Hue operation failed.'); }
408
+ finally { operationArg.signal.removeEventListener('abort', abort); }
409
+ }
410
+
411
+ private active(operationArg: IHueOperation): void {
412
+ if (operationArg.signal.aborted || performance.now() >= operationArg.deadline || this.#destroyPromise) throw this.cancelled(operationArg);
413
+ operationArg.lease.assertOpen();
414
+ }
415
+ private cancelled(operationArg: IHueOperation): Error { return new Error(performance.now() >= operationArg.deadline || operationArg.signal.reason instanceof Error && operationArg.signal.reason.message === 'Hue request timed out.' ? 'Hue request timed out.' : 'Hue request cancelled.'); }
416
+ private credentialRequest(operationArg: IHueOperation): IHueCredentialRequest { return Object.freeze({ homeId: this.#context.homeId, origin: hueOrigin(this.#config), bridgeId: this.#config.bridgeId, credentialReference: this.#config.credentialReference, signal: operationArg.signal, deadline: operationArg.deadline }); }
417
+ private storeKey(): string { return `hue.credential.${encodeURIComponent(this.#config.credentialReference)}`; }
418
+ private async credential(operationArg: IHueOperation): Promise<string> {
419
+ const request = this.credentialRequest(operationArg);
420
+ if (this.#options.credentialProvider) return hueKey(await this.foreign(operationArg, () => this.#options.credentialProvider!.resolve(request)));
421
+ await this.foreign(operationArg, () => this.#store!.initialize({ signal: operationArg.signal }));
422
+ const envelope = hueData(await this.foreign(operationArg, () => this.#store!.get(this.storeKey())), ['version', 'homeId', 'origin', 'bridgeId', 'credentialReference', 'appKey']);
423
+ if (envelope.version !== 1 || envelope.homeId !== request.homeId || envelope.origin !== request.origin || envelope.bridgeId !== request.bridgeId || envelope.credentialReference !== request.credentialReference) throw new Error('Hue credential reference does not match the selected home and bridge.');
424
+ return hueKey(envelope.appKey);
425
+ }
426
+ private permission(resourceArg: 'config' | 'lights', idArg: string, fieldsArg: readonly string[]): void {
427
+ if (!this.#options.controlPermissions?.some((permission) => permission.resource === resourceArg && permission.resourceId === idArg && fieldsArg.every((field) => permission.fields.includes(field)))) throw new Error('Hue control requires exact owner authorization.');
196
428
  }
197
429
 
198
430
  private assertOpen(abortSignalArg?: AbortSignal): void {
199
- if (this.#lifetime.signal.aborted || abortSignalArg?.aborted) throw new Error('Hue client operation was cancelled or the client is closed.');
431
+ if (this.#destroyPromise || this.#lifetime.signal.aborted || this.#owner.signal.aborted || this.#context.abortSignal?.aborted || abortSignalArg?.aborted) throw new Error('Hue client operation was cancelled or the client is closed.');
200
432
  if (this.#connections.size >= 32) throw new Error('Hue concurrent request limit reached.');
201
433
  }
202
434
 
203
- private requestOptions(methodArg: string, pathArg: string, signalArg: AbortSignal, headersArg: Record<string, string> = {}): plugins.https.RequestOptions {
435
+ private requestOptions(methodArg: string, pathArg: string, signalArg: AbortSignal, headersArg: Record<string, string> = {}, keyArg?: string): plugins.https.RequestOptions {
204
436
  return { hostname: this.#config.host, port: this.#config.port, path: pathArg, method: methodArg,
205
437
  servername: this.#config.bridgeId, ca: this.#config.trustedCa, rejectUnauthorized: true,
206
438
  checkServerIdentity: (_hostnameArg, certificateArg) => plugins.tls.checkServerIdentity(this.#config.bridgeId, certificateArg),
207
439
  signal: signalArg, agent: this.#agent,
208
- headers: { 'hue-application-key': this.#config.appKey, accept: 'application/json', ...headersArg } };
440
+ headers: { ...(keyArg === undefined ? {} : { 'hue-application-key': keyArg }), accept: 'application/json', ...headersArg } };
209
441
  }
210
442
 
211
- private async request(methodArg: string, pathArg: string, bodyArg?: unknown, abortSignalArg?: AbortSignal): Promise<unknown> {
443
+ private async request(methodArg: string, pathArg: string, bodyArg?: unknown, abortSignalArg?: AbortSignal, keyArg?: string, dispatchArg?: () => void): Promise<unknown> {
212
444
  this.assertOpen(abortSignalArg);
213
445
  const abort = new AbortController();
214
- const signal = AbortSignal.any([this.#lifetime.signal, abort.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
446
+ const signal = AbortSignal.any([this.#lifetime.signal, this.#owner.signal, abort.signal, ...(this.#context.abortSignal ? [this.#context.abortSignal] : []), ...(abortSignalArg ? [abortSignalArg] : [])]);
215
447
  const encoded = bodyArg === undefined ? undefined : JSON.stringify(bodyArg);
216
448
  let request: ReturnType<typeof plugins.https.request> | undefined;
217
449
  let resolveClosed: () => void = () => {};
@@ -223,7 +455,7 @@ export class HueClient {
223
455
  return await new Promise<unknown>((resolveArg, rejectArg) => {
224
456
  request = plugins.https.request(this.requestOptions(methodArg, pathArg, signal, encoded === undefined ? {} : {
225
457
  'content-type': 'application/json', 'content-length': String(Buffer.byteLength(encoded)),
226
- }), (responseArg) => {
458
+ }, keyArg), (responseArg) => {
227
459
  if (responseArg.statusCode !== 200 || !hueContentType(responseArg.headers['content-type'], 'application/json')) {
228
460
  responseArg.destroy();
229
461
  rejectArg(new Error(`Hue API request failed with HTTP status ${responseArg.statusCode ?? 0}.`));
@@ -247,6 +479,7 @@ export class HueClient {
247
479
  });
248
480
  request.once('error', () => rejectArg(new Error('Hue HTTPS request failed or was cancelled.')));
249
481
  request.once('close', () => { this.#connections.delete(connection); resolveClosed(); });
482
+ dispatchArg?.();
250
483
  request.end(encoded);
251
484
  });
252
485
  } finally {
@@ -1,39 +1,82 @@
1
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
1
2
  import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
2
- import { HueClient } from './hue.classes.client.js';
3
- import { hueBridgeId, normalizeHueConfig, normalizeHueHost } from './hue.validation.js';
4
- import type { IHueConfig } from './hue.types.js';
3
+ import { HueClient, HuePairingError } from './hue.classes.client.js';
4
+ import { captureHueOptions, hueBridgeId, hueData, hueKey, normalizeHueConfig, normalizeHueHost } from './hue.validation.js';
5
+ import type { IHueConfig, IHueIntegrationOptions } from './hue.types.js';
5
6
 
6
7
  export class HueConfigFlow implements IConfigFlow<IHueConfig> {
8
+ readonly #options: Readonly<IHueIntegrationOptions>;
9
+ readonly #owner: NativeIntegrationOwner;
10
+ readonly #scope: NativeIntegrationScope;
11
+ readonly #ownsOwner: boolean;
12
+ #terminal = false;
13
+ #submitted = false;
14
+
15
+ constructor(optionsArg: IHueIntegrationOptions = {}, ownerArg?: NativeIntegrationOwner) {
16
+ this.#options = captureHueOptions(optionsArg);
17
+ this.#ownsOwner = !ownerArg;
18
+ this.#owner = ownerArg ?? new NativeIntegrationOwner('hue', { homeId: this.#options.homeId, capacity: this.#options.capacity });
19
+ this.#scope = this.#owner.createFlowScope();
20
+ }
21
+
7
22
  public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IHueConfig>> {
8
- void contextArg;
9
- const bridgeId = hueBridgeId(candidateArg.id) ? candidateArg.id : undefined;
23
+ const context = this.#scope.admit(contextArg);
24
+ if (this.#terminal) throw new Error('Hue config flow is closed.');
25
+ const candidate = hueData(candidateArg, ['id', 'source', 'host', 'port', 'name', 'integrationDomain', 'manufacturer', 'model', 'serialNumber', 'macAddress', 'metadata']);
26
+ if (candidate.integrationDomain !== 'hue') throw new Error('Hue flow requires a Hue candidate.');
27
+ const bridgeId = hueBridgeId(candidate.id) ? candidate.id : undefined;
28
+ const port = candidate.port;
29
+ if (port !== undefined && (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('Hue discovered port is invalid.');
10
30
  let host: string | undefined;
11
- try { host = normalizeHueHost(candidateArg.host); } catch { host = undefined; }
31
+ try { host = normalizeHueHost(candidate.host); } catch { host = undefined; }
32
+ const submit = async (valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IHueConfig>> => {
33
+ if (this.#terminal || this.#submitted) return { kind: 'error', title: 'Hue flow closed', error: 'Hue config flow is closed or already submitting.' };
34
+ this.#submitted = true;
35
+ let paired = false;
36
+ let capturedValues: Record<string, unknown> | undefined;
37
+ try {
38
+ const values = hueData(valuesArg, ['homeId', 'bridgeId', 'host', 'credentialReference', 'appKey', 'trustedCa']);
39
+ if (values.homeId !== undefined && values.homeId !== context.homeId) throw new Error('Hue form home does not match the host home.');
40
+ capturedValues = Object.freeze(values);
41
+ const config = normalizeHueConfig({ homeId: context.homeId, bridgeId: bridgeId ?? values.bridgeId as string,
42
+ host: host ?? values.host as string, port: port as number | undefined,
43
+ credentialReference: values.credentialReference as string, trustedCa: values.trustedCa === '' ? undefined : values.trustedCa as string | undefined });
44
+ const suppliedKey = values.appKey === undefined || values.appKey === '' ? undefined : hueKey(values.appKey);
45
+ const result = await this.#scope.run(context, config.homeId, async (lease) => {
46
+ const client = new HueClient(config, { ...lease.context, abortSignal: lease.signal }, this.#options, this.#owner);
47
+ let cleanupStarted = false;
48
+ lease.own(() => { if (cleanupStarted) return client.retryCleanup(); cleanupStarted = true; return client.destroy(); });
49
+ if (!client.canCreateCredential) throw new Error('Hue flow requires credential creation by the selected provider or encrypted store.');
50
+ const key = suppliedKey ?? await client.acquireAppKey(undefined, lease.signal);
51
+ paired = !suppliedKey;
52
+ await client.createCredential(key, lease.signal);
53
+ lease.assertOpen();
54
+ return { kind: 'done', title: 'Hue bridge verified', config } as IConfigFlowStep<IHueConfig>;
55
+ });
56
+ this.#terminal = true;
57
+ await this.#scope.close();
58
+ return result;
59
+ } catch (error) {
60
+ if (error instanceof HuePairingError && error.linkButtonRequired && !paired) {
61
+ return { kind: 'wait', title: 'Press the Hue link button', description: 'Press the physical link button on the selected bridge, then retry.', submit: () => submit(capturedValues!) };
62
+ }
63
+ if (paired || error instanceof HuePairingError) { this.#terminal = true; await this.#scope.close(); }
64
+ return { kind: 'error', title: 'Hue bridge could not be verified', error: paired || error instanceof HuePairingError ? 'Hue pairing or credential creation was dispatched; do not replay it.' : 'Check the host home, bridge ID, HTTPS endpoint, trusted CA, credential reference and permissions. The bridge must support Hue API v2.' };
65
+ } finally { this.#submitted = false; }
66
+ };
12
67
  return {
13
68
  kind: 'form', title: 'Connect Philips Hue Bridge',
14
- description: 'Provide a bridge application key created with the physical link button. For Hue private-CA certificates, provide the trusted Hue CA from the developer portal. Setup verifies the TLS identity and live bridge resources.',
69
+ description: 'Select a credential reference. Press the physical link button to pair, or supply an existing application key for one-time insertion into the host credential store. Setup verifies TLS identity and live resources.',
15
70
  fields: [
16
71
  ...(!bridgeId ? [{ name: 'bridgeId', label: 'Hue bridge ID', type: 'text' as const, required: true }] : []),
17
72
  ...(!host ? [{ name: 'host', label: 'Bridge hostname or IP address', type: 'text' as const, required: true }] : []),
18
- { name: 'appKey', label: 'Hue app key', type: 'password', required: true },
73
+ { name: 'credentialReference', label: 'Credential reference', type: 'text', required: true },
74
+ { name: 'appKey', label: 'Existing Hue app key (optional)', type: 'password' },
19
75
  { name: 'trustedCa', label: 'Trusted Hue CA certificate (PEM)', type: 'text' },
20
- ],
21
- submit: async (valuesArg) => {
22
- let client: HueClient | undefined;
23
- try {
24
- const config = normalizeHueConfig({ bridgeId: bridgeId ?? valuesArg.bridgeId as string,
25
- host: host ?? valuesArg.host as string, port: candidateArg.port ?? 443,
26
- appKey: valuesArg.appKey as string, trustedCa: valuesArg.trustedCa === '' ? undefined : valuesArg.trustedCa as string | undefined });
27
- client = new HueClient(config);
28
- await client.getResources();
29
- return { kind: 'done', title: 'Hue bridge verified', config };
30
- } catch {
31
- return { kind: 'error', title: 'Hue bridge could not be verified',
32
- error: 'Check the bridge ID, HTTPS endpoint, trusted CA and application key. The bridge must support Hue API v2.' };
33
- } finally {
34
- await client?.destroy();
35
- }
36
- },
76
+ ], submit,
37
77
  };
38
78
  }
79
+
80
+ public destroy(): Promise<void> { this.#terminal = true; return this.#ownsOwner ? this.#owner.close() : this.#scope.close(); }
81
+ public retryCleanup(): Promise<void> { return this.#ownsOwner ? this.#owner.retryCleanup() : this.#scope.retryCleanup(); }
39
82
  }