@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,323 +1,440 @@
1
1
  import * as plugins from '../../plugins.js';
2
+ import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
3
+ import type { INativeIntegrationSetupContext } from '../../core/types.js';
2
4
  import { AuthenticationError, DeviceCommunicationError } from '../../core/errors.js';
3
- import type { ILogger } from '../../core/types.js';
4
5
  import { ShellySubscription } from './shelly.classes.subscription.js';
5
- import {
6
- createShellyDigest,
7
- isShellyRecord,
8
- mergeShellyStatus,
9
- parseShellyConfig,
10
- parseShellyDeviceInfo,
11
- parseShellyHttpChallenge,
12
- parseShellyRpcResult,
13
- parseShellyStatus,
14
- } from './shelly.rpc.js';
6
+ import { createShellyDigest, isShellyRecord, mergeShellyStatus, parseShellyConfig, parseShellyDeviceInfo,
7
+ parseShellyHttpChallenge, parseShellyRpcResult, parseShellyStatus } from './shelly.rpc.js';
15
8
  import type { IShellyDigestChallenge } from './shelly.rpc.js';
16
- import type { IShellyConfig, IShellyDeviceConfig, IShellyDeviceInfo, IShellySnapshot, IShellyStatus, TShellyClientEvent } from './shelly.types.js';
9
+ import { normalizeShellyConfig, normalizeShellyOptions, type TNormalizedShellyConfig } from './shelly.validation.js';
10
+ import { ShellyOperationError, type IShellyConfig, type IShellyDeviceConfig, type IShellyDeviceInfo,
11
+ type IShellyEffectResult, type IShellyIntegrationOptions, type IShellySnapshot, type IShellyStatus,
12
+ type TShellyClientEvent } from './shelly.types.js';
17
13
 
14
+ interface IShellyOperation {
15
+ readonly signal: AbortSignal;
16
+ readonly deadline: number;
17
+ password?: string;
18
+ dispatched: boolean;
19
+ acknowledged: boolean;
20
+ task?: Promise<unknown>;
21
+ }
18
22
  interface IShellySubscriber {
19
23
  handler(eventArg: TShellyClientEvent): void;
20
- signal?: AbortSignal;
21
- abort(): void;
24
+ active: boolean;
25
+ failed: boolean;
26
+ detach(): void;
27
+ stop(): Promise<void>;
22
28
  }
29
+ interface IShellyHttpResponse { status: number; challenge?: string; body: string; }
23
30
 
24
31
  export class ShellyClient {
25
- private requestId = 1;
26
- private nonceCount = 0;
27
- private digestChallenge?: IShellyDigestChallenge;
28
- private deviceId?: string;
29
- private snapshot?: IShellySnapshot;
30
- private subscription?: ShellySubscription;
31
- private readonly subscribers = new Set<IShellySubscriber>();
32
- private readonly lifetime = new AbortController();
33
- private readonly lifetimeSignal: AbortSignal;
34
- // Firmware tracks increasing digest nonce counts, so HTTP calls must arrive in order.
35
- private requestQueue: Promise<void> = Promise.resolve();
36
- private readonly timeoutMs: number;
37
- private readonly endpoint: URL;
38
- private readonly config: IShellyConfig;
39
- private healthy = false;
40
- private destroyed = false;
32
+ readonly #config: TNormalizedShellyConfig;
33
+ readonly #context: INativeAdmissionContext;
34
+ readonly #endpoint: URL;
35
+ readonly #lifetime = new AbortController();
36
+ readonly #tasks = new Set<Promise<unknown>>();
37
+ readonly #credentials = new Set<Promise<unknown>>();
38
+ readonly #credentialOperations = new Map<Promise<unknown>, Promise<unknown>>();
39
+ readonly #credentialFailures = new Set<Error>();
40
+ readonly #requests = new Set<plugins.http.ClientRequest>();
41
+ readonly #sockets = new Set<plugins.net.Socket>();
42
+ readonly #drains = new Set<Promise<void>>();
43
+ readonly #subscribers = new Set<IShellySubscriber>();
44
+ readonly #subscriptions = new Set<ShellySubscription>();
45
+ readonly #parentAbort: () => void;
46
+ #resolvePassword?: IShellyIntegrationOptions['resolvePassword'];
47
+ #tail: Promise<void> = Promise.resolve();
48
+ #requestId = 1;
49
+ #nonceCount = 0;
50
+ #challenge?: IShellyDigestChallenge;
51
+ #snapshot?: IShellySnapshot;
52
+ #subscription?: ShellySubscription;
53
+ #healthy = false;
54
+ #closed = false;
55
+ #closePromise?: Promise<void>;
56
+ #cleanupPromise?: Promise<void>;
57
+ #retryPromise?: Promise<void>;
58
+ #cleanupFailed = false;
41
59
 
42
- constructor(configArg: IShellyConfig, abortSignalArg?: AbortSignal, private readonly logger?: ILogger) {
43
- const config: IShellyConfig = {
44
- host: configArg.host,
45
- port: configArg.port,
46
- protocol: configArg.protocol,
47
- username: configArg.username,
48
- password: configArg.password,
49
- requestTimeoutMs: configArg.requestTimeoutMs,
50
- };
51
- this.config = config;
52
- this.timeoutMs = config.requestTimeoutMs ?? 10000;
53
- if (!Number.isSafeInteger(this.timeoutMs) || this.timeoutMs < 10 || this.timeoutMs > 60000) {
54
- throw new Error('Shelly requestTimeoutMs must be an integer between 10 and 60000.');
55
- }
56
- if (config.username !== undefined && config.username !== 'admin') throw new AuthenticationError('Shelly authentication username must be admin.');
57
- if (config.password !== undefined && typeof config.password !== 'string') throw new AuthenticationError('Shelly password must be a string.');
58
- if (config.protocol !== undefined && config.protocol !== 'http' && config.protocol !== 'https') throw new Error('Shelly protocol must be http or https.');
59
- if (config.port !== undefined && (!Number.isSafeInteger(config.port) || config.port < 1 || config.port > 65535)) throw new Error('Shelly port must be between 1 and 65535.');
60
- if (config.host === undefined) throw new Error('Shelly host is required for live requests.');
61
- if (typeof config.host !== 'string' || !config.host || /[\s\/?#@]/.test(config.host)) throw new Error('Shelly host must be a hostname or IP address.');
62
- const host = config.host.includes(':') && !config.host.startsWith('[') ? `[${config.host}]` : config.host;
63
- try { this.endpoint = new URL(`${config.protocol ?? 'http'}://${host}${config.port === undefined ? '' : `:${config.port}`}`); }
64
- catch { throw new Error('Shelly host must be a hostname or IP address.'); }
65
- this.lifetimeSignal = AbortSignal.any([this.lifetime.signal, ...(abortSignalArg ? [abortSignalArg] : [])]);
60
+ constructor(configArg: IShellyConfig, contextArg: INativeIntegrationSetupContext,
61
+ optionsArg: IShellyIntegrationOptions = {}) {
62
+ this.#config = normalizeShellyConfig(configArg);
63
+ const options = normalizeShellyOptions(optionsArg);
64
+ this.#context = new NativeHomeAdmission('shelly', { homeId: options.homeId }).admit(contextArg, this.#config.homeId);
65
+ this.#resolvePassword = options.resolvePassword;
66
+ if (this.#config.credentialRef !== undefined && !this.#resolvePassword) throw new TypeError('Shelly credential reference requires a caller-owned password resolver.');
67
+ const host = this.#config.host.includes(':') && !this.#config.host.startsWith('[') ? `[${this.#config.host}]` : this.#config.host;
68
+ this.#endpoint = new URL(`${this.#config.protocol}://${host}:${this.#config.port}`);
69
+ this.#parentAbort = () => { void this.destroy().catch(() => undefined); };
70
+ this.#context.abortSignal?.addEventListener('abort', this.#parentAbort, { once: true });
71
+ if (this.#context.abortSignal?.aborted) this.#parentAbort();
66
72
  }
67
73
 
68
74
  public isHealthy(): boolean {
69
- return !this.destroyed && !this.lifetimeSignal.aborted && this.healthy && (!this.subscription || this.subscription.isHealthy);
75
+ return !this.#closed && this.#healthy && (!this.#subscription || this.#subscription.isHealthy);
70
76
  }
71
-
72
- public async getSnapshot(abortSignalArg?: AbortSignal): Promise<IShellySnapshot> {
73
- this.assertActive(abortSignalArg);
74
- if (this.snapshot && this.subscription?.isHealthy) return structuredClone(this.snapshot);
75
- const deviceInfo = await this.getDeviceInfo(abortSignalArg);
76
- const status = await this.getStatus(abortSignalArg);
77
- const deviceConfig = await this.getDeviceConfig(abortSignalArg);
78
- this.snapshot = { deviceInfo, status, deviceConfig };
79
- return structuredClone(this.snapshot);
77
+ /** Validated push observations may satisfy reads; controls always call read() afresh. */
78
+ public getSnapshot(signalArg?: AbortSignal): Promise<IShellySnapshot> {
79
+ try {
80
+ this.assertOpen();
81
+ if (signalArg !== undefined && !(signalArg instanceof AbortSignal)) throw new TypeError('Shelly cancellation requires an AbortSignal.');
82
+ if (signalArg?.aborted) throw new DOMException('Shelly operation cancelled.', 'AbortError');
83
+ if (this.#snapshot && this.#healthy && this.#subscription?.isHealthy) return Promise.resolve(structuredClone(this.#snapshot));
84
+ } catch (errorArg) { return Promise.reject(errorArg); }
85
+ return this.run((operationArg) => this.read(operationArg), signalArg);
80
86
  }
81
-
82
- public async getDeviceInfo(abortSignalArg?: AbortSignal): Promise<IShellyDeviceInfo> {
83
- this.assertActive(abortSignalArg);
84
- const response = await this.requestJson('/shelly', undefined, abortSignalArg);
85
- const deviceInfo = this.parseResponse(() => {
86
- const info = parseShellyDeviceInfo(response);
87
- if (this.deviceId !== undefined && this.deviceId !== info.id) throw new DeviceCommunicationError('Shelly endpoint device identity changed.');
88
- return info;
89
- });
90
- this.deviceId = deviceInfo.id;
91
- return deviceInfo;
87
+ public getDeviceInfo(signalArg?: AbortSignal): Promise<IShellyDeviceInfo> {
88
+ return this.run(async (operationArg) => { await this.bootstrap(operationArg); return this.identity(await this.rpc(operationArg, 'Shelly.GetDeviceInfo')); }, signalArg);
92
89
  }
93
-
94
- public async getStatus(abortSignalArg?: AbortSignal): Promise<IShellyStatus> {
95
- this.assertActive(abortSignalArg);
96
- const response = await this.callRpc('Shelly.GetStatus', undefined, abortSignalArg);
97
- const status = this.parseResponse(() => parseShellyStatus(response));
98
- this.healthy = true;
99
- if (this.snapshot) this.updateStatus(status, true, Date.now());
100
- return status;
90
+ public getStatus(signalArg?: AbortSignal): Promise<IShellyStatus> {
91
+ return this.run(async (operationArg) => (await this.read(operationArg)).status, signalArg);
101
92
  }
102
-
103
- public async getDeviceConfig(abortSignalArg?: AbortSignal): Promise<IShellyDeviceConfig> {
104
- this.assertActive(abortSignalArg);
105
- const response = await this.callRpc('Shelly.GetConfig', undefined, abortSignalArg);
106
- return this.parseResponse(() => parseShellyConfig(response));
93
+ public getDeviceConfig(signalArg?: AbortSignal): Promise<IShellyDeviceConfig> {
94
+ return this.run(async (operationArg) => (await this.read(operationArg)).deviceConfig ?? {}, signalArg);
107
95
  }
108
-
109
- public async setSwitchOutput(switchIdArg: number, onArg: boolean, abortSignalArg?: AbortSignal): Promise<void> {
110
- if (!Number.isSafeInteger(switchIdArg) || switchIdArg < 0 || typeof onArg !== 'boolean') throw new Error('Shelly switch writes require a non-negative integer id and boolean output.');
111
- const snapshot = await this.getSnapshot(abortSignalArg);
112
- if (!isShellyRecord(snapshot.status[`switch:${switchIdArg}`])) throw new Error('Shelly switch is not present on the connected device.');
113
- const result = await this.callRpc('Switch.Set', { id: switchIdArg, on: onArg }, abortSignalArg);
114
- this.parseResponse(() => {
96
+ public setSwitchOutput(idArg: number, onArg: boolean, signalArg?: AbortSignal): Promise<IShellyEffectResult> {
97
+ if (!Number.isSafeInteger(idArg) || idArg < 0 || typeof onArg !== 'boolean') return Promise.reject(new ShellyOperationError('Shelly switch writes require a non-negative integer id and boolean output.'));
98
+ if (!this.#config.allowControl) return Promise.reject(new ShellyOperationError('Shelly output controls are disabled.'));
99
+ return this.run(async (operationArg) => {
100
+ const before = await this.read(operationArg);
101
+ if (!before.methods.includes('Switch.Set') || !before.methods.includes('Switch.GetStatus')) throw new ShellyOperationError('Shelly does not currently allow the required switch methods.');
102
+ if (!isShellyRecord(before.status[`switch:${idArg}`])) throw new ShellyOperationError('Shelly switch is not present on the connected device.');
103
+ parseShellyStatus({ [`switch:${idArg}`]: await this.rpc(operationArg, 'Switch.GetStatus', { id: idArg }) });
104
+ const result = await this.rpc(operationArg, 'Switch.Set', { id: idArg, on: onArg }, true);
115
105
  if (typeof result.was_on !== 'boolean') throw new DeviceCommunicationError('Shelly returned an invalid switch response.');
116
- });
117
- await this.getStatus(abortSignalArg);
106
+ operationArg.acknowledged = true;
107
+ this.identity(await this.rpc(operationArg, 'Shelly.GetDeviceInfo'));
108
+ const status = parseShellyStatus({ [`switch:${idArg}`]: await this.rpc(operationArg, 'Switch.GetStatus', { id: idArg }) });
109
+ const component = status[`switch:${idArg}`];
110
+ if (!isShellyRecord(component)) throw new DeviceCommunicationError('Shelly returned an invalid switch readback.');
111
+ const snapshot = { ...before, status: { ...before.status, ...status } };
112
+ this.observe(snapshot);
113
+ return { componentId: idArg, requested: onArg, wasOn: result.was_on, commandAcknowledged: true,
114
+ causalityConfirmed: false, outcome: component.output === onArg ? 'observed-target' : 'indeterminate',
115
+ snapshot: structuredClone(snapshot) };
116
+ }, signalArg);
118
117
  }
119
-
120
- public async subscribe(handlerArg: (eventArg: TShellyClientEvent) => void, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
121
- this.assertActive(abortSignalArg);
122
- if (!this.snapshot) await this.getSnapshot(abortSignalArg);
123
- let rejectCancelled: ((errorArg: Error) => void) | undefined;
124
- const cancellation = new Promise<never>((_, rejectArg) => { rejectCancelled = rejectArg; });
125
- const subscriber: IShellySubscriber = {
126
- handler: handlerArg,
127
- signal: abortSignalArg,
128
- abort: () => {
129
- rejectCancelled?.(new DOMException('Shelly subscription cancelled.', 'AbortError'));
130
- void unsubscribe().catch(() => {
131
- this.logger?.log('error', 'Shelly subscription cleanup failed.');
132
- this.emit({ type: 'error', error: new DeviceCommunicationError('Shelly subscription cleanup failed.'), timestamp: Date.now() });
133
- });
134
- },
135
- };
136
- let removed = false;
137
- const unsubscribe = async (): Promise<void> => {
138
- if (!removed) {
139
- removed = true;
140
- subscriber.signal?.removeEventListener('abort', subscriber.abort);
141
- this.subscribers.delete(subscriber);
142
- }
143
- if (this.subscribers.size === 0 && this.subscription) {
144
- const subscription = this.subscription;
145
- await subscription.stop();
146
- if (this.subscription === subscription) this.subscription = undefined;
147
- }
148
- };
149
- this.subscribers.add(subscriber);
150
- abortSignalArg?.addEventListener('abort', subscriber.abort, { once: true });
151
- if (abortSignalArg?.aborted) subscriber.abort();
118
+ public subscribe(handlerArg: (eventArg: TShellyClientEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
152
119
  try {
153
- await Promise.race([this.connectSubscription(), cancellation]);
154
- this.assertActive(abortSignalArg);
155
- rejectCancelled = undefined;
156
- return unsubscribe;
157
- } catch (errorArg) {
158
- await unsubscribe();
120
+ this.assertOpen();
121
+ if (typeof handlerArg !== 'function' || signalArg !== undefined && !(signalArg instanceof AbortSignal)) throw new TypeError('Shelly subscription requires a callback and optional AbortSignal.');
122
+ if (signalArg?.aborted) throw new DOMException('Shelly subscription cancelled.', 'AbortError');
123
+ if (this.#subscribers.size >= 32) throw new Error('Shelly subscriber capacity reached.');
124
+ } catch (errorArg) { return Promise.reject(errorArg); }
125
+ const aborted = (): void => { void subscriber.stop().catch(() => undefined); };
126
+ const subscriber: IShellySubscriber = { handler: handlerArg, active: true, failed: false,
127
+ detach: () => { subscriber.active = false; signalArg?.removeEventListener('abort', aborted); },
128
+ stop: async () => {
129
+ subscriber.detach();
130
+ if (!subscriber.failed) this.#subscribers.delete(subscriber);
131
+ if (![...this.#subscribers].some((entryArg) => entryArg.active) && this.#subscription) {
132
+ const subscription = this.#subscription;
133
+ await subscription.stop();
134
+ if (this.#subscription === subscription) this.#subscription = undefined;
135
+ this.#subscriptions.delete(subscription);
136
+ }
137
+ if (subscriber.failed) throw new Error('Shelly subscriber callback failed.');
138
+ } };
139
+ this.#subscribers.add(subscriber);
140
+ signalArg?.addEventListener('abort', aborted, { once: true });
141
+ if (signalArg?.aborted) aborted();
142
+ return this.run(async (operationArg) => {
143
+ if (!this.#snapshot) await this.read(operationArg); else await this.resolveCredential(operationArg);
144
+ this.active(operationArg);
145
+ await this.connectSubscription(operationArg);
146
+ this.active(operationArg);
147
+ return subscriber.stop;
148
+ }, signalArg).catch(async (errorArg: unknown) => {
149
+ try { await subscriber.stop(); } catch (cleanupArg) { throw new AggregateError([errorArg, cleanupArg], 'Shelly subscription and cleanup failed.'); }
159
150
  throw errorArg;
151
+ });
152
+ }
153
+ /** Memo precedes abort and all foreign callbacks. */
154
+ public destroy(): Promise<void> {
155
+ if (!this.#closePromise) {
156
+ this.#closed = true;
157
+ this.#healthy = false;
158
+ this.#closePromise = Promise.resolve().then(() => this.drainCleanup(false));
159
+ this.#cleanupPromise = this.#closePromise;
160
+ this.#context.abortSignal?.removeEventListener('abort', this.#parentAbort);
161
+ for (const subscriber of this.#subscribers) subscriber.detach();
162
+ this.#lifetime.abort(new DOMException('Shelly operation cancelled.', 'AbortError'));
163
+ for (const request of this.#requests) request.destroy();
164
+ for (const socket of this.#sockets) socket.destroy();
160
165
  }
166
+ return this.#closePromise;
161
167
  }
162
-
163
- public async destroy(): Promise<void> {
164
- this.destroyed = true;
165
- this.healthy = false;
166
- for (const subscriber of this.subscribers) subscriber.signal?.removeEventListener('abort', subscriber.abort);
167
- this.subscribers.clear();
168
- this.lifetime.abort();
169
- await this.requestQueue;
170
- if (this.subscription) {
171
- await this.subscription.stop();
172
- this.subscription = undefined;
168
+ /** Terminal cleanup retry never resolves credentials or replays transport. */
169
+ public retryCleanup(): Promise<void> {
170
+ if (!this.#closePromise) return this.destroy();
171
+ if (!this.#cleanupFailed) return this.#cleanupPromise ?? this.#closePromise;
172
+ if (!this.#retryPromise) {
173
+ this.#retryPromise = Promise.resolve().then(() => this.drainCleanup(true));
174
+ this.#cleanupPromise = this.#retryPromise;
175
+ const settled = (): void => { this.#retryPromise = undefined; };
176
+ void this.#retryPromise.then(settled, settled);
173
177
  }
178
+ return this.#retryPromise;
174
179
  }
175
-
176
- private async connectSubscription(): Promise<void> {
177
- if (this.subscription?.isStopping) {
178
- const previous = this.subscription;
179
- await previous.stop();
180
- if (this.subscription === previous) this.subscription = undefined;
180
+ private async drainCleanup(retryArg: boolean): Promise<void> {
181
+ const cleanup = [...this.#subscriptions].map((entryArg) => Promise.resolve().then(() => retryArg ? entryArg.retryCleanup() : entryArg.stop()));
182
+ const results = await Promise.allSettled(cleanup);
183
+ while (this.#tasks.size || this.#credentials.size || this.#drains.size) await Promise.allSettled([...this.#tasks, ...this.#credentials, ...this.#drains]);
184
+ const late = await Promise.allSettled([...this.#subscriptions].map((entryArg) => retryArg ? entryArg.retryCleanup() : entryArg.stop()));
185
+ const failures = [...results, ...late].filter((entryArg): entryArg is PromiseRejectedResult => entryArg.status === 'rejected').map((entryArg) => entryArg.reason as unknown);
186
+ if (!retryArg) {
187
+ failures.push(...this.#credentialFailures);
188
+ for (const subscriber of this.#subscribers) if (subscriber.failed) failures.push(new Error('Shelly subscriber callback failed.'));
189
+ } else this.#credentialFailures.clear();
190
+ for (const subscription of this.#subscriptions) if (subscription.isClosed) this.#subscriptions.delete(subscription);
191
+ if (retryArg || !failures.length) this.#subscribers.clear();
192
+ this.#resolvePassword = undefined;
193
+ this.#snapshot = undefined;
194
+ this.#cleanupFailed = failures.length > 0;
195
+ if (failures.length) throw new AggregateError([...new Set(failures)], 'Shelly owned cleanup failed.');
196
+ }
197
+ private async read(operationArg: IShellyOperation): Promise<IShellySnapshot> {
198
+ await this.bootstrap(operationArg);
199
+ const deviceInfo = this.identity(await this.rpc(operationArg, 'Shelly.GetDeviceInfo'));
200
+ const allowed = await this.rpc(operationArg, 'Shelly.ListMethods');
201
+ if (!Array.isArray(allowed.methods) || allowed.methods.length > 512 ||
202
+ !allowed.methods.every((methodArg: unknown) => typeof methodArg === 'string' && /^[A-Za-z][A-Za-z0-9]*\.[A-Za-z][A-Za-z0-9]*$/.test(methodArg))) throw new DeviceCommunicationError('Shelly returned invalid allowed methods.');
203
+ const methods = [...new Set(allowed.methods as string[])];
204
+ if (!methods.includes('Shelly.GetStatus') || !methods.includes('Shelly.GetConfig') || !methods.includes('Shelly.GetDeviceInfo')) throw new DeviceCommunicationError('Shelly does not currently allow the required observation methods.');
205
+ const status = parseShellyStatus(await this.rpc(operationArg, 'Shelly.GetStatus'));
206
+ if (status.sys?.mac !== undefined && status.sys.mac.toUpperCase() !== this.#config.expectedMacAddress) throw new DeviceCommunicationError('Shelly endpoint device identity changed.');
207
+ const deviceConfig = parseShellyConfig(await this.rpc(operationArg, 'Shelly.GetConfig'));
208
+ const snapshot: IShellySnapshot = { homeId: this.#context.homeId, deviceInfo, status, deviceConfig, methods, controlEnabled: this.#config.allowControl };
209
+ this.observe(snapshot);
210
+ return structuredClone(snapshot);
211
+ }
212
+ private async bootstrap(operationArg: IShellyOperation): Promise<void> {
213
+ this.identity(await this.requestJson(operationArg, '/shelly'));
214
+ await this.resolveCredential(operationArg);
215
+ }
216
+ private identity(valueArg: unknown): IShellyDeviceInfo {
217
+ const info = parseShellyDeviceInfo(valueArg);
218
+ if (info.id !== this.#config.expectedDeviceId || info.mac?.toUpperCase() !== this.#config.expectedMacAddress || info.model !== this.#config.expectedModel || info.gen !== this.#config.expectedGeneration) throw new DeviceCommunicationError('Shelly endpoint device identity changed.');
219
+ return info;
220
+ }
221
+ private observe(snapshotArg: IShellySnapshot): void {
222
+ this.assertOpen();
223
+ this.#snapshot = structuredClone(snapshotArg);
224
+ this.#healthy = true;
225
+ this.emit({ type: 'status', snapshot: structuredClone(snapshotArg), timestamp: Date.now() });
226
+ }
227
+ private async connectSubscription(operationArg: IShellyOperation): Promise<void> {
228
+ if (this.#subscription?.isStopping) {
229
+ await this.#subscription.stop();
230
+ this.#subscriptions.delete(this.#subscription);
231
+ this.#subscription = undefined;
181
232
  }
182
- this.assertActive();
183
- if (!this.subscription || this.subscription.isClosed) {
184
- if (!this.deviceId) throw new Error('Shelly subscription requires a validated device.');
185
- const url = new URL('/rpc', this.endpoint);
186
- url.protocol = this.endpoint.protocol === 'https:' ? 'wss:' : 'ws:';
187
- this.subscription = new ShellySubscription({
188
- url: url.toString(),
189
- deviceId: this.deviceId,
190
- source: `shx-${plugins.crypto.randomUUID()}`,
191
- password: this.config.password,
192
- timeoutMs: this.timeoutMs,
193
- abortSignal: this.lifetimeSignal,
233
+ this.active(operationArg);
234
+ if (!this.#subscription || this.#subscription.isClosed) {
235
+ const url = new URL('/rpc', this.#endpoint);
236
+ url.protocol = this.#config.protocol === 'https' ? 'wss:' : 'ws:';
237
+ const subscription = new ShellySubscription({ url: url.toString(), deviceId: this.#config.expectedDeviceId,
238
+ source: `shx-${plugins.crypto.randomUUID()}`, password: operationArg.password,
239
+ timeoutMs: this.#config.requestTimeoutMs, abortSignal: this.#lifetime.signal,
194
240
  onStatus: (statusArg, fullArg, timestampArg) => this.updateStatus(statusArg, fullArg, timestampArg),
195
241
  onEvent: (eventArg, timestampArg) => {
196
242
  this.emit({ type: 'event', event: eventArg, timestamp: timestampArg });
197
- if (eventArg.event === 'config_changed') void this.refreshConfig(timestampArg);
243
+ if (eventArg.event === 'config_changed') void this.run((operationArg) => this.read(operationArg)).catch(() => {
244
+ if (!this.#closed) this.emit({ type: 'error', error: new DeviceCommunicationError('Shelly configuration refresh failed.'), timestamp: Date.now() });
245
+ });
198
246
  },
199
247
  onAvailability: (availableArg) => this.emit({ type: 'availability', available: availableArg, timestamp: Date.now() }),
200
- onError: (errorArg) => this.emit({ type: 'error', error: errorArg, timestamp: Date.now() }),
201
- });
248
+ onError: (errorArg) => this.emit({ type: 'error', error: errorArg, timestamp: Date.now() }) });
249
+ this.#subscriptions.add(subscription);
250
+ this.#subscription = subscription;
202
251
  }
203
- await this.subscription.start();
252
+ const subscription = this.#subscription;
253
+ let cancelled: () => void = () => {};
254
+ const cancellation = new Promise<never>((_resolveArg, rejectArg) => {
255
+ cancelled = () => { try { this.active(operationArg); } catch (errorArg) { rejectArg(errorArg); } };
256
+ operationArg.signal.addEventListener('abort', cancelled, { once: true });
257
+ });
258
+ try { await Promise.race([subscription.start(), cancellation]); }
259
+ finally { operationArg.signal.removeEventListener('abort', cancelled); }
204
260
  }
205
-
206
261
  private updateStatus(statusArg: IShellyStatus, fullArg: boolean, timestampArg: number): void {
207
- if (!this.snapshot || this.destroyed) return;
208
- this.snapshot = { ...this.snapshot, status: fullArg ? statusArg : mergeShellyStatus(this.snapshot.status, statusArg) };
209
- this.emit({ type: 'status', snapshot: structuredClone(this.snapshot), timestamp: timestampArg });
262
+ if (!this.#snapshot || this.#closed) return;
263
+ const status = fullArg ? statusArg : mergeShellyStatus(this.#snapshot.status, statusArg);
264
+ if (status.sys?.mac !== undefined && status.sys.mac.toUpperCase() !== this.#config.expectedMacAddress) throw new DeviceCommunicationError('Shelly notification identity changed.');
265
+ this.#snapshot = { ...this.#snapshot, status };
266
+ this.emit({ type: 'status', snapshot: structuredClone(this.#snapshot), timestamp: timestampArg });
210
267
  }
211
-
212
268
  private emit(eventArg: TShellyClientEvent): void {
213
- for (const subscriber of [...this.subscribers]) {
214
- if (!this.subscribers.has(subscriber)) continue;
269
+ if (this.#closed) return;
270
+ for (const subscriber of [...this.#subscribers]) {
271
+ if (this.#closed) break;
272
+ if (!subscriber.active) continue;
215
273
  try { subscriber.handler(structuredClone(eventArg)); }
216
274
  catch {
217
- this.logger?.log('error', 'Shelly subscriber callback failed.');
218
- subscriber.abort();
275
+ subscriber.failed = true;
276
+ subscriber.detach();
277
+ try { this.#context.logger?.log('error', 'Shelly subscriber callback failed.'); } catch { /* Failure remains retained. */ }
219
278
  this.emit({ type: 'error', error: new DeviceCommunicationError('Shelly subscriber callback failed.'), timestamp: Date.now() });
220
279
  }
221
280
  }
222
281
  }
223
-
224
- private async refreshConfig(timestampArg: number): Promise<void> {
282
+ private async resolveCredential(operationArg: IShellyOperation): Promise<void> {
283
+ if (this.#config.credentialRef === undefined) return;
284
+ this.active(operationArg);
285
+ const resolver = this.#resolvePassword;
286
+ if (!resolver) throw new AuthenticationError('Shelly credential resolver is unavailable.');
287
+ const credential = Promise.resolve().then(() => {
288
+ this.active(operationArg);
289
+ return resolver(Object.freeze({ homeId: this.#context.homeId, credentialRef: this.#config.credentialRef!, signal: operationArg.signal, deadline: operationArg.deadline }));
290
+ });
291
+ this.#credentials.add(credential);
292
+ this.#credentialOperations.set(credential, operationArg.task!);
293
+ void credential.then(() => { this.#credentials.delete(credential); this.#credentialOperations.delete(credential); }, (errorArg: unknown) => {
294
+ this.#credentials.delete(credential);
295
+ this.#credentialOperations.delete(credential);
296
+ if (operationArg.signal.aborted && errorArg !== operationArg.signal.reason) this.#credentialFailures.add(new Error('Shelly caller credential resolution failed.'));
297
+ });
298
+ let cancel: () => void = () => {};
299
+ const cancelled = new Promise<never>((_resolveArg, rejectArg) => {
300
+ cancel = () => { try { this.active(operationArg); } catch (errorArg) { rejectArg(errorArg); } };
301
+ operationArg.signal.addEventListener('abort', cancel, { once: true });
302
+ });
225
303
  try {
226
- const deviceConfig = await this.getDeviceConfig();
227
- if (this.snapshot && !this.destroyed) {
228
- this.snapshot = { ...this.snapshot, deviceConfig };
229
- this.emit({ type: 'status', snapshot: structuredClone(this.snapshot), timestamp: timestampArg });
230
- }
231
- } catch {
232
- if (!this.destroyed && !this.lifetimeSignal.aborted) this.emit({ type: 'error', error: new DeviceCommunicationError('Shelly configuration refresh failed.'), timestamp: Date.now() });
233
- }
234
- }
235
-
236
- private parseResponse<TResult>(parserArg: () => TResult): TResult {
237
- try { return parserArg(); }
238
- catch (errorArg) { this.healthy = false; throw errorArg; }
304
+ const password = await Promise.race([credential, cancelled]);
305
+ this.active(operationArg);
306
+ if (typeof password !== 'string' || !password || password.length > 1024) throw new AuthenticationError('Shelly caller credential resolution failed.');
307
+ operationArg.password = password;
308
+ } catch { this.active(operationArg); throw new AuthenticationError('Shelly caller credential resolution failed.'); }
309
+ finally { operationArg.signal.removeEventListener('abort', cancel); }
239
310
  }
240
-
241
- private async callRpc(methodArg: string, paramsArg?: Record<string, unknown>, abortSignalArg?: AbortSignal): Promise<Record<string, unknown>> {
242
- if (!this.deviceId) await this.getDeviceInfo(abortSignalArg);
243
- const id = this.requestId++;
311
+ private rpc(operationArg: IShellyOperation, methodArg: string, paramsArg?: Record<string, unknown>, effectArg = false): Promise<Record<string, unknown>> {
312
+ const id = this.#requestId++;
244
313
  const source = 'smarthome.exchange';
245
- const frame = await this.requestJson('/rpc', { id, src: source, method: methodArg, params: paramsArg }, abortSignalArg);
246
- return this.parseResponse(() => parseShellyRpcResult(frame, id, this.deviceId, source));
314
+ return this.requestJson(operationArg, '/rpc', { id, src: source, method: methodArg, ...(paramsArg ? { params: paramsArg } : {}) }, effectArg).then((frameArg) => parseShellyRpcResult(frameArg, id, this.#config.expectedDeviceId, source));
247
315
  }
248
-
249
- private async requestJson(pathArg: string, bodyArg?: Record<string, unknown>, abortSignalArg?: AbortSignal): Promise<unknown> {
250
- this.assertActive(abortSignalArg);
251
- const deadline = new AbortController();
252
- let timedOut = false;
253
- const timer = setTimeout(() => { timedOut = true; deadline.abort(); }, this.timeoutMs);
254
- const signal = AbortSignal.any([deadline.signal, this.lifetimeSignal, ...(abortSignalArg ? [abortSignalArg] : [])]);
255
- const cancelled = (): Error => timedOut ? new DeviceCommunicationError('Shelly request timed out.') : new DOMException('Shelly request cancelled.', 'AbortError');
256
- let rejectCancelled: ((errorArg: Error) => void) | undefined;
257
- const cancellation = new Promise<never>((_, rejectArg) => { rejectCancelled = rejectArg; });
258
- const handleAbort = (): void => rejectCancelled?.(cancelled());
259
- signal.addEventListener('abort', handleAbort, { once: true });
260
- if (signal.aborted) handleAbort();
261
- const operation = this.requestQueue.then(async () => {
262
- if (signal.aborted) throw cancelled();
263
- const method = bodyArg ? 'POST' : 'GET';
264
- let response = await this.fetchPath(pathArg, method, bodyArg, signal);
265
- if (response.status === 401) {
266
- await response.body?.cancel();
267
- if (!this.config.password) throw new AuthenticationError('Shelly authentication requires a password.');
268
- this.digestChallenge = parseShellyHttpChallenge(response.headers.get('www-authenticate'), this.deviceId);
269
- this.nonceCount = 0;
270
- response = await this.fetchPath(pathArg, method, bodyArg, signal);
271
- }
272
- if (!response.ok) {
273
- await response.body?.cancel();
274
- if (response.status === 401) throw new AuthenticationError('Shelly authentication failed.');
275
- throw new DeviceCommunicationError(`Shelly request failed with HTTP ${response.status}.`);
276
- }
277
- if (!response.body) throw new DeviceCommunicationError('Shelly returned an empty response.');
278
- const reader = response.body.getReader();
279
- const chunks: Uint8Array[] = [];
280
- let size = 0;
281
- try {
282
- while (true) {
283
- const { done, value } = await reader.read();
284
- if (done) break;
285
- size += value.byteLength;
286
- if (size > 1024 * 1024) {
287
- await reader.cancel();
288
- throw new DeviceCommunicationError('Shelly response exceeds the size limit.');
289
- }
290
- chunks.push(value);
291
- }
292
- } finally { reader.releaseLock(); }
293
- return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
294
- });
295
- this.requestQueue = operation.then(() => undefined, () => undefined);
296
- try { return await Promise.race([operation, cancellation]); }
297
- catch (errorArg) {
298
- this.healthy = false;
299
- if (signal.aborted) throw cancelled();
300
- if (errorArg instanceof AuthenticationError || errorArg instanceof DeviceCommunicationError) throw errorArg;
301
- throw new DeviceCommunicationError('Shelly request failed or returned invalid JSON.');
302
- } finally {
303
- clearTimeout(timer);
304
- signal.removeEventListener('abort', handleAbort);
305
- rejectCancelled = undefined;
316
+ private async requestJson(operationArg: IShellyOperation, pathArg: string, bodyArg?: Record<string, unknown>, effectArg = false): Promise<unknown> {
317
+ this.active(operationArg);
318
+ let response = await this.request(operationArg, pathArg, bodyArg, effectArg);
319
+ if (response.status === 401 && !effectArg) {
320
+ if (!operationArg.password) throw new AuthenticationError('Shelly authentication requires a password.');
321
+ this.#challenge = parseShellyHttpChallenge(response.challenge ?? null, this.#config.expectedDeviceId);
322
+ this.#nonceCount = 0;
323
+ response = await this.request(operationArg, pathArg, bodyArg);
306
324
  }
325
+ if (response.status !== 200) {
326
+ if (response.status === 401) throw new AuthenticationError('Shelly authentication failed.');
327
+ throw new DeviceCommunicationError('Shelly HTTP request was rejected.');
328
+ }
329
+ try { return JSON.parse(response.body) as unknown; } catch { throw new DeviceCommunicationError('Shelly returned invalid JSON.'); }
307
330
  }
308
-
309
- private async fetchPath(pathArg: string, methodArg: string, bodyArg: Record<string, unknown> | undefined, signalArg: AbortSignal): Promise<Response> {
310
- const headers: Record<string, string> = {};
311
- if (bodyArg) headers['content-type'] = 'application/json';
312
- if (this.digestChallenge && this.config.password) {
313
- const digest = createShellyDigest(this.digestChallenge, this.config.password, ++this.nonceCount, methodArg, pathArg);
314
- headers.authorization = `Digest username="admin", realm="${this.digestChallenge.realm}", nonce="${this.digestChallenge.nonce}", uri="${pathArg}", algorithm=SHA-256, response="${digest.response}", qop=auth, nc=${digest.nc}, cnonce="${digest.cnonce}"${this.digestChallenge.opaque ? `, opaque="${this.digestChallenge.opaque}"` : ''}`;
331
+ private async request(operationArg: IShellyOperation, pathArg: string, bodyArg?: Record<string, unknown>, effectArg = false): Promise<IShellyHttpResponse> {
332
+ this.active(operationArg);
333
+ const method = bodyArg ? 'POST' : 'GET';
334
+ const payload = bodyArg ? JSON.stringify(bodyArg) : undefined;
335
+ const headers: Record<string, string | number> = { connection: 'close', 'accept-encoding': 'identity' };
336
+ if (payload) { headers['content-type'] = 'application/json'; headers['content-length'] = Buffer.byteLength(payload); }
337
+ if (this.#challenge && operationArg.password) {
338
+ const digest = createShellyDigest(this.#challenge, operationArg.password, ++this.#nonceCount, method, pathArg);
339
+ headers.authorization = `Digest username="admin", realm="${this.#challenge.realm}", nonce="${this.#challenge.nonce}", uri="${pathArg}", algorithm=SHA-256, response="${digest.response}", qop=auth, nc=${digest.nc}, cnonce="${digest.cnonce}"${this.#challenge.opaque ? `, opaque="${this.#challenge.opaque}"` : ''}`;
340
+ }
341
+ let request: plugins.http.ClientRequest | undefined;
342
+ let socketDrain: Promise<void> = Promise.resolve();
343
+ let resolveRequestClose: () => void = () => {};
344
+ const requestDrain = new Promise<void>((resolveArg) => { resolveRequestClose = resolveArg; });
345
+ const abort = (): void => { request?.destroy(); };
346
+ operationArg.signal.addEventListener('abort', abort, { once: true });
347
+ let failure: unknown;
348
+ let result: IShellyHttpResponse | undefined;
349
+ try {
350
+ result = await new Promise<IShellyHttpResponse>((resolveArg, rejectArg) => {
351
+ const transport = this.#config.protocol === 'https' ? plugins.https : plugins.http;
352
+ request = transport.request(new URL(pathArg, this.#endpoint), { method, headers, agent: false, maxHeaderSize: 8192 }, (responseArg) => {
353
+ const fail = (messageArg: string): void => { rejectArg(new DeviceCommunicationError(messageArg)); responseArg.destroy(); };
354
+ if (responseArg.rawHeaders.length > 128 || responseArg.headers['content-encoding'] && responseArg.headers['content-encoding'] !== 'identity') { fail('Shelly returned unsupported response headers.'); return; }
355
+ const status = responseArg.statusCode ?? 0;
356
+ if (status === 200 && !/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(responseArg.headers['content-type'] ?? '')) { fail('Shelly returned unsupported response content.'); return; }
357
+ const chunks: Buffer[] = [];
358
+ let size = 0;
359
+ responseArg.on('data', (chunkArg: unknown) => {
360
+ if (!Buffer.isBuffer(chunkArg)) { fail('Shelly returned invalid response bytes.'); return; }
361
+ size += chunkArg.length;
362
+ if (size > this.#config.maxResponseBytes) { fail('Shelly response exceeds the size limit.'); return; }
363
+ chunks.push(chunkArg);
364
+ });
365
+ responseArg.once('error', () => rejectArg(new DeviceCommunicationError('Shelly response transport failed.')));
366
+ responseArg.once('end', () => {
367
+ if (!responseArg.complete) { fail('Shelly response was truncated.'); return; }
368
+ try { resolveArg({ status, challenge: responseArg.headers['www-authenticate'], body: new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)) }); }
369
+ catch { fail('Shelly returned invalid UTF-8.'); }
370
+ });
371
+ });
372
+ this.#requests.add(request);
373
+ this.#drains.add(requestDrain);
374
+ request.once('close', () => { this.#requests.delete(request!); this.#drains.delete(requestDrain); resolveRequestClose(); });
375
+ request.once('error', () => rejectArg(new DeviceCommunicationError('Shelly request transport failed.')));
376
+ request.once('socket', (socketArg) => {
377
+ this.#sockets.add(socketArg);
378
+ socketDrain = new Promise<void>((resolveArg) => socketArg.once('close', () => { this.#sockets.delete(socketArg); this.#drains.delete(socketDrain); resolveArg(); }));
379
+ this.#drains.add(socketDrain);
380
+ if (this.#closed || operationArg.signal.aborted) socketArg.destroy();
381
+ });
382
+ try { this.active(operationArg); if (effectArg) operationArg.dispatched = true; request.end(payload); }
383
+ catch (errorArg) { request.destroy(); rejectArg(errorArg); }
384
+ });
385
+ } catch (errorArg) { failure = errorArg; request?.destroy(); }
386
+ finally {
387
+ if (request) await Promise.allSettled([requestDrain, socketDrain]);
388
+ operationArg.signal.removeEventListener('abort', abort);
315
389
  }
316
- return globalThis.fetch(new URL(pathArg, this.endpoint), { method: methodArg, headers, body: bodyArg ? JSON.stringify(bodyArg) : undefined, signal: signalArg, redirect: 'error' });
390
+ this.active(operationArg);
391
+ if (failure !== undefined) throw failure;
392
+ if (!result) throw new DeviceCommunicationError('Shelly request failed.');
393
+ return result;
317
394
  }
318
-
319
- private assertActive(abortSignalArg?: AbortSignal): void {
320
- if (this.destroyed) throw new Error('Shelly client has been destroyed.');
321
- if (this.lifetimeSignal.aborted || abortSignalArg?.aborted) throw new DOMException('Shelly operation cancelled.', 'AbortError');
395
+ private run<TResult>(workArg: (operationArg: IShellyOperation) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
396
+ try {
397
+ this.assertOpen();
398
+ if (signalArg !== undefined && !(signalArg instanceof AbortSignal)) throw new TypeError('Shelly cancellation requires an AbortSignal.');
399
+ if (signalArg?.aborted) throw new DOMException('Shelly operation cancelled.', 'AbortError');
400
+ const retainedCredentials = [...this.#credentialOperations.values()].filter((entryArg) => !this.#tasks.has(entryArg)).length;
401
+ if (this.#tasks.size + retainedCredentials + this.#credentialFailures.size >= this.#config.maxPendingOperations) throw new ShellyOperationError('Shelly operation capacity reached.');
402
+ } catch (errorArg) { return Promise.reject(errorArg); }
403
+ const controller = new AbortController();
404
+ const operation: IShellyOperation = { signal: AbortSignal.any([controller.signal, this.#lifetime.signal]), deadline: performance.now() + this.#config.requestTimeoutMs, dispatched: false, acknowledged: false };
405
+ const cancel = (): void => controller.abort(new DOMException('Shelly operation cancelled.', 'AbortError'));
406
+ signalArg?.addEventListener('abort', cancel, { once: true });
407
+ const timer = setTimeout(() => controller.abort(new DeviceCommunicationError('Shelly request timed out.')), this.#config.requestTimeoutMs);
408
+ let rejectCancelled: () => void = () => {};
409
+ const cancellation = new Promise<never>((_resolveArg, rejectArg) => {
410
+ rejectCancelled = () => { try { this.active(operation); } catch (errorArg) { rejectArg(this.operationError(errorArg, operation)); } };
411
+ operation.signal.addEventListener('abort', rejectCancelled, { once: true });
412
+ });
413
+ const task = this.#tail.then(async () => {
414
+ this.active(operation);
415
+ try { return await workArg(operation); }
416
+ catch (errorArg) { this.#healthy = false; throw this.operationError(errorArg, operation); }
417
+ finally { operation.password = undefined; }
418
+ });
419
+ this.#tasks.add(task);
420
+ operation.task = task;
421
+ this.#tail = task.then(() => undefined, () => undefined);
422
+ const settled = (): void => { this.#tasks.delete(task); clearTimeout(timer); signalArg?.removeEventListener('abort', cancel); operation.signal.removeEventListener('abort', rejectCancelled); };
423
+ void task.then(settled, settled);
424
+ if (signalArg?.aborted) cancel();
425
+ return Promise.race([task, cancellation]);
426
+ }
427
+ private operationError(errorArg: unknown, operationArg: IShellyOperation): Error {
428
+ if (operationArg.dispatched) return new ShellyOperationError('Shelly control outcome is indeterminate.', 'indeterminate', operationArg.acknowledged);
429
+ return errorArg instanceof AuthenticationError || errorArg instanceof DeviceCommunicationError || errorArg instanceof ShellyOperationError || errorArg instanceof DOMException && errorArg.name === 'AbortError' ? errorArg : new ShellyOperationError('Shelly operation failed.');
430
+ }
431
+ private active(operationArg: IShellyOperation): void {
432
+ if (operationArg.signal.aborted) throw operationArg.signal.reason;
433
+ this.assertOpen();
434
+ if (performance.now() >= operationArg.deadline) throw new DeviceCommunicationError('Shelly request timed out.');
435
+ }
436
+ private assertOpen(): void {
437
+ if (this.#context.abortSignal?.aborted) throw new DOMException('Shelly operation cancelled.', 'AbortError');
438
+ if (this.#closed) throw new Error('Shelly client has been destroyed.');
322
439
  }
323
440
  }