@smarthome.exchange/integrations 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/changelog.md +35 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/core/classes.nativeintegrationowner.d.ts +2 -0
  4. package/dist_ts/core/classes.nativeintegrationowner.js +5 -1
  5. package/dist_ts/core/classes.runtimemanager.d.ts +2 -0
  6. package/dist_ts/core/classes.runtimemanager.js +11 -3
  7. package/dist_ts/core/types.d.ts +2 -0
  8. package/dist_ts/integrations/deconz/deconz.classes.client.d.ts +37 -29
  9. package/dist_ts/integrations/deconz/deconz.classes.client.js +521 -294
  10. package/dist_ts/integrations/deconz/deconz.classes.configflow.d.ts +6 -1
  11. package/dist_ts/integrations/deconz/deconz.classes.configflow.js +63 -33
  12. package/dist_ts/integrations/deconz/deconz.classes.integration.d.ts +4 -3
  13. package/dist_ts/integrations/deconz/deconz.classes.integration.js +85 -40
  14. package/dist_ts/integrations/deconz/deconz.mapper.d.ts +1 -0
  15. package/dist_ts/integrations/deconz/deconz.mapper.js +24 -6
  16. package/dist_ts/integrations/deconz/deconz.types.d.ts +43 -6
  17. package/dist_ts/integrations/deconz/deconz.validation.d.ts +7 -1
  18. package/dist_ts/integrations/deconz/deconz.validation.js +215 -4
  19. package/dist_ts/integrations/esphome/esphome.classes.client.d.ts +19 -7
  20. package/dist_ts/integrations/esphome/esphome.classes.client.js +183 -89
  21. package/dist_ts/integrations/esphome/esphome.classes.configflow.d.ts +5 -2
  22. package/dist_ts/integrations/esphome/esphome.classes.configflow.js +43 -33
  23. package/dist_ts/integrations/esphome/esphome.classes.integration.d.ts +8 -6
  24. package/dist_ts/integrations/esphome/esphome.classes.integration.js +71 -43
  25. package/dist_ts/integrations/esphome/esphome.discovery.js +4 -12
  26. package/dist_ts/integrations/esphome/esphome.mapper.d.ts +2 -2
  27. package/dist_ts/integrations/esphome/esphome.mapper.js +6 -4
  28. package/dist_ts/integrations/esphome/esphome.types.d.ts +31 -6
  29. package/dist_ts/integrations/esphome/esphome.types.js +1 -1
  30. package/dist_ts/integrations/esphome/esphome.validation.d.ts +7 -0
  31. package/dist_ts/integrations/esphome/esphome.validation.js +66 -0
  32. package/dist_ts/integrations/homeassistant/homeassistant.classes.client.d.ts +32 -4
  33. package/dist_ts/integrations/homeassistant/homeassistant.classes.client.js +345 -116
  34. package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.d.ts +8 -5
  35. package/dist_ts/integrations/homeassistant/homeassistant.classes.integration.js +34 -8
  36. package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.d.ts +7 -4
  37. package/dist_ts/integrations/homeassistant/homeassistant.classes.runtime.js +44 -56
  38. package/dist_ts/integrations/homeassistant/homeassistant.types.d.ts +12 -1
  39. package/dist_ts/integrations/hue/hue.classes.client.d.ts +6 -1
  40. package/dist_ts/integrations/hue/hue.classes.client.js +120 -45
  41. package/dist_ts/integrations/hue/hue.classes.configflow.js +16 -7
  42. package/dist_ts/integrations/hue/hue.classes.integration.d.ts +2 -0
  43. package/dist_ts/integrations/hue/hue.classes.integration.js +103 -30
  44. package/dist_ts/integrations/hue/hue.validation.d.ts +1 -0
  45. package/dist_ts/integrations/hue/hue.validation.js +14 -4
  46. package/dist_ts/integrations/mqtt/mqtt.classes.client.d.ts +15 -2
  47. package/dist_ts/integrations/mqtt/mqtt.classes.client.js +173 -23
  48. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.d.ts +5 -1
  49. package/dist_ts/integrations/mqtt/mqtt.classes.configflow.js +53 -33
  50. package/dist_ts/integrations/mqtt/mqtt.classes.integration.d.ts +4 -1
  51. package/dist_ts/integrations/mqtt/mqtt.classes.integration.js +127 -56
  52. package/dist_ts/integrations/mqtt/mqtt.classes.session.d.ts +13 -0
  53. package/dist_ts/integrations/mqtt/mqtt.classes.session.js +130 -0
  54. package/dist_ts/integrations/mqtt/mqtt.mapper.d.ts +3 -0
  55. package/dist_ts/integrations/mqtt/mqtt.mapper.js +44 -1
  56. package/dist_ts/integrations/mqtt/mqtt.types.d.ts +46 -0
  57. package/dist_ts/integrations/mqtt/mqtt.validation.d.ts +7 -0
  58. package/dist_ts/integrations/mqtt/mqtt.validation.js +180 -0
  59. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.d.ts +4 -2
  60. package/dist_ts/integrations/samsungtv/samsungtv.classes.client.js +41 -16
  61. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.d.ts +5 -0
  62. package/dist_ts/integrations/samsungtv/samsungtv.classes.configflow.js +20 -7
  63. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.d.ts +1 -0
  64. package/dist_ts/integrations/samsungtv/samsungtv.classes.integration.js +53 -50
  65. package/dist_ts/integrations/samsungtv/samsungtv.types.d.ts +4 -1
  66. package/dist_ts/integrations/shelly/shelly.classes.client.d.ts +26 -30
  67. package/dist_ts/integrations/shelly/shelly.classes.client.js +504 -276
  68. package/dist_ts/integrations/shelly/shelly.classes.configflow.d.ts +8 -3
  69. package/dist_ts/integrations/shelly/shelly.classes.configflow.js +43 -38
  70. package/dist_ts/integrations/shelly/shelly.classes.integration.d.ts +6 -6
  71. package/dist_ts/integrations/shelly/shelly.classes.integration.js +103 -101
  72. package/dist_ts/integrations/shelly/shelly.classes.subscription.d.ts +4 -0
  73. package/dist_ts/integrations/shelly/shelly.classes.subscription.js +24 -2
  74. package/dist_ts/integrations/shelly/shelly.mapper.d.ts +1 -2
  75. package/dist_ts/integrations/shelly/shelly.mapper.js +21 -20
  76. package/dist_ts/integrations/shelly/shelly.rpc.js +4 -1
  77. package/dist_ts/integrations/shelly/shelly.types.d.ts +42 -2
  78. package/dist_ts/integrations/shelly/shelly.types.js +11 -2
  79. package/dist_ts/integrations/shelly/shelly.validation.d.ts +7 -0
  80. package/dist_ts/integrations/shelly/shelly.validation.js +79 -0
  81. package/dist_ts/integrations/sonos/sonos.classes.client.d.ts +79 -22
  82. package/dist_ts/integrations/sonos/sonos.classes.client.js +427 -139
  83. package/dist_ts/integrations/sonos/sonos.classes.configflow.d.ts +9 -2
  84. package/dist_ts/integrations/sonos/sonos.classes.configflow.js +26 -28
  85. package/dist_ts/integrations/sonos/sonos.classes.integration.d.ts +7 -6
  86. package/dist_ts/integrations/sonos/sonos.classes.integration.js +289 -224
  87. package/dist_ts/integrations/sonos/sonos.config.d.ts +6 -1
  88. package/dist_ts/integrations/sonos/sonos.config.js +23 -2
  89. package/dist_ts/integrations/sonos/sonos.mapper.d.ts +1 -1
  90. package/dist_ts/integrations/sonos/sonos.mapper.js +14 -9
  91. package/dist_ts/integrations/sonos/sonos.matchers.js +3 -3
  92. package/dist_ts/integrations/sonos/sonos.types.d.ts +26 -1
  93. package/dist_ts/integrations/tasmota/tasmota.classes.integration.d.ts +5 -0
  94. package/dist_ts/integrations/tasmota/tasmota.classes.integration.js +23 -7
  95. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.d.ts +19 -5
  96. package/dist_ts/integrations/tasmota/tasmota.classes.runtime.js +115 -41
  97. package/dist_ts/integrations/tasmota/tasmota.config.js +23 -1
  98. package/dist_ts/integrations/tasmota/tasmota.credentials.d.ts +11 -0
  99. package/dist_ts/integrations/tasmota/tasmota.credentials.js +97 -0
  100. package/dist_ts/integrations/tasmota/tasmota.discovery.d.ts +7 -1
  101. package/dist_ts/integrations/tasmota/tasmota.discovery.js +108 -30
  102. package/dist_ts/integrations/tasmota/tasmota.mapper.d.ts +2 -2
  103. package/dist_ts/integrations/tasmota/tasmota.mapper.js +7 -7
  104. package/dist_ts/integrations/tasmota/tasmota.types.d.ts +41 -2
  105. package/dist_ts/integrations/tuya/tuya.classes.integration.d.ts +4 -1
  106. package/dist_ts/integrations/tuya/tuya.classes.integration.js +79 -110
  107. package/dist_ts/integrations/tuya/tuya.classes.runtime.d.ts +5 -2
  108. package/dist_ts/integrations/tuya/tuya.classes.runtime.js +73 -26
  109. package/dist_ts/integrations/tuya/tuya.mapper.js +2 -2
  110. package/dist_ts/integrations/tuya/tuya.types.d.ts +3 -0
  111. package/dist_ts/integrations/tuya/tuya.validation.d.ts +2 -2
  112. package/dist_ts/integrations/tuya/tuya.validation.js +6 -11
  113. package/dist_ts/integrations/webostv/webostv.classes.client.d.ts +6 -5
  114. package/dist_ts/integrations/webostv/webostv.classes.client.js +147 -76
  115. package/dist_ts/integrations/webostv/webostv.classes.configflow.d.ts +7 -2
  116. package/dist_ts/integrations/webostv/webostv.classes.configflow.js +36 -20
  117. package/dist_ts/integrations/webostv/webostv.classes.integration.d.ts +4 -4
  118. package/dist_ts/integrations/webostv/webostv.classes.integration.js +46 -54
  119. package/dist_ts/integrations/webostv/webostv.mapper.js +5 -5
  120. package/dist_ts/integrations/webostv/webostv.types.d.ts +16 -3
  121. package/dist_ts/integrations/webostv/webostv.types.js +1 -1
  122. package/dist_ts/integrations/webostv/webostv.validation.d.ts +3 -1
  123. package/dist_ts/integrations/webostv/webostv.validation.js +23 -1
  124. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.d.ts +6 -1
  125. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.js +18 -8
  126. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.d.ts +4 -1
  127. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.js +54 -6
  128. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.d.ts +12 -6
  129. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.js +73 -59
  130. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.d.ts +3 -2
  131. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.js +17 -12
  132. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.types.d.ts +31 -2
  133. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.d.ts +9 -1
  134. package/dist_ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.js +57 -7
  135. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.d.ts +10 -4
  136. package/dist_ts/integrations/zwave_js/zwave_js.classes.client.js +167 -31
  137. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.d.ts +6 -1
  138. package/dist_ts/integrations/zwave_js/zwave_js.classes.configflow.js +57 -35
  139. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.d.ts +3 -1
  140. package/dist_ts/integrations/zwave_js/zwave_js.classes.integration.js +59 -37
  141. package/dist_ts/integrations/zwave_js/zwave_js.discovery.js +3 -3
  142. package/dist_ts/integrations/zwave_js/zwave_js.mapper.js +41 -24
  143. package/dist_ts/integrations/zwave_js/zwave_js.types.d.ts +25 -3
  144. package/dist_ts/integrations/zwave_js/zwave_js.validation.d.ts +6 -2
  145. package/dist_ts/integrations/zwave_js/zwave_js.validation.js +52 -17
  146. package/dist_ts/plugins.d.ts +2 -1
  147. package/dist_ts/plugins.js +3 -2
  148. package/package.json +8 -8
  149. package/readme.md +25 -6
  150. package/ts/00_commitinfo_data.ts +1 -1
  151. package/ts/core/classes.nativeintegrationowner.ts +5 -0
  152. package/ts/core/classes.runtimemanager.ts +11 -2
  153. package/ts/core/types.ts +2 -0
  154. package/ts/integrations/deconz/deconz.classes.client.ts +348 -252
  155. package/ts/integrations/deconz/deconz.classes.configflow.ts +57 -29
  156. package/ts/integrations/deconz/deconz.classes.integration.ts +69 -37
  157. package/ts/integrations/deconz/deconz.mapper.ts +22 -5
  158. package/ts/integrations/deconz/deconz.types.ts +49 -6
  159. package/ts/integrations/deconz/deconz.validation.ts +177 -4
  160. package/ts/integrations/deconz/readme.md +15 -1
  161. package/ts/integrations/esphome/esphome.classes.client.ts +179 -94
  162. package/ts/integrations/esphome/esphome.classes.configflow.ts +40 -33
  163. package/ts/integrations/esphome/esphome.classes.integration.ts +63 -36
  164. package/ts/integrations/esphome/esphome.discovery.ts +3 -12
  165. package/ts/integrations/esphome/esphome.mapper.ts +9 -7
  166. package/ts/integrations/esphome/esphome.types.ts +35 -6
  167. package/ts/integrations/esphome/esphome.validation.ts +66 -0
  168. package/ts/integrations/esphome/readme.md +20 -6
  169. package/ts/integrations/homeassistant/homeassistant.classes.client.ts +353 -125
  170. package/ts/integrations/homeassistant/homeassistant.classes.integration.ts +35 -18
  171. package/ts/integrations/homeassistant/homeassistant.classes.runtime.ts +50 -63
  172. package/ts/integrations/homeassistant/homeassistant.types.ts +17 -1
  173. package/ts/integrations/homeassistant/readme.md +22 -6
  174. package/ts/integrations/hue/hue.classes.client.ts +110 -41
  175. package/ts/integrations/hue/hue.classes.configflow.ts +12 -6
  176. package/ts/integrations/hue/hue.classes.integration.ts +92 -26
  177. package/ts/integrations/hue/hue.validation.ts +13 -3
  178. package/ts/integrations/hue/readme.md +3 -3
  179. package/ts/integrations/mqtt/mqtt.classes.client.ts +136 -19
  180. package/ts/integrations/mqtt/mqtt.classes.configflow.ts +45 -32
  181. package/ts/integrations/mqtt/mqtt.classes.integration.ts +99 -38
  182. package/ts/integrations/mqtt/mqtt.classes.session.ts +118 -0
  183. package/ts/integrations/mqtt/mqtt.mapper.ts +43 -1
  184. package/ts/integrations/mqtt/mqtt.types.ts +52 -0
  185. package/ts/integrations/mqtt/mqtt.validation.ts +157 -0
  186. package/ts/integrations/mqtt/readme.md +12 -3
  187. package/ts/integrations/samsungtv/readme.md +11 -5
  188. package/ts/integrations/samsungtv/samsungtv.classes.client.ts +40 -15
  189. package/ts/integrations/samsungtv/samsungtv.classes.configflow.ts +21 -6
  190. package/ts/integrations/samsungtv/samsungtv.classes.integration.ts +53 -38
  191. package/ts/integrations/samsungtv/samsungtv.types.ts +4 -1
  192. package/ts/integrations/shelly/readme.md +22 -7
  193. package/ts/integrations/shelly/shelly.classes.client.ts +386 -269
  194. package/ts/integrations/shelly/shelly.classes.configflow.ts +43 -38
  195. package/ts/integrations/shelly/shelly.classes.integration.ts +92 -111
  196. package/ts/integrations/shelly/shelly.classes.subscription.ts +24 -1
  197. package/ts/integrations/shelly/shelly.mapper.ts +19 -20
  198. package/ts/integrations/shelly/shelly.rpc.ts +2 -0
  199. package/ts/integrations/shelly/shelly.types.ts +50 -2
  200. package/ts/integrations/shelly/shelly.validation.ts +76 -0
  201. package/ts/integrations/sonos/readme.md +117 -38
  202. package/ts/integrations/sonos/sonos.classes.client.ts +392 -123
  203. package/ts/integrations/sonos/sonos.classes.configflow.ts +25 -28
  204. package/ts/integrations/sonos/sonos.classes.integration.ts +211 -186
  205. package/ts/integrations/sonos/sonos.config.ts +24 -2
  206. package/ts/integrations/sonos/sonos.mapper.ts +13 -8
  207. package/ts/integrations/sonos/sonos.matchers.ts +2 -2
  208. package/ts/integrations/sonos/sonos.types.ts +30 -1
  209. package/ts/integrations/tasmota/readme.md +16 -5
  210. package/ts/integrations/tasmota/tasmota.classes.integration.ts +24 -6
  211. package/ts/integrations/tasmota/tasmota.classes.runtime.ts +112 -43
  212. package/ts/integrations/tasmota/tasmota.config.ts +17 -0
  213. package/ts/integrations/tasmota/tasmota.credentials.ts +92 -0
  214. package/ts/integrations/tasmota/tasmota.discovery.ts +87 -24
  215. package/ts/integrations/tasmota/tasmota.mapper.ts +6 -6
  216. package/ts/integrations/tasmota/tasmota.types.ts +43 -2
  217. package/ts/integrations/tuya/readme.md +52 -10
  218. package/ts/integrations/tuya/tuya.classes.integration.ts +69 -98
  219. package/ts/integrations/tuya/tuya.classes.runtime.ts +70 -24
  220. package/ts/integrations/tuya/tuya.mapper.ts +1 -1
  221. package/ts/integrations/tuya/tuya.types.ts +4 -0
  222. package/ts/integrations/tuya/tuya.validation.ts +5 -8
  223. package/ts/integrations/webostv/readme.md +115 -22
  224. package/ts/integrations/webostv/webostv.classes.client.ts +123 -50
  225. package/ts/integrations/webostv/webostv.classes.configflow.ts +35 -15
  226. package/ts/integrations/webostv/webostv.classes.integration.ts +44 -37
  227. package/ts/integrations/webostv/webostv.mapper.ts +4 -4
  228. package/ts/integrations/webostv/webostv.types.ts +18 -3
  229. package/ts/integrations/webostv/webostv.validation.ts +22 -1
  230. package/ts/integrations/zigbee2mqtt/readme.md +22 -27
  231. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.configflow.ts +16 -7
  232. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.integration.ts +53 -6
  233. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.classes.runtime.ts +62 -49
  234. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.mapper.ts +17 -11
  235. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.types.ts +35 -2
  236. package/ts/integrations/zigbee2mqtt/zigbee2mqtt.validation.ts +53 -7
  237. package/ts/integrations/zwave_js/readme.md +42 -9
  238. package/ts/integrations/zwave_js/zwave_js.classes.client.ts +155 -31
  239. package/ts/integrations/zwave_js/zwave_js.classes.configflow.ts +55 -31
  240. package/ts/integrations/zwave_js/zwave_js.classes.integration.ts +57 -35
  241. package/ts/integrations/zwave_js/zwave_js.discovery.ts +2 -2
  242. package/ts/integrations/zwave_js/zwave_js.mapper.ts +37 -23
  243. package/ts/integrations/zwave_js/zwave_js.types.ts +26 -3
  244. package/ts/integrations/zwave_js/zwave_js.validation.ts +52 -18
  245. package/ts/plugins.ts +2 -1
@@ -1,48 +1,53 @@
1
- import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
2
- import type { IShellyConfig } from './shelly.types.js';
1
+ import type { IConfigFlow, IConfigFlowStep, IDiscoveryCandidate, INativeConfigFlowContext } from '../../core/types.js';
2
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
3
+ import type { IShellyConfig, IShellyIntegrationOptions } from './shelly.types.js';
3
4
  import { ShellyClient } from './shelly.classes.client.js';
5
+ import { normalizeShellyConfig, normalizeShellyOptions, shellyConfigFields, shellyData } from './shelly.validation.js';
4
6
 
5
7
  export class ShellyConfigFlow implements IConfigFlow<IShellyConfig> {
6
- public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IShellyConfig>> {
7
- void contextArg;
8
- return {
9
- kind: 'form',
10
- title: 'Connect Shelly',
11
- description: 'Configure the local Shelly HTTP endpoint. Password is only needed when device authentication is enabled.',
8
+ readonly #scope: NativeIntegrationScope;
9
+ readonly #options: Readonly<IShellyIntegrationOptions>;
10
+ constructor(optionsArg: IShellyIntegrationOptions = {}, scopeArg?: NativeIntegrationScope) {
11
+ this.#options = normalizeShellyOptions(optionsArg);
12
+ this.#scope = scopeArg ?? new NativeIntegrationOwner('shelly', { homeId: this.#options.homeId, capacity: this.#options.capacity }).createFlowScope();
13
+ }
14
+ public async start(candidateArg: IDiscoveryCandidate, contextArg: INativeConfigFlowContext): Promise<IConfigFlowStep<IShellyConfig>> {
15
+ const candidate = shellyData(candidateArg);
16
+ if (candidate.integrationDomain !== undefined && candidate.integrationDomain !== 'shelly') return { kind: 'error', error: 'Candidate does not belong to Shelly.' };
17
+ const hints = { host: candidate.host, port: candidate.port };
18
+ const admitted = this.#scope.admit(contextArg);
19
+ return { kind: 'form', title: 'Connect selected Shelly Gen2/3',
20
+ description: 'Confirm the reported id, MAC, model and generation for the host-selected home. Supply only a caller credential reference; output controls require explicit enablement.',
12
21
  fields: [
13
22
  { name: 'host', label: 'Host', type: 'text', required: true },
14
23
  { name: 'port', label: 'Port', type: 'number' },
15
- { name: 'protocol', label: 'Protocol', type: 'select', options: [
16
- { label: 'HTTP', value: 'http' },
17
- { label: 'HTTPS', value: 'https' },
18
- ] },
19
- { name: 'username', label: 'Username', type: 'text' },
20
- { name: 'password', label: 'Password', type: 'password' },
24
+ { name: 'protocol', label: 'Protocol', type: 'select', options: [{ label: 'HTTP', value: 'http' }, { label: 'HTTPS', value: 'https' }] },
25
+ { name: 'expectedDeviceId', label: 'Exact observed device id', type: 'text', required: true },
26
+ { name: 'expectedMacAddress', label: 'Exact observed MAC (12 hex digits)', type: 'text', required: true },
27
+ { name: 'expectedModel', label: 'Exact observed model', type: 'text', required: true },
28
+ { name: 'expectedGeneration', label: 'Observed generation (2 or 3)', type: 'number', required: true },
29
+ { name: 'credentialRef', label: 'Caller password reference (if authentication is enabled)', type: 'text' },
30
+ { name: 'allowUnencryptedLan', label: 'Allow unencrypted selected LAN HTTP', type: 'boolean' },
31
+ { name: 'allowControl', label: 'Enable absolute switch output controls', type: 'boolean' },
21
32
  ],
22
- submit: async (valuesArg) => {
23
- if (valuesArg.protocol !== undefined && !['http', 'https'].includes(String(valuesArg.protocol)) ||
24
- valuesArg.host !== undefined && typeof valuesArg.host !== 'string' ||
25
- valuesArg.port !== undefined && typeof valuesArg.port !== 'number' ||
26
- valuesArg.username !== undefined && valuesArg.username !== 'admin' ||
27
- valuesArg.password !== undefined && typeof valuesArg.password !== 'string') {
28
- return { kind: 'error', error: 'Invalid Shelly connection settings.' };
29
- }
30
- const config: IShellyConfig = {
31
- host: typeof valuesArg.host === 'string' ? valuesArg.host : candidateArg.host ?? '',
32
- port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port,
33
- protocol: valuesArg.protocol === 'https' ? 'https' : 'http',
34
- username: 'admin',
35
- password: typeof valuesArg.password === 'string' ? valuesArg.password : undefined,
36
- };
37
- let client: ShellyClient | undefined;
33
+ submit: async (inputArg) => {
38
34
  try {
39
- client = new ShellyClient(config);
40
- await client.getSnapshot();
41
- return { kind: 'done', title: 'Shelly configured', config };
42
- } catch {
43
- return { kind: 'error', error: 'Could not validate the Shelly Gen2/3 connection and credentials.' };
44
- } finally { await client?.destroy(); }
45
- },
46
- };
35
+ const values = shellyData(inputArg, shellyConfigFields);
36
+ if (values.homeId !== undefined && values.homeId !== admitted.homeId) throw new Error('Shelly submitted home conflicts with the host-selected home.');
37
+ const config = normalizeShellyConfig({ ...values, homeId: admitted.homeId, host: values.host ?? hints.host, port: values.port ?? hints.port });
38
+ return await this.#scope.run(admitted, config.homeId, async (leaseArg) => {
39
+ const client = new ShellyClient(config, { ...leaseArg.context, abortSignal: leaseArg.signal }, this.#options);
40
+ leaseArg.own(() => client.retryCleanup());
41
+ await client.getSnapshot();
42
+ leaseArg.assertOpen();
43
+ return { kind: 'done', title: 'Selected Shelly admitted', config };
44
+ });
45
+ } catch (errorArg) {
46
+ if (errorArg instanceof AggregateError) throw errorArg;
47
+ return { kind: 'error', error: 'Could not validate selected Shelly home, identity, capabilities and credential reference.' };
48
+ }
49
+ } };
47
50
  }
51
+ public destroy(): Promise<void> { return this.#scope.close(); }
52
+ public retryCleanup(): Promise<void> { return this.#scope.retryCleanup(); }
48
53
  }
@@ -1,99 +1,73 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
- import type {
4
- IDeviceFeatureWriteRequest,
5
- IIntegrationEntity,
6
- IIntegrationEvent,
7
- IIntegrationRuntime,
8
- IIntegrationSetupContext,
9
- IServiceCallRequest,
10
- IServiceCallResult,
11
- } from '../../core/types.js';
3
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
4
+ import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime,
5
+ INativeIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
12
6
  import { ShellyClient } from './shelly.classes.client.js';
13
7
  import { ShellyConfigFlow } from './shelly.classes.configflow.js';
14
8
  import { createShellyDiscoveryDescriptor } from './shelly.discovery.js';
15
9
  import { ShellyMapper } from './shelly.mapper.js';
16
- import type { IShellyConfig, IShellySnapshot, TShellyClientEvent } from './shelly.types.js';
10
+ import { normalizeShellyConfig, normalizeShellyOptions, shellyData } from './shelly.validation.js';
11
+ import { ShellyOperationError, type IShellyConfig, type IShellyIntegrationOptions, type IShellySnapshot,
12
+ type TShellyClientEvent } from './shelly.types.js';
17
13
 
18
14
  export class ShellyIntegration extends BaseIntegration<IShellyConfig> {
19
15
  public readonly domain = 'shelly';
20
16
  public readonly displayName = 'Shelly';
21
17
  public readonly status = 'control-runtime' as const;
22
18
  public readonly discoveryDescriptor = createShellyDiscoveryDescriptor();
23
- public readonly configFlow = new ShellyConfigFlow();
24
- public readonly metadata = {
25
- source: 'home-assistant/core',
26
- upstreamPath: 'homeassistant/components/shelly',
27
- upstreamDomain: 'shelly',
28
- integrationType: 'device',
29
- iotClass: 'local_push',
30
- qualityScale: 'platinum',
31
- };
32
- private readonly runtimes = new Set<ShellyRuntime>();
33
- private readonly pendingClients = new Set<ShellyClient>();
34
- private destroyed = false;
19
+ public readonly configFlow: ShellyConfigFlow;
20
+ public readonly metadata = { source: 'native-typescript', upstreamPath: 'homeassistant/components/shelly',
21
+ upstreamDomain: 'shelly', integrationType: 'device', iotClass: 'local_push', qualityScale: 'platinum' };
22
+ readonly #owner: NativeIntegrationOwner;
23
+ readonly #options: Readonly<IShellyIntegrationOptions>;
35
24
 
36
- public async setup(configArg: IShellyConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
37
- if (this.destroyed) throw new Error('Shelly integration has been destroyed.');
38
- const client = new ShellyClient(configArg, contextArg.abortSignal, contextArg.logger);
39
- this.pendingClients.add(client);
40
- try {
41
- const snapshot = await client.getSnapshot();
42
- if (this.destroyed) throw new Error('Shelly integration has been destroyed.');
43
- const runtime = new ShellyRuntime(client, snapshot, () => this.runtimes.delete(runtime));
44
- this.runtimes.add(runtime);
45
- this.pendingClients.delete(client);
46
- return runtime;
47
- } catch (errorArg) {
48
- try { await client.destroy(); }
49
- catch (cleanupErrorArg) { throw new AggregateError([errorArg, cleanupErrorArg], 'Shelly setup and cleanup failed.'); }
50
- this.pendingClients.delete(client);
51
- throw errorArg;
52
- }
25
+ public constructor(optionsArg: IShellyIntegrationOptions = {}) {
26
+ super();
27
+ this.#options = normalizeShellyOptions(optionsArg);
28
+ this.#owner = new NativeIntegrationOwner('shelly', { homeId: this.#options.homeId, capacity: this.#options.capacity });
29
+ this.configFlow = new ShellyConfigFlow(this.#options, this.#owner.createFlowScope());
53
30
  }
54
-
55
- public async destroy(): Promise<void> {
56
- this.destroyed = true;
57
- const results = await Promise.allSettled([
58
- ...[...this.runtimes].map((runtimeArg) => runtimeArg.destroy()),
59
- ...[...this.pendingClients].map(async (clientArg) => {
60
- await clientArg.destroy();
61
- this.pendingClients.delete(clientArg);
62
- }),
63
- ]);
64
- const errors = results.flatMap((resultArg) => resultArg.status === 'rejected' ? [resultArg.reason as unknown] : []);
65
- if (errors.length) throw new AggregateError(errors, 'Shelly integration cleanup failed.');
31
+ public setup(configArg: IShellyConfig, contextArg: INativeIntegrationSetupContext): Promise<IIntegrationRuntime> {
32
+ try {
33
+ const config = normalizeShellyConfig(configArg);
34
+ return this.#owner.runSetup(contextArg, config.homeId, async (leaseArg) => {
35
+ const client = new ShellyClient(config, { ...leaseArg.context, abortSignal: leaseArg.signal }, this.#options);
36
+ leaseArg.own(() => client.retryCleanup());
37
+ const snapshot = await client.getSnapshot();
38
+ leaseArg.assertOpen();
39
+ const runtime = new ShellyRuntime(client, snapshot, leaseArg);
40
+ leaseArg.promoteToRuntime();
41
+ return runtime;
42
+ });
43
+ } catch (errorArg) { return Promise.reject(errorArg); }
66
44
  }
45
+ public destroy(): Promise<void> { return this.#owner.close(); }
46
+ public retryCleanup(): Promise<void> { return this.#owner.retryCleanup(); }
67
47
  }
68
-
69
48
  export class HomeAssistantShellyIntegration extends ShellyIntegration {}
70
49
 
71
50
  class ShellyRuntime implements IIntegrationRuntime {
72
51
  public readonly domain = 'shelly';
73
-
74
- constructor(private readonly client: ShellyClient, private snapshot: IShellySnapshot, private readonly onDestroyed: () => void) {}
75
-
76
- public isHealthy(): boolean { return this.client.isHealthy(); }
77
-
78
- public async devices(abortSignalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
79
- this.snapshot = await this.client.getSnapshot(abortSignalArg);
80
- return ShellyMapper.toDevices(this.snapshot).map((deviceArg) => ({
81
- ...deviceArg,
82
- online: this.isHealthy(),
52
+ constructor(private readonly client: ShellyClient, private snapshot: IShellySnapshot, private readonly lease: INativeSetupLease) {}
53
+ public isHealthy(): boolean { return !this.lease.signal.aborted && this.client.isHealthy(); }
54
+ public async devices(signalArg?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
55
+ this.lease.assertOpen();
56
+ this.snapshot = await this.client.getSnapshot(signalArg);
57
+ return ShellyMapper.toDevices(this.snapshot).map((deviceArg) => ({ ...deviceArg, online: this.isHealthy(),
83
58
  state: deviceArg.state.map((stateArg) => stateArg.featureId === 'connectivity' ?
84
- { ...stateArg, value: this.isHealthy() ? 'online' : 'offline' } : stateArg),
85
- }));
59
+ { ...stateArg, value: this.isHealthy() ? 'online' : 'offline' } : stateArg) }));
86
60
  }
87
-
88
61
  public async entities(): Promise<IIntegrationEntity[]> {
62
+ this.lease.assertOpen();
89
63
  this.snapshot = await this.client.getSnapshot();
90
64
  return ShellyMapper.toEntities(this.snapshot).map((entityArg) => ({ ...entityArg, available: this.isHealthy() }));
91
65
  }
92
-
93
- public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, abortSignalArg?: AbortSignal): Promise<() => Promise<void>> {
66
+ public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void, signalArg?: AbortSignal): Promise<() => Promise<void>> {
67
+ this.lease.assertOpen();
94
68
  let previousEntities = ShellyMapper.toEntities(this.snapshot);
95
69
  return this.client.subscribe((eventArg: TShellyClientEvent) => {
96
- const deviceId = ShellyMapper.toDevices(this.snapshot)[0].id;
70
+ const deviceId = ShellyMapper.deviceId(this.snapshot);
97
71
  const emit = (event: Omit<IIntegrationEvent, 'integrationDomain' | 'deviceId' | 'timestamp'>): void =>
98
72
  handlerArg({ ...event, integrationDomain: this.domain, deviceId, timestamp: eventArg.timestamp });
99
73
  if (eventArg.type === 'status') {
@@ -103,54 +77,61 @@ class ShellyRuntime implements IIntegrationRuntime {
103
77
  const previous = previousEntities.find((candidateArg) => candidateArg.id === entity.id);
104
78
  if (JSON.stringify(previous) !== JSON.stringify(entity)) emit({ type: previous ? 'state_changed' : 'entity_added', entityId: entity.id, data: { entity } });
105
79
  }
106
- for (const previous of previousEntities) {
107
- if (!entities.some((entityArg) => entityArg.id === previous.id)) emit({ type: 'entity_removed', entityId: previous.id });
108
- }
80
+ for (const previous of previousEntities) if (!entities.some((entityArg) => entityArg.id === previous.id)) emit({ type: 'entity_removed', entityId: previous.id });
109
81
  previousEntities = entities;
110
- } else if (eventArg.type === 'availability') {
111
- emit({ type: 'availability_changed', data: { available: eventArg.available } });
112
- } else if (eventArg.type === 'error') {
113
- emit({ type: 'error', data: { message: eventArg.error.message } });
114
- } else {
115
- emit({ type: 'state_changed', data: { vendor: 'shelly', event: eventArg.event } });
116
- }
117
- }, abortSignalArg);
82
+ } else if (eventArg.type === 'availability') emit({ type: 'availability_changed', data: { available: eventArg.available } });
83
+ else if (eventArg.type === 'error') emit({ type: 'error', data: { message: eventArg.error.message } });
84
+ else emit({ type: 'state_changed', data: { vendor: 'shelly', event: eventArg.event } });
85
+ }, signalArg);
118
86
  }
119
-
120
- public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
121
- if (requestArg.domain !== 'switch' || !['turn_on', 'turn_off'].includes(requestArg.service)) return { success: false, error: 'Shelly supports switch.turn_on and switch.turn_off.' };
122
- if (requestArg.data && Object.keys(requestArg.data).length) return { success: false, error: 'Shelly switch services do not accept additional data.' };
123
- this.snapshot = await this.client.getSnapshot();
124
- const deviceId = ShellyMapper.toDevices(this.snapshot)[0].id;
125
- if (requestArg.target.deviceId !== undefined && requestArg.target.deviceId !== deviceId) return { success: false, error: 'Shelly service target does not belong to the connected device.' };
87
+ public async callService(inputArg: IServiceCallRequest): Promise<IServiceCallResult> {
88
+ this.lease.assertOpen();
89
+ let request: Record<string, unknown>;
90
+ let target: Record<string, unknown>;
91
+ try {
92
+ request = shellyData(inputArg, ['domain', 'service', 'target', 'data']);
93
+ target = shellyData(request.target, ['deviceId', 'entityId']);
94
+ if (request.domain !== 'switch' || !['turn_on', 'turn_off'].includes(String(request.service)) ||
95
+ request.data !== undefined && Object.keys(shellyData(request.data)).length) return { success: false, error: 'Shelly supports only absolute switch.turn_on and switch.turn_off without extra data.' };
96
+ } catch { return { success: false, error: 'Invalid Shelly service request.' }; }
97
+ const deviceId = ShellyMapper.deviceId(this.snapshot);
98
+ if (target.deviceId !== undefined && target.deviceId !== deviceId) return { success: false, error: 'Shelly service target does not belong to the connected home and device.' };
126
99
  const switches = ShellyMapper.toEntities(this.snapshot).filter((entityArg) => entityArg.platform === 'switch');
127
- const target = requestArg.target.entityId !== undefined ? switches.find((entityArg) => entityArg.id === requestArg.target.entityId) :
128
- requestArg.target.deviceId === deviceId && switches.length === 1 ? switches[0] : undefined;
129
- if (!target) return { success: false, error: 'Shelly service requires an exact switch entity or an unambiguous device target.' };
130
- const switchId = this.componentId(target.uniqueId);
131
- if (switchId === undefined) return { success: false, error: 'Shelly switch target has no component id.' };
132
- await this.client.setSwitchOutput(switchId, requestArg.service === 'turn_on');
133
- return { success: true };
100
+ const selected = target.entityId !== undefined ? switches.find((entityArg) => entityArg.id === target.entityId) :
101
+ target.deviceId === deviceId && switches.length === 1 ? switches[0] : undefined;
102
+ if (!selected) return { success: false, error: 'Shelly service requires an exact switch entity or an unambiguous device target.' };
103
+ const match = /\.switch_(\d+)$/.exec(selected.uniqueId);
104
+ if (!match) return { success: false, error: 'Shelly switch target has no component id.' };
105
+ return this.control(Number(match[1]), request.service === 'turn_on');
134
106
  }
135
-
136
- public async writeFeature(requestArg: IDeviceFeatureWriteRequest, abortSignalArg?: AbortSignal): Promise<IServiceCallResult> {
137
- this.snapshot = await this.client.getSnapshot(abortSignalArg);
138
- const device = ShellyMapper.toDevices(this.snapshot)[0];
139
- if (requestArg.device.id !== device.id || requestArg.device.integrationDomain !== this.domain) return { success: false, error: 'Shelly feature target does not belong to the connected device.' };
140
- const feature = device.features.find((featureArg) => featureArg.id === requestArg.feature.id);
107
+ public async writeFeature(inputArg: IDeviceFeatureWriteRequest, signalArg?: AbortSignal): Promise<IServiceCallResult> {
108
+ this.lease.assertOpen();
109
+ let request: Record<string, unknown>;
110
+ let deviceInput: Record<string, unknown>;
111
+ let featureInput: Record<string, unknown>;
112
+ try {
113
+ request = shellyData(inputArg, ['device', 'feature', 'value']);
114
+ deviceInput = shellyData(request.device);
115
+ featureInput = shellyData(request.feature);
116
+ } catch { return { success: false, error: 'Invalid Shelly feature request.' }; }
117
+ if (deviceInput.id !== ShellyMapper.deviceId(this.snapshot) || deviceInput.integrationDomain !== this.domain) return { success: false, error: 'Shelly feature target does not belong to the connected home and device.' };
118
+ const feature = ShellyMapper.toDevices(this.snapshot)[0].features.find((entryArg) => entryArg.id === featureInput.id);
141
119
  const match = feature && /^switch_(\d+)_output$/.exec(feature.id);
142
- if (!match || !feature.writable || feature.capability !== 'switch' || typeof requestArg.value !== 'boolean') return { success: false, error: 'Shelly writes require a writable switch output and boolean value.' };
143
- await this.client.setSwitchOutput(Number(match[1]), requestArg.value, abortSignalArg);
144
- return { success: true };
120
+ if (!match || !feature.writable || feature.capability !== 'switch' || featureInput.capability !== 'switch' || typeof request.value !== 'boolean') return { success: false, error: 'Shelly writes require a current writable switch output and boolean value.' };
121
+ return this.control(Number(match[1]), request.value, signalArg);
145
122
  }
146
-
147
- public async destroy(): Promise<void> {
148
- await this.client.destroy();
149
- this.onDestroyed();
150
- }
151
-
152
- private componentId(uniqueIdArg: string): number | undefined {
153
- const match = /_switch_(\d+)$/.exec(uniqueIdArg);
154
- return match ? Number(match[1]) : undefined;
123
+ public destroy(): Promise<void> { return this.lease.close(); }
124
+ private async control(idArg: number, onArg: boolean, signalArg?: AbortSignal): Promise<IServiceCallResult> {
125
+ try {
126
+ const receipt = await this.client.setSwitchOutput(idArg, onArg, signalArg);
127
+ this.snapshot = receipt.snapshot;
128
+ return { success: receipt.outcome === 'observed-target',
129
+ ...(receipt.outcome === 'indeterminate' ? { error: 'Shelly acknowledged the command, but independent reported output differs.' } : {}),
130
+ data: { ...receipt, devices: ShellyMapper.toDevices(receipt.snapshot), entities: ShellyMapper.toEntities(receipt.snapshot) } };
131
+ } catch (errorArg) {
132
+ const error = errorArg instanceof ShellyOperationError ? errorArg : undefined;
133
+ return { success: false, error: error?.message ?? 'Shelly control was not admitted.',
134
+ data: { effectOutcome: error?.effectOutcome ?? 'not-sent', commandAcknowledged: error?.commandAcknowledged ?? false, causalityConfirmed: false } };
135
+ }
155
136
  }
156
137
  }
@@ -28,6 +28,8 @@ export class ShellySubscription {
28
28
  private resolveStart?: () => void;
29
29
  private rejectStart?: (errorArg: Error) => void;
30
30
  private closePromise?: Promise<void>;
31
+ private stopPromise?: Promise<void>;
32
+ private retryPromise?: Promise<void>;
31
33
  private resolveClose?: () => void;
32
34
  private deadline?: ReturnType<typeof setTimeout>;
33
35
  private requestId = 1;
@@ -65,7 +67,26 @@ export class ShellySubscription {
65
67
  return this.startPromise;
66
68
  }
67
69
 
68
- public async stop(): Promise<void> {
70
+ public stop(): Promise<void> {
71
+ if (!this.stopPromise) {
72
+ // Publish before availability callbacks or synchronous socket/abort listeners.
73
+ this.stopPromise = Promise.resolve().then(() => this.drainClose());
74
+ this.stopping = true;
75
+ }
76
+ return this.stopPromise;
77
+ }
78
+
79
+ public retryCleanup(): Promise<void> {
80
+ if (!this.stopPromise) return this.stop();
81
+ if (!this.retryPromise) {
82
+ this.retryPromise = Promise.resolve().then(() => this.drainClose());
83
+ const settled = (): void => { this.retryPromise = undefined; };
84
+ void this.retryPromise.then(settled, settled);
85
+ }
86
+ return this.retryPromise;
87
+ }
88
+
89
+ private async drainClose(): Promise<void> {
69
90
  this.stopping = true;
70
91
  this.setHealthy(false);
71
92
  this.clearDeadline();
@@ -123,6 +144,7 @@ export class ShellySubscription {
123
144
  this.resolveStart?.();
124
145
  this.resolveStart = undefined;
125
146
  this.rejectStart = undefined;
147
+ this.options.password = undefined;
126
148
  } catch (errorArg) {
127
149
  this.fail(errorArg instanceof AuthenticationError || errorArg instanceof DeviceCommunicationError ?
128
150
  errorArg : new DeviceCommunicationError('Shelly returned an invalid WebSocket payload.'));
@@ -214,5 +236,6 @@ export class ShellySubscription {
214
236
  this.socket?.removeEventListener('close', this.handleClose);
215
237
  this.resolveClose?.();
216
238
  this.resolveClose = undefined;
239
+ this.options.password = undefined;
217
240
  }
218
241
  }
@@ -4,7 +4,7 @@ import type { IShellyDeviceConfig, IShellyDeviceInfo, IShellySnapshot, IShellySt
4
4
 
5
5
  export class ShellyMapper {
6
6
  public static toDevices(snapshotArg: IShellySnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
7
- const deviceId = this.deviceId(snapshotArg.deviceInfo, snapshotArg.status);
7
+ const deviceId = this.deviceId(snapshotArg);
8
8
  const updatedAt = new Date().toISOString();
9
9
  const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
10
10
  { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
@@ -21,7 +21,8 @@ export class ShellyMapper {
21
21
  for (const switchComponent of this.switchComponents(snapshotArg.status)) {
22
22
  const prefix = `switch_${switchComponent.id}`;
23
23
  const name = this.componentName(snapshotArg.deviceConfig, `switch:${switchComponent.id}`, `Switch ${switchComponent.id}`);
24
- features.push({ id: `${prefix}_output`, capability: 'switch', name, readable: true, writable: true });
24
+ features.push({ id: `${prefix}_output`, capability: 'switch', name, readable: true,
25
+ writable: snapshotArg.controlEnabled && snapshotArg.methods.includes('Switch.Set') && snapshotArg.methods.includes('Switch.GetStatus') });
25
26
  state.push({ featureId: `${prefix}_output`, value: switchComponent.status.output ?? null, updatedAt });
26
27
  this.pushNumericFeature(features, state, `${prefix}_power`, `${name} power`, switchComponent.status.apower, 'W', updatedAt);
27
28
  this.pushNumericFeature(features, state, `${prefix}_voltage`, `${name} voltage`, switchComponent.status.voltage, 'V', updatedAt);
@@ -36,7 +37,7 @@ export class ShellyMapper {
36
37
  name: this.deviceName(snapshotArg.deviceInfo, snapshotArg.deviceConfig),
37
38
  protocol: 'http',
38
39
  manufacturer: 'Shelly',
39
- model: snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.app,
40
+ model: snapshotArg.deviceInfo.model,
40
41
  online: true,
41
42
  features,
42
43
  state,
@@ -46,20 +47,21 @@ export class ShellyMapper {
46
47
  authEnabled: snapshotArg.deviceInfo.auth_en ?? snapshotArg.deviceInfo.auth,
47
48
  macAddress: snapshotArg.deviceInfo.mac || snapshotArg.status.sys?.mac,
48
49
  profile: snapshotArg.deviceInfo.profile,
50
+ homeId: snapshotArg.homeId,
49
51
  },
50
52
  }];
51
53
  }
52
54
 
53
55
  public static toEntities(snapshotArg: IShellySnapshot): IIntegrationEntity[] {
54
- const deviceId = this.deviceId(snapshotArg.deviceInfo, snapshotArg.status);
55
- const deviceSlug = this.slug(this.deviceName(snapshotArg.deviceInfo, snapshotArg.deviceConfig));
56
+ const deviceId = this.deviceId(snapshotArg);
57
+ const deviceSlug = `${encodeURIComponent(snapshotArg.homeId)}.${encodeURIComponent(snapshotArg.deviceInfo.id!)}`;
56
58
  const entities: IIntegrationEntity[] = [];
57
59
 
58
60
  for (const switchComponent of this.switchComponents(snapshotArg.status)) {
59
61
  const switchName = this.componentName(snapshotArg.deviceConfig, `switch:${switchComponent.id}`, `Switch ${switchComponent.id}`);
60
- const uniquePrefix = `shelly_${this.slug(deviceId)}_switch_${switchComponent.id}`;
62
+ const uniquePrefix = `${deviceId}.switch_${switchComponent.id}`;
61
63
  entities.push({
62
- id: `switch.${deviceSlug}_${switchComponent.id}`,
64
+ id: `switch.${deviceSlug}.${switchComponent.id}`,
63
65
  uniqueId: uniquePrefix,
64
66
  integrationDomain: 'shelly',
65
67
  deviceId,
@@ -72,11 +74,11 @@ export class ShellyMapper {
72
74
  },
73
75
  available: true,
74
76
  });
75
- this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_power`, `sensor.${deviceSlug}_${switchComponent.id}_power`, `${switchName} power`, switchComponent.status.apower, 'W');
76
- this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_voltage`, `sensor.${deviceSlug}_${switchComponent.id}_voltage`, `${switchName} voltage`, switchComponent.status.voltage, 'V');
77
- this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_current`, `sensor.${deviceSlug}_${switchComponent.id}_current`, `${switchName} current`, switchComponent.status.current, 'A');
78
- this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_energy`, `sensor.${deviceSlug}_${switchComponent.id}_energy`, `${switchName} energy`, switchComponent.status.aenergy?.total, 'Wh');
79
- this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_temperature`, `sensor.${deviceSlug}_${switchComponent.id}_temperature`, `${switchName} temperature`, switchComponent.status.temperature?.tC, 'C');
77
+ this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_power`, `sensor.${deviceSlug}.${switchComponent.id}.power`, `${switchName} power`, switchComponent.status.apower, 'W');
78
+ this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_voltage`, `sensor.${deviceSlug}.${switchComponent.id}.voltage`, `${switchName} voltage`, switchComponent.status.voltage, 'V');
79
+ this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_current`, `sensor.${deviceSlug}.${switchComponent.id}.current`, `${switchName} current`, switchComponent.status.current, 'A');
80
+ this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_energy`, `sensor.${deviceSlug}.${switchComponent.id}.energy`, `${switchName} energy`, switchComponent.status.aenergy?.total, 'Wh');
81
+ this.pushSensorEntity(entities, deviceId, `${uniquePrefix}_temperature`, `sensor.${deviceSlug}.${switchComponent.id}.temperature`, `${switchName} temperature`, switchComponent.status.temperature?.tC, 'C');
80
82
  }
81
83
 
82
84
  return entities;
@@ -91,7 +93,7 @@ export class ShellyMapper {
91
93
  unitArg: string,
92
94
  updatedAtArg: string
93
95
  ): void {
94
- if (typeof valueArg !== 'number') {
96
+ if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
95
97
  return;
96
98
  }
97
99
  featuresArg.push({ id: idArg, capability: 'sensor', name: nameArg, readable: true, writable: false, unit: unitArg });
@@ -107,7 +109,7 @@ export class ShellyMapper {
107
109
  valueArg: number | null | undefined,
108
110
  unitArg: string
109
111
  ): void {
110
- if (typeof valueArg !== 'number') {
112
+ if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
111
113
  return;
112
114
  }
113
115
  entitiesArg.push({
@@ -139,8 +141,9 @@ export class ShellyMapper {
139
141
  return components.sort((leftArg, rightArg) => leftArg.id - rightArg.id);
140
142
  }
141
143
 
142
- private static deviceId(deviceInfoArg: IShellyDeviceInfo, statusArg: IShellyStatus): string {
143
- return `shelly.device.${this.slug(deviceInfoArg.id || deviceInfoArg.mac || statusArg.sys?.mac || 'unknown')}`;
144
+ public static deviceId(snapshotArg: IShellySnapshot): string {
145
+ if (!snapshotArg.homeId || !snapshotArg.deviceInfo.id) throw new TypeError('Shelly mapping requires admitted home and observed device identity.');
146
+ return `shelly.device.${encodeURIComponent(snapshotArg.homeId)}.${encodeURIComponent(snapshotArg.deviceInfo.id)}`;
144
147
  }
145
148
 
146
149
  private static deviceName(deviceInfoArg: IShellyDeviceInfo, configArg?: IShellyDeviceConfig): string {
@@ -155,10 +158,6 @@ export class ShellyMapper {
155
158
  return fallbackArg;
156
159
  }
157
160
 
158
- private static slug(valueArg: string): string {
159
- return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'shelly';
160
- }
161
-
162
161
  private static isRecord(valueArg: unknown): valueArg is Record<string, unknown> {
163
162
  return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
164
163
  }
@@ -29,6 +29,8 @@ export const parseShellyDeviceInfo = (valueArg: unknown): IShellyDeviceInfo => {
29
29
  if (valueArg.gen !== 2 && valueArg.gen !== 3) {
30
30
  throw new DeviceCommunicationError('Shelly integration supports generation 2 and 3 devices.');
31
31
  }
32
+ if (typeof valueArg.mac !== 'string' || !/^[0-9a-f]{12}$/i.test(valueArg.mac) ||
33
+ typeof valueArg.model !== 'string' || !valueArg.model || valueArg.model.length > 128) invalidPayload();
32
34
  for (const key of ['mac', 'model', 'ver', 'fw_id', 'app', 'profile']) {
33
35
  if (valueArg[key] !== undefined && typeof valueArg[key] !== 'string') invalidPayload();
34
36
  }
@@ -1,12 +1,57 @@
1
1
  export type TShellyProtocol = 'http' | 'https';
2
2
 
3
3
  export interface IShellyConfig {
4
+ homeId: string;
4
5
  host: string;
5
6
  port?: number;
6
7
  protocol?: TShellyProtocol;
7
- username?: string;
8
- password?: string;
8
+ expectedDeviceId: string;
9
+ expectedMacAddress: string;
10
+ expectedModel: string;
11
+ expectedGeneration: 2 | 3;
12
+ credentialRef?: string;
13
+ /** Absolute output controls are denied unless explicitly enabled. */
14
+ allowControl?: boolean;
15
+ allowUnencryptedLan?: boolean;
9
16
  requestTimeoutMs?: number;
17
+ maxPendingOperations?: number;
18
+ maxResponseBytes?: number;
19
+ }
20
+
21
+ export interface IShellyCredentialRequest {
22
+ readonly homeId: string;
23
+ readonly credentialRef: string;
24
+ readonly signal: AbortSignal;
25
+ /** Monotonic performance.now() deadline, including queue time. */
26
+ readonly deadline: number;
27
+ }
28
+
29
+ export type TShellyPasswordResolver = (requestArg: IShellyCredentialRequest) => Promise<string>;
30
+
31
+ export interface IShellyIntegrationOptions {
32
+ readonly homeId?: string;
33
+ readonly capacity?: number;
34
+ readonly resolvePassword?: TShellyPasswordResolver;
35
+ }
36
+
37
+ export type TShellyEffectOutcome = 'not-sent' | 'indeterminate';
38
+
39
+ export class ShellyOperationError extends Error {
40
+ constructor(messageArg: string, public readonly effectOutcome: TShellyEffectOutcome = 'not-sent',
41
+ public readonly commandAcknowledged = false) {
42
+ super(messageArg);
43
+ this.name = 'ShellyOperationError';
44
+ }
45
+ }
46
+
47
+ export interface IShellyEffectResult {
48
+ readonly componentId: number;
49
+ readonly requested: boolean;
50
+ readonly wasOn: boolean;
51
+ readonly commandAcknowledged: true;
52
+ readonly causalityConfirmed: false;
53
+ readonly outcome: 'observed-target' | 'indeterminate';
54
+ readonly snapshot: IShellySnapshot;
10
55
  }
11
56
 
12
57
  export interface IShellyDeviceInfo {
@@ -78,9 +123,12 @@ export interface IShellySwitchStatus {
78
123
  }
79
124
 
80
125
  export interface IShellySnapshot {
126
+ homeId: string;
81
127
  deviceInfo: IShellyDeviceInfo;
82
128
  status: IShellyStatus;
83
129
  deviceConfig?: IShellyDeviceConfig;
130
+ methods: readonly string[];
131
+ controlEnabled: boolean;
84
132
  }
85
133
 
86
134
  export interface IShellyComponentEvent {