@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,38 +1,35 @@
1
- import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
1
+ import { NativeIntegrationOwner, type NativeIntegrationScope } from '../../core/classes.nativeintegrationowner.js';
2
+ import type { IConfigFlow, IConfigFlowStep, IDiscoveryCandidate, INativeConfigFlowContext } from '../../core/types.js';
2
3
  import type { ISonosConfig } from './sonos.types.js';
3
4
  import { isSonosDataRecord, normalizeSonosConfig } from './sonos.config.js';
4
5
 
5
6
  export class SonosConfigFlow implements IConfigFlow<ISonosConfig> {
6
- public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISonosConfig>> {
7
- void contextArg;
7
+ private readonly scope: NativeIntegrationScope;
8
+ constructor(scope?: NativeIntegrationScope, options: { readonly homeId?: string } = {}) {
9
+ this.scope = scope ?? new NativeIntegrationOwner('sonos', options).createFlowScope();
10
+ }
11
+ public async start(candidateArg: IDiscoveryCandidate, contextArg: INativeConfigFlowContext): Promise<IConfigFlowStep<ISonosConfig>> {
8
12
  if (!isSonosDataRecord(candidateArg) || candidateArg.integrationDomain !== 'sonos') return { kind: 'error', error: 'Candidate does not belong to Sonos.' };
9
- candidateArg = { source: candidateArg.source, integrationDomain: 'sonos', host: candidateArg.host, port: candidateArg.port, id: candidateArg.id };
13
+ const candidate = { source: candidateArg.source, host: candidateArg.host, port: candidateArg.port, id: candidateArg.id };
14
+ const admitted = this.scope.admit(contextArg);
10
15
  return {
11
- kind: 'form',
12
- title: 'Connect Sonos',
13
- description: 'Configure the local Sonos player HTTP endpoint discovered by SSDP or mDNS.',
14
- fields: [
15
- { name: 'host', label: 'Host', type: 'text', required: true },
16
- { name: 'port', label: 'Port', type: 'number' },
17
- ],
18
- submit: async (valuesArg) => {
19
- if (!isSonosDataRecord(valuesArg)) throw new Error('Sonos form values must be a plain object.');
20
- for (const key of Reflect.ownKeys(valuesArg)) {
21
- const descriptor = Object.getOwnPropertyDescriptor(valuesArg, key);
22
- if (typeof key !== 'string' || !['host', 'port'].includes(key) || !descriptor || !('value' in descriptor)) throw new Error('Sonos form contains an unsupported field.');
23
- }
24
- if (valuesArg.host !== undefined && typeof valuesArg.host !== 'string') throw new Error('Sonos host must be text.');
25
- if (valuesArg.port !== undefined && typeof valuesArg.port !== 'number') throw new Error('Sonos port must be numeric.');
26
- return {
27
- kind: 'done',
28
- title: 'Sonos configured',
29
- config: normalizeSonosConfig({
30
- host: typeof valuesArg.host === 'string' ? valuesArg.host : candidateArg.host ?? '',
31
- port: typeof valuesArg.port === 'number' ? valuesArg.port : candidateArg.port ?? 1400,
32
- ...(candidateArg.id ? { expectedUid: candidateArg.id } : {}),
33
- }),
34
- };
16
+ kind: 'form', title: 'Connect Sonos', description: 'Configure the selected local Sonos HTTP endpoint for the host-selected home.',
17
+ fields: [{ name: 'host', label: 'Host', type: 'text', required: true }, { name: 'port', label: 'Port', type: 'number' }],
18
+ submit: (values) => {
19
+ if (!isSonosDataRecord(values)) throw new Error('Sonos form values must be a plain object.');
20
+ if (Object.keys(values).some((key) => !['homeId', 'host', 'port'].includes(key))) throw new Error('Sonos form contains an unsupported field.');
21
+ if (values.homeId !== undefined && values.homeId !== admitted.homeId) throw new Error('Sonos submitted home conflicts with the host-selected home.');
22
+ if (values.host !== undefined && typeof values.host !== 'string') throw new Error('Sonos host must be text.');
23
+ if (values.port !== undefined && typeof values.port !== 'number') throw new Error('Sonos port must be numeric.');
24
+ const config = normalizeSonosConfig({ homeId: admitted.homeId, host: typeof values.host === 'string' ? values.host : candidate.host ?? '',
25
+ port: typeof values.port === 'number' ? values.port : candidate.port ?? 1400, ...(candidate.id ? { expectedUid: candidate.id } : {}) });
26
+ return this.scope.run(admitted, config.homeId, async (lease) => {
27
+ lease.assertOpen();
28
+ return { kind: 'done', title: 'Sonos configured', config };
29
+ });
35
30
  },
36
31
  };
37
32
  }
33
+ public destroy(): Promise<void> { return this.scope.close(); }
34
+ public retryCleanup(): Promise<void> { return this.scope.retryCleanup(); }
38
35
  }
@@ -1,60 +1,66 @@
1
1
  import * as plugins from '../../plugins.js';
2
2
  import { BaseIntegration } from '../../core/classes.baseintegration.js';
3
- import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
4
- import { SonosClient } from './sonos.classes.client.js';
3
+ import { NativeIntegrationOwner, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
4
+ import type { IDeviceFeatureWriteRequest, IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, INativeIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
5
+ import { SonosClient, SonosCommandError, type ISonosOperationSession } from './sonos.classes.client.js';
5
6
  import { SonosConfigFlow } from './sonos.classes.configflow.js';
6
7
  import { createSonosDiscoveryDescriptor } from './sonos.discovery.js';
7
8
  import { SonosMapper } from './sonos.mapper.js';
8
- import { isSonosDataRecord } from './sonos.config.js';
9
- import type { ISonosConfig, ISonosSnapshot } from './sonos.types.js';
9
+ import { isSonosDataRecord, normalizeSonosConfig, normalizeSonosOwnerOptions } from './sonos.config.js';
10
+ import type { ISonosConfig, ISonosControlResult, ISonosOwnerOptions, ISonosSnapshot } from './sonos.types.js';
10
11
 
11
12
  export class SonosIntegration extends BaseIntegration<ISonosConfig> {
12
13
  public readonly domain = 'sonos';
13
14
  public readonly displayName = 'Sonos';
14
15
  public readonly status = 'control-runtime' as const;
15
16
  public readonly discoveryDescriptor = createSonosDiscoveryDescriptor();
16
- public readonly configFlow = new SonosConfigFlow();
17
+ public readonly configFlow: SonosConfigFlow;
17
18
  public readonly metadata = { source: 'native-typescript', integrationType: 'device', iotClass: 'local_poll' };
18
- private readonly runtimes = new Set<SonosRuntime>();
19
- private readonly setups = new Set<Promise<IIntegrationRuntime>>();
20
- private readonly lifetime = new AbortController();
19
+ private readonly owner: NativeIntegrationOwner;
20
+ private readonly options: Readonly<ISonosOwnerOptions>;
21
21
  private closePromise?: Promise<void>;
22
22
 
23
- public setup(config: ISonosConfig, context: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
24
- if (this.lifetime.signal.aborted) return Promise.reject(new Error('Sonos integration is closed.'));
25
- const signal = AbortSignal.any([this.lifetime.signal, ...(context.abortSignal ? [context.abortSignal] : [])]);
26
- const pending = (async () => {
27
- const client = new SonosClient(config, signal);
28
- try {
29
- const initial = await client.getSnapshot(signal);
30
- signal.throwIfAborted();
31
- const runtime = new SonosRuntime(client, initial, { ...context, abortSignal: signal }, () => { this.runtimes.delete(runtime); });
32
- this.runtimes.add(runtime);
23
+ constructor(options: ISonosOwnerOptions = {}) {
24
+ super();
25
+ this.options = normalizeSonosOwnerOptions(options);
26
+ this.owner = new NativeIntegrationOwner('sonos', this.options.homeId === undefined ? {} : { homeId: this.options.homeId });
27
+ this.configFlow = new SonosConfigFlow(this.owner.createFlowScope());
28
+ }
29
+
30
+ public setup(config: ISonosConfig, context: INativeIntegrationSetupContext): Promise<IIntegrationRuntime> {
31
+ try {
32
+ const selected = normalizeSonosConfig(config);
33
+ return this.owner.runSetup(context, selected.homeId, async (lease) => {
34
+ const client = new SonosClient(selected, lease.context, this.options, { owner: this.owner, lease });
35
+ let runtime: SonosRuntime | undefined;
36
+ let cleanupStarted = false;
37
+ lease.own(() => {
38
+ const retry = cleanupStarted;
39
+ cleanupStarted = true;
40
+ return runtime ? runtime.closeOwnedResources(retry) : retry ? client.retryCleanup() : client.destroy();
41
+ });
42
+ const initial = await client.withSetupLease(lease, (session) => session.getSnapshot());
43
+ lease.assertOpen();
44
+ runtime = new SonosRuntime(client, initial, lease);
45
+ lease.promoteToRuntime();
33
46
  return runtime;
34
- } catch (error) {
35
- try { await client.destroy(); }
36
- catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Sonos setup and cleanup failed.'); }
37
- throw error;
38
- }
39
- })();
40
- this.setups.add(pending);
41
- void pending.then(() => this.setups.delete(pending), () => this.setups.delete(pending));
42
- return pending;
47
+ });
48
+ } catch (error) { return Promise.reject(error); }
43
49
  }
44
50
 
45
51
  public destroy(): Promise<void> {
46
52
  if (!this.closePromise) {
47
- this.lifetime.abort(new Error('Sonos integration is closed.'));
48
- this.closePromise = (async () => {
49
- await Promise.allSettled([...this.setups]);
50
- const results = await Promise.allSettled([...this.runtimes].map((runtime) => runtime.destroy()));
51
- this.runtimes.clear();
53
+ let closing: Promise<void>[] = [];
54
+ this.closePromise = Promise.resolve().then(async () => {
55
+ const results = await Promise.allSettled(closing);
52
56
  const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
53
- if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos runtime cleanup failed.');
54
- })();
57
+ if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos owned cleanup failed; handles remain retained.');
58
+ });
59
+ closing = [this.configFlow.destroy(), this.owner.close()];
55
60
  }
56
61
  return this.closePromise;
57
62
  }
63
+ public retryCleanup(): Promise<void> { return this.owner.retryCleanup(); }
58
64
  }
59
65
 
60
66
  export class HomeAssistantSonosIntegration extends SonosIntegration {}
@@ -64,6 +70,7 @@ interface ISonosSubscription {
64
70
  job: Promise<void>;
65
71
  failed: boolean;
66
72
  failure?: unknown;
73
+ stopPromise?: Promise<void>;
67
74
  stop(): Promise<void>;
68
75
  }
69
76
 
@@ -74,178 +81,198 @@ class SonosRuntime implements IIntegrationRuntime {
74
81
  private healthy = true;
75
82
  private closed = false;
76
83
  private closePromise?: Promise<void>;
77
- private readonly parentAbort?: () => void;
78
-
79
- constructor(private readonly client: SonosClient, private snapshot: ISonosSnapshot, private readonly context: IIntegrationSetupContext = {}, private readonly onClosed?: () => void) {
80
- if (context.abortSignal) {
81
- this.parentAbort = () => this.lifetime.abort(context.abortSignal?.reason);
82
- context.abortSignal.addEventListener('abort', this.parentAbort, { once: true });
83
- if (context.abortSignal.aborted) this.parentAbort();
84
- }
85
- }
86
84
 
87
- public isHealthy(): boolean { return this.healthy && !this.closed && !this.lifetime.signal.aborted; }
85
+ constructor(private readonly client: SonosClient, private snapshot: ISonosSnapshot, private readonly lease: INativeSetupLease) {}
86
+ public isHealthy(): boolean { return this.healthy && !this.closed && !this.lease.signal.aborted; }
88
87
 
89
- public async devices(signal?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
90
- return SonosMapper.toDevices(await this.read(signal));
88
+ public devices(signal?: AbortSignal): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
89
+ return this.observe(async (session) => SonosMapper.toDevices(await this.read(session)), signal);
91
90
  }
91
+ public entities(): Promise<IIntegrationEntity[]> { return this.observe(async (session) => SonosMapper.toEntities(await this.read(session))); }
92
92
 
93
- public async entities(): Promise<IIntegrationEntity[]> { return SonosMapper.toEntities(await this.read()); }
94
-
95
- public async writeFeature(request: IDeviceFeatureWriteRequest, signal?: AbortSignal): Promise<IServiceCallResult> {
96
- assertDataRecord(request, ['device', 'feature', 'value']);
97
- assertDataRecord(request.device);
98
- assertDataRecord(request.feature);
99
- const deviceId = request.device.id;
100
- const integrationDomain = request.device.integrationDomain;
101
- const featureId = request.feature.id;
102
- const value = request.value;
103
- const snapshot = await this.read(signal);
104
- const device = SonosMapper.toDevices(snapshot)[0];
105
- if (integrationDomain !== this.domain || deviceId !== device.id) throw new Error('Sonos feature target does not belong to the selected renderer.');
106
- const feature = device.features.find((feature) => feature.id === featureId);
107
- if (!feature?.writable || feature.capability !== 'media') throw new Error('Sonos feature is not writable for this renderer.');
108
- if (feature.id === 'volume') {
109
- if (typeof value !== 'number') throw new Error('Sonos volume must be numeric.');
110
- await this.client.setVolume(value, this.signal(signal));
111
- } else if (feature.id === 'muted') {
112
- if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
113
- await this.client.setMute(value, this.signal(signal));
114
- } else if (feature.id === 'playback') {
115
- if (value === 'playing') await this.client.play(this.signal(signal));
116
- else if (value === 'paused') await this.client.pause(this.signal(signal));
117
- else if (value === 'stopped') await this.client.stop(this.signal(signal));
118
- else throw new Error('Sonos playback must be playing, paused or stopped.');
119
- } else throw new Error('Unsupported Sonos feature.');
120
- return this.readback(signal);
93
+ public writeFeature(request: IDeviceFeatureWriteRequest, signal?: AbortSignal): Promise<IServiceCallResult> {
94
+ try {
95
+ assertDataRecord(request, ['device', 'feature', 'value']); assertDataRecord(request.device); assertDataRecord(request.feature);
96
+ const deviceId = request.device.id; const integrationDomain = request.device.integrationDomain; const featureId = request.feature.id; const value = request.value;
97
+ this.assertControlAllowed();
98
+ return this.observe(async (session) => {
99
+ const device = SonosMapper.toDevices(await this.read(session))[0];
100
+ if (integrationDomain !== this.domain || deviceId !== device.id) throw new Error('Sonos feature target does not belong to the selected renderer.');
101
+ const feature = device.features.find((feature) => feature.id === featureId);
102
+ if (!feature?.writable || feature.capability !== 'media') throw new Error('Sonos feature is not writable for this renderer.');
103
+ let result: ISonosControlResult;
104
+ if (feature.id === 'volume') {
105
+ if (typeof value !== 'number') throw new Error('Sonos volume must be numeric.');
106
+ result = await session.setVolume(value);
107
+ } else if (feature.id === 'muted') {
108
+ if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
109
+ result = await session.setMute(value);
110
+ } else if (feature.id === 'playback') {
111
+ if (value === 'playing') result = await session.play();
112
+ else if (value === 'paused') result = await session.pause();
113
+ else if (value === 'stopped') result = await session.stop();
114
+ else throw new Error('Sonos playback must be playing, paused or stopped.');
115
+ } else throw new Error('Unsupported Sonos feature.');
116
+ return this.result(result);
117
+ }, signal);
118
+ } catch (error) { return Promise.reject(error); }
121
119
  }
122
120
 
123
- public async callService(request: IServiceCallRequest): Promise<IServiceCallResult> {
124
- assertDataRecord(request, ['domain', 'service', 'target', 'data']);
125
- assertDataRecord(request.target, ['deviceId', 'entityId']);
126
- if (request.data !== undefined) assertDataRecord(request.data);
127
- request = { domain: request.domain, service: request.service,
128
- target: { ...(request.target.deviceId === undefined ? {} : { deviceId: request.target.deviceId }),
129
- ...(request.target.entityId === undefined ? {} : { entityId: request.target.entityId }) },
130
- ...(request.data === undefined ? {} : { data: { ...request.data } }) };
131
- if (request.domain !== 'media_player' || typeof request.service !== 'string') throw new Error('Unsupported Sonos service domain.');
132
- const snapshot = await this.read();
133
- const deviceId = SonosMapper.deviceId(snapshot);
134
- const entityId = SonosMapper.entityId(snapshot.speakerInfo.uid);
135
- if ((!request.target.deviceId && !request.target.entityId) || (request.target.deviceId !== undefined && request.target.deviceId !== deviceId)
136
- || (request.target.entityId !== undefined && request.target.entityId !== entityId)) throw new Error('Sonos service target does not belong to the selected renderer.');
137
- const data = request.data ?? {};
138
- assertDataRecord(data);
139
- const signal = this.signal();
140
- if (['play', 'pause', 'stop', 'next_track', 'previous_track'].includes(request.service)) {
141
- assertDataRecord(data, []);
142
- if (request.service === 'play') await this.client.play(signal);
143
- else if (request.service === 'pause') await this.client.pause(signal);
144
- else if (request.service === 'stop') await this.client.stop(signal);
145
- else if (request.service === 'next_track') await this.client.next(signal);
146
- else await this.client.previous(signal);
147
- } else if (request.service === 'volume_set') {
148
- assertDataRecord(data, ['volume', 'volume_level']);
149
- if (Object.hasOwn(data, 'volume') === Object.hasOwn(data, 'volume_level')) throw new Error('Sonos volume_set requires exactly one volume argument.');
150
- const value = Object.hasOwn(data, 'volume') ? data.volume : data.volume_level;
151
- if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Sonos volume must be finite.');
152
- const percent = Math.round(value * 100);
153
- if (Object.hasOwn(data, 'volume_level') && (value < 0 || value > 1 || Math.abs(value * 100 - percent) > 1e-8)) throw new Error('Sonos volume_level must represent an integer percent from 0 to 100.');
154
- await this.client.setVolume(Object.hasOwn(data, 'volume') ? value : percent, signal);
155
- } else if (request.service === 'volume_mute') {
156
- assertDataRecord(data, ['muted', 'is_volume_muted']);
157
- if (Object.hasOwn(data, 'muted') === Object.hasOwn(data, 'is_volume_muted')) throw new Error('Sonos volume_mute requires exactly one mute argument.');
158
- const value = Object.hasOwn(data, 'muted') ? data.muted : data.is_volume_muted;
159
- if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
160
- await this.client.setMute(value, signal);
161
- } else if (request.service === 'play_media') {
162
- assertDataRecord(data, ['uri', 'media_content_id', 'title', 'mime_type']);
163
- if (Object.hasOwn(data, 'uri') === Object.hasOwn(data, 'media_content_id')) throw new Error('Sonos play_media requires exactly one URI argument.');
164
- const uri = Object.hasOwn(data, 'uri') ? data.uri : data.media_content_id;
165
- if (typeof uri !== 'string' || (data.title !== undefined && typeof data.title !== 'string') || (data.mime_type !== undefined && typeof data.mime_type !== 'string')) throw new Error('Sonos media URI, title and MIME type must be text.');
166
- await this.client.playUri(uri, { title: data.title, mimeType: data.mime_type }, signal);
167
- } else throw new Error(`Unsupported Sonos service: ${request.service}`);
168
- return this.readback();
121
+ public callService(request: IServiceCallRequest): Promise<IServiceCallResult> {
122
+ try {
123
+ assertDataRecord(request, ['domain', 'service', 'target', 'data']); assertDataRecord(request.target, ['deviceId', 'entityId']);
124
+ if (request.data !== undefined) assertDataRecord(request.data);
125
+ const selected: IServiceCallRequest = { domain: request.domain, service: request.service,
126
+ target: { ...(request.target.deviceId === undefined ? {} : { deviceId: request.target.deviceId }), ...(request.target.entityId === undefined ? {} : { entityId: request.target.entityId }) },
127
+ ...(request.data === undefined ? {} : { data: { ...request.data } }) };
128
+ if (selected.domain !== 'media_player' || typeof selected.service !== 'string') throw new Error('Unsupported Sonos service domain.');
129
+ this.assertControlAllowed();
130
+ return this.observe(async (session) => {
131
+ const snapshot = await this.read(session);
132
+ const deviceId = SonosMapper.deviceId(snapshot); const entityId = SonosMapper.entityId(snapshot.homeId, snapshot.speakerInfo.uid);
133
+ if ((!selected.target.deviceId && !selected.target.entityId) || (selected.target.deviceId !== undefined && selected.target.deviceId !== deviceId)
134
+ || (selected.target.entityId !== undefined && selected.target.entityId !== entityId)) throw new Error('Sonos service target does not belong to the selected renderer.');
135
+ const data = selected.data ?? {}; assertDataRecord(data);
136
+ let result: ISonosControlResult;
137
+ if (['play', 'pause', 'stop', 'next_track', 'previous_track'].includes(selected.service)) {
138
+ assertDataRecord(data, []);
139
+ if (selected.service === 'play') result = await session.play();
140
+ else if (selected.service === 'pause') result = await session.pause();
141
+ else if (selected.service === 'stop') result = await session.stop();
142
+ else if (selected.service === 'next_track') result = await session.next();
143
+ else result = await session.previous();
144
+ } else if (selected.service === 'volume_set') {
145
+ assertDataRecord(data, ['volume', 'volume_level']);
146
+ if (Object.hasOwn(data, 'volume') === Object.hasOwn(data, 'volume_level')) throw new Error('Sonos volume_set requires exactly one volume argument.');
147
+ const value = Object.hasOwn(data, 'volume') ? data.volume : data.volume_level;
148
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('Sonos volume must be finite.');
149
+ const percent = Math.round(value * 100);
150
+ if (Object.hasOwn(data, 'volume_level') && (value < 0 || value > 1 || Math.abs(value * 100 - percent) > 1e-8)) throw new Error('Sonos volume_level must represent an integer percent from 0 to 100.');
151
+ result = await session.setVolume(Object.hasOwn(data, 'volume') ? value : percent);
152
+ } else if (selected.service === 'volume_mute') {
153
+ assertDataRecord(data, ['muted', 'is_volume_muted']);
154
+ if (Object.hasOwn(data, 'muted') === Object.hasOwn(data, 'is_volume_muted')) throw new Error('Sonos volume_mute requires exactly one mute argument.');
155
+ const value = Object.hasOwn(data, 'muted') ? data.muted : data.is_volume_muted;
156
+ if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
157
+ result = await session.setMute(value);
158
+ } else if (selected.service === 'play_media') {
159
+ assertDataRecord(data, ['uri', 'media_content_id', 'title', 'mime_type']);
160
+ if (Object.hasOwn(data, 'uri') === Object.hasOwn(data, 'media_content_id')) throw new Error('Sonos play_media requires exactly one URI argument.');
161
+ const uri = Object.hasOwn(data, 'uri') ? data.uri : data.media_content_id;
162
+ if (typeof uri !== 'string' || (data.title !== undefined && typeof data.title !== 'string') || (data.mime_type !== undefined && typeof data.mime_type !== 'string')) throw new Error('Sonos media URI, title and MIME type must be text.');
163
+ result = await session.playUri(uri, { title: data.title, mimeType: data.mime_type });
164
+ } else throw new Error(`Unsupported Sonos service: ${selected.service}`);
165
+ return this.result(result);
166
+ });
167
+ } catch (error) { return Promise.reject(error); }
169
168
  }
170
169
 
171
- public async subscribe(handler: (event: IIntegrationEvent) => void, signal?: AbortSignal): Promise<() => Promise<void>> {
172
- if (typeof handler !== 'function') throw new Error('Sonos subscription handler must be callable.');
173
- if (this.subscriptions.size >= 16) throw new Error('Sonos runtime supports at most 16 subscriptions.');
174
- const controller = new AbortController();
175
- const combined = AbortSignal.any([this.signal(signal), controller.signal]);
176
- const initial = await this.read(combined);
177
- combined.throwIfAborted();
178
- if (this.subscriptions.size >= 16) throw new Error('Sonos runtime supports at most 16 subscriptions.');
179
- handler(this.event('state_changed', initial));
180
- combined.throwIfAborted();
181
- let subscription: ISonosSubscription;
182
- const job = (async () => {
183
- while (!combined.aborted) {
184
- await wait(this.client.config.pollIntervalMs, combined);
185
- if (combined.aborted) return;
186
- let snapshot: ISonosSnapshot;
187
- try { snapshot = await this.read(combined); }
188
- catch (error) {
170
+ public subscribe(handler: (event: IIntegrationEvent) => void, signal?: AbortSignal): Promise<() => Promise<void>> {
171
+ try {
172
+ this.lease.assertOpen();
173
+ if (typeof handler !== 'function') throw new Error('Sonos subscription handler must be callable.');
174
+ if (this.subscriptions.size >= 16) throw new Error('Sonos runtime supports at most 16 subscriptions.');
175
+ const controller = new AbortController();
176
+ const combined = AbortSignal.any([this.lease.signal, this.lifetime.signal, controller.signal, ...(signal ? [signal] : [])]);
177
+ combined.throwIfAborted();
178
+ const subscription: ISonosSubscription = {
179
+ controller, job: Promise.resolve(), failed: false,
180
+ stop: () => {
181
+ if (!subscription.stopPromise) {
182
+ subscription.stopPromise = Promise.resolve().then(async () => {
183
+ await subscription.job;
184
+ this.subscriptions.delete(subscription);
185
+ if (subscription.failed) throw subscription.failure;
186
+ });
187
+ controller.abort(new Error('Sonos subscription stopped.'));
188
+ }
189
+ return subscription.stopPromise;
190
+ },
191
+ };
192
+ // Reservation precedes the initial read and the first caller callback.
193
+ this.subscriptions.add(subscription);
194
+ const publish = async (type: IIntegrationEvent['type'], data: unknown): Promise<void> => {
195
+ try { await handler(this.event(type, data)); }
196
+ catch (error) { subscription.failed = true; subscription.failure = error; throw error; }
197
+ };
198
+ const initial = this.observe(async (session) => {
199
+ const snapshot = await this.read(session); combined.throwIfAborted();
200
+ await publish('state_changed', snapshot); combined.throwIfAborted();
201
+ }, combined);
202
+ subscription.job = initial.then(async () => {
203
+ while (!combined.aborted) {
204
+ await wait(this.client.config.pollIntervalMs, combined);
189
205
  if (combined.aborted) return;
190
- handler(this.event('availability_changed', { available: false }));
191
- handler(this.event('error', { message: error instanceof Error ? error.message : 'Sonos read failed.' }));
192
- continue;
206
+ await this.observe(async (session) => {
207
+ let snapshot: ISonosSnapshot;
208
+ try { snapshot = await this.read(session); }
209
+ catch (error) {
210
+ if (combined.aborted) return;
211
+ await publish('availability_changed', { available: false });
212
+ await publish('error', { message: error instanceof Error ? error.message : 'Sonos read failed.' });
213
+ return;
214
+ }
215
+ await publish('state_changed', snapshot);
216
+ }, combined);
193
217
  }
194
- handler(this.event('state_changed', snapshot));
195
- }
196
- })().catch((error: unknown) => {
197
- if (!combined.aborted) { subscription.failed = true; subscription.failure = error; this.healthy = false; controller.abort(error); }
198
- }).then(() => {
199
- if (!subscription.failed) this.subscriptions.delete(subscription);
200
- });
201
- subscription = {
202
- controller, job, failed: false,
203
- stop: async () => {
204
- controller.abort(new Error('Sonos subscription stopped.'));
205
- await job;
206
- this.subscriptions.delete(subscription);
207
- if (subscription.failed) throw subscription.failure;
208
- },
209
- };
210
- this.subscriptions.add(subscription);
211
- return subscription.stop;
218
+ }).catch((error: unknown) => {
219
+ if (!combined.aborted || subscription.failed) { subscription.failed = true; subscription.failure = error; this.healthy = false; controller.abort(error); }
220
+ }).then(() => { if (!subscription.failed) this.subscriptions.delete(subscription); });
221
+ return initial.then(() => subscription.stop, async (error: unknown) => {
222
+ try { await subscription.stop(); } catch (cleanupError) { if (cleanupError !== error) throw new AggregateError([error, cleanupError], 'Sonos subscription admission and cleanup failed.'); }
223
+ throw error;
224
+ });
225
+ } catch (error) { return Promise.reject(error); }
212
226
  }
213
227
 
214
- public destroy(): Promise<void> {
228
+ public destroy(): Promise<void> { return this.lease.close(); }
229
+
230
+ /** The owner's retained composite handle invokes this, including explicit cleanup retry. */
231
+ public closeOwnedResources(retry = false): Promise<void> {
232
+ if (retry) return this.drain(true);
215
233
  if (!this.closePromise) {
216
- this.closed = true;
217
- this.healthy = false;
234
+ this.closed = true; this.healthy = false;
235
+ this.closePromise = Promise.resolve().then(() => this.drain(false));
218
236
  this.lifetime.abort(new Error('Sonos runtime is closed.'));
219
- if (this.parentAbort) this.context.abortSignal?.removeEventListener('abort', this.parentAbort);
220
- this.closePromise = (async () => {
221
- const results = await Promise.allSettled([this.client.destroy(), ...[...this.subscriptions].map((subscription) => subscription.stop())]);
222
- this.onClosed?.();
223
- const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
224
- if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos cleanup failed.');
225
- })();
226
237
  }
227
238
  return this.closePromise;
228
239
  }
240
+ private async drain(retry: boolean): Promise<void> {
241
+ const results = await Promise.allSettled([
242
+ Promise.resolve().then(() => retry ? this.client.retryCleanup() : this.client.destroy()),
243
+ ...[...this.subscriptions].map((subscription) => Promise.resolve().then(() => subscription.stop())),
244
+ ]);
245
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
246
+ if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos cleanup or subscriber failed; ownership remains retained.');
247
+ }
229
248
 
230
- private async read(signal?: AbortSignal): Promise<ISonosSnapshot> {
231
- try { this.snapshot = await this.client.getSnapshot(this.signal(signal)); this.healthy = true; return this.snapshot; }
249
+ private observe<TResult>(work: (session: ISonosOperationSession) => Promise<TResult>, signal?: AbortSignal): Promise<TResult> {
250
+ try {
251
+ this.lease.assertOpen();
252
+ return this.client.withOperation(async (session) => {
253
+ try { return await work(session); }
254
+ catch (error) { if (error instanceof SonosCommandError && error.effectOutcome === 'indeterminate') this.healthy = false; throw error; }
255
+ }, signal);
256
+ }
257
+ catch (error) { return Promise.reject(error); }
258
+ }
259
+ private async read(session: ISonosOperationSession): Promise<ISonosSnapshot> {
260
+ try { this.snapshot = await session.getSnapshot(); this.healthy = true; return this.snapshot; }
232
261
  catch (error) { this.healthy = false; throw error; }
233
262
  }
234
-
235
- private async readback(signal?: AbortSignal): Promise<IServiceCallResult> {
236
- const snapshot = await this.read(signal);
237
- return { success: true, data: { devices: SonosMapper.toDevices(snapshot), entities: SonosMapper.toEntities(snapshot) } };
263
+ private assertControlAllowed(): void {
264
+ this.lease.assertOpen();
265
+ if (!this.client.controlAllowed) throw new Error('Sonos control requires explicit runtime owner permission.');
238
266
  }
239
-
240
- private signal(signal?: AbortSignal): AbortSignal {
241
- const combined = AbortSignal.any([this.lifetime.signal, ...(signal ? [signal] : [])]);
242
- combined.throwIfAborted();
243
- return combined;
267
+ private result(result: ISonosControlResult): IServiceCallResult {
268
+ this.snapshot = result.snapshot; this.healthy = true;
269
+ return { success: true, data: { acknowledgedActions: result.acknowledgedActions, outcome: result.outcome, requestedValue: result.requestedValue, observedValue: result.observedValue,
270
+ physicalEffectConfirmed: false, observationAtomic: false, observedAt: result.snapshot.observedAt,
271
+ devices: SonosMapper.toDevices(result.snapshot), entities: SonosMapper.toEntities(result.snapshot) } };
244
272
  }
245
-
246
273
  private event(type: IIntegrationEvent['type'], data: unknown): IIntegrationEvent {
247
- return { type, integrationDomain: this.domain, deviceId: SonosMapper.deviceId(this.snapshot),
248
- entityId: SonosMapper.entityId(this.snapshot.speakerInfo.uid), data: structuredClone(data), timestamp: Date.now() };
274
+ return { type, integrationDomain: this.domain, deviceId: SonosMapper.deviceId(this.snapshot), entityId: SonosMapper.entityId(this.snapshot.homeId, this.snapshot.speakerInfo.uid),
275
+ data: structuredClone(data), timestamp: Date.now() };
249
276
  }
250
277
  }
251
278
 
@@ -256,12 +283,10 @@ function assertDataRecord(value: unknown, allowed?: string[]): asserts value is
256
283
  if (typeof key !== 'string' || !descriptor || !('value' in descriptor) || (allowed && !allowed.includes(key))) throw new Error('Sonos input contains an unsupported field.');
257
284
  }
258
285
  }
259
-
260
286
  function wait(milliseconds: number, signal: AbortSignal): Promise<void> {
261
287
  return new Promise((resolve) => {
262
288
  const finish = (): void => { clearTimeout(timer); signal.removeEventListener('abort', finish); resolve(); };
263
289
  const timer = setTimeout(finish, milliseconds);
264
- signal.addEventListener('abort', finish, { once: true });
265
- if (signal.aborted) finish();
290
+ signal.addEventListener('abort', finish, { once: true }); if (signal.aborted) finish();
266
291
  });
267
292
  }
@@ -1,6 +1,7 @@
1
- import type { ISonosConfig } from './sonos.types.js';
1
+ import type { ISonosConfig, ISonosEndpointConfig, ISonosOwnerOptions } from './sonos.types.js';
2
2
 
3
3
  export interface INormalizedSonosConfig extends ISonosConfig {
4
+ readonly homeId: string;
4
5
  readonly host: string;
5
6
  readonly port: number;
6
7
  readonly expectedUid?: string;
@@ -9,6 +10,19 @@ export interface INormalizedSonosConfig extends ISonosConfig {
9
10
  readonly pollIntervalMs: number;
10
11
  }
11
12
 
13
+ export function normalizeSonosHome(value: unknown): string {
14
+ if (typeof value !== 'string' || !value || value.length > 256 || value.trim() !== value || /[\p{Cc}\p{Cs}]/u.test(value)) {
15
+ throw new Error('Sonos homeId must be a bounded nonempty identifier without surrounding whitespace or control characters.');
16
+ }
17
+ return value;
18
+ }
19
+
20
+ export function normalizeSonosOwnerOptions(input: ISonosOwnerOptions): Readonly<ISonosOwnerOptions> {
21
+ if (!isSonosDataRecord(input) || Object.keys(input).some((key) => !['homeId', 'allowControl'].includes(key))) throw new Error('Sonos owner options contain unsupported fields.');
22
+ if (input.allowControl !== undefined && typeof input.allowControl !== 'boolean') throw new Error('Sonos allowControl must be boolean.');
23
+ return Object.freeze({ ...(input.homeId === undefined ? {} : { homeId: normalizeSonosHome(input.homeId) }), allowControl: input.allowControl === true });
24
+ }
25
+
12
26
  export function normalizeSonosUid(value: unknown): string {
13
27
  if (typeof value !== 'string' || !/^RINCON_[0-9a-f]{12}0?1400$/i.test(value)) {
14
28
  throw new Error('Sonos player identity must be a RINCON UUID.');
@@ -20,11 +34,19 @@ export function isSonosDataRecord(value: unknown): value is Record<string, unkno
20
34
  if (!value || typeof value !== 'object' || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false;
21
35
  return Reflect.ownKeys(value).every((key) => {
22
36
  const descriptor = Object.getOwnPropertyDescriptor(value, key);
23
- return typeof key === 'string' && descriptor !== undefined && 'value' in descriptor;
37
+ return typeof key === 'string' && descriptor !== undefined && descriptor.enumerable === true && 'value' in descriptor;
24
38
  });
25
39
  }
26
40
 
27
41
  export function normalizeSonosConfig(input: ISonosConfig): INormalizedSonosConfig {
42
+ if (!isSonosDataRecord(input) || !Object.hasOwn(input, 'homeId')) throw new Error('Sonos configuration requires its own homeId.');
43
+ const homeId = normalizeSonosHome(input.homeId);
44
+ const { homeId: _homeId, ...endpoint } = input;
45
+ return Object.freeze({ ...normalizeSonosEndpoint(endpoint), homeId });
46
+ }
47
+
48
+ /** Discovery validates an endpoint only; it cannot select an owning home. */
49
+ export function normalizeSonosEndpoint(input: ISonosEndpointConfig): Omit<INormalizedSonosConfig, 'homeId'> {
28
50
  if (!isSonosDataRecord(input) || !Object.hasOwn(input, 'host')) throw new Error('Sonos configuration must be a plain object containing its own host field.');
29
51
  const allowed = new Set(['host', 'port', 'expectedUid', 'timeoutMs', 'maxResponseBytes', 'pollIntervalMs']);
30
52
  for (const key of Reflect.ownKeys(input)) {