@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,169 +1,438 @@
1
1
  import * as plugins from '../../plugins.js';
2
- import { isSonosDataRecord, normalizeSonosConfig, normalizeSonosUid, type INormalizedSonosConfig } from './sonos.config.js';
2
+ import { NativeHomeAdmission, type INativeAdmissionContext } from '../../core/classes.nativehomeadmission.js';
3
+ import { NativeIntegrationOwner, type INativeOperationLease, type INativeSetupLease } from '../../core/classes.nativeintegrationowner.js';
4
+ import type { INativeIntegrationSetupContext } from '../../core/types.js';
5
+ import { isSonosDataRecord, normalizeSonosConfig, normalizeSonosOwnerOptions, normalizeSonosUid, type INormalizedSonosConfig } from './sonos.config.js';
3
6
  import { parseSonosGroup } from './sonos.topology.js';
4
- import type { ISonosConfig, ISonosGroup, ISonosMediaOptions, ISonosPositionInfo, ISonosSnapshot, ISonosSpeakerInfo, ISonosTransportInfo } from './sonos.types.js';
7
+ import type { ISonosCapabilities, ISonosConfig, ISonosControlResult, ISonosGroup, ISonosMediaOptions, ISonosOwnerOptions, ISonosPositionInfo, ISonosSnapshot, ISonosSpeakerInfo, ISonosTransportInfo } from './sonos.types.js';
5
8
  import { readSonosXmlElement, readSonosText, readSonosOptionalText } from './sonos.xml.js';
6
9
 
7
10
  const transportPath = '/MediaRenderer/AVTransport/Control';
11
+ const renderingPath = '/MediaRenderer/RenderingControl/Control';
12
+ const transportService = 'urn:schemas-upnp-org:service:AVTransport:1';
13
+ const renderingService = 'urn:schemas-upnp-org:service:RenderingControl:1';
14
+
15
+ interface ISonosDescription { speakerInfo: ISonosSpeakerInfo; device: Record<string, unknown>; }
16
+ interface ISonosResources {
17
+ soap: plugins.ecobridgeDeviceManager.UpnpSoapClient;
18
+ playback: plugins.ecobridgeDeviceManager.UpnpPlaybackController;
19
+ volume: plugins.ecobridgeDeviceManager.UpnpVolumeController;
20
+ }
21
+ interface ISonosCleanup { close(): Promise<void>; status: 'idle' | 'pending' | 'failed' | 'closed'; promise?: Promise<void>; }
22
+ interface ISonosClientOwnership { readonly owner: NativeIntegrationOwner; readonly lease: INativeSetupLease; }
23
+
24
+ /** A session cannot escape its admitted operation or outlive its deadline. */
25
+ export interface ISonosOperationSession {
26
+ getSnapshot(): Promise<ISonosSnapshot>;
27
+ getSpeakerInfo(): Promise<ISonosSpeakerInfo>;
28
+ getTransportInfo(): Promise<ISonosTransportInfo>;
29
+ getPositionInfo(): Promise<ISonosPositionInfo>;
30
+ getVolume(): Promise<number>;
31
+ getMute(): Promise<boolean>;
32
+ play(): Promise<ISonosControlResult>;
33
+ pause(): Promise<ISonosControlResult>;
34
+ stop(): Promise<ISonosControlResult>;
35
+ next(): Promise<ISonosControlResult>;
36
+ previous(): Promise<ISonosControlResult>;
37
+ setVolume(value: number): Promise<ISonosControlResult>;
38
+ setMute(value: boolean): Promise<ISonosControlResult>;
39
+ playUri(uri: string, options?: ISonosMediaOptions): Promise<ISonosControlResult>;
40
+ }
41
+
42
+ export class SonosCommandError extends Error {
43
+ public readonly physicalEffectConfirmed = false;
44
+ public readonly acknowledgedActions: readonly string[];
45
+ constructor(message: string, public readonly effectOutcome: 'not-sent' | 'rejected' | 'indeterminate', acknowledgedActions: readonly string[], cause?: unknown) {
46
+ super(message, { cause });
47
+ this.name = 'SonosCommandError';
48
+ this.acknowledgedActions = Object.freeze([...acknowledgedActions]);
49
+ }
50
+ }
8
51
 
9
52
  export class SonosClient {
10
53
  public readonly config: INormalizedSonosConfig;
54
+ public readonly controlAllowed: boolean;
55
+ private readonly context: INativeAdmissionContext;
56
+ private readonly owner: NativeIntegrationOwner;
57
+ private readonly standalone: boolean;
58
+ private readonly resourceLease: Promise<INativeSetupLease>;
59
+ private readonly setupLease?: INativeSetupLease;
11
60
  private readonly lifetime = new AbortController();
12
- private readonly soap: plugins.ecobridgeDeviceManager.UpnpSoapClient;
13
- private readonly playback: plugins.ecobridgeDeviceManager.UpnpPlaybackController;
14
- private readonly volume: plugins.ecobridgeDeviceManager.UpnpVolumeController;
15
61
  private readonly operations = new Set<Promise<unknown>>();
62
+ private readonly cleanup = new Set<ISonosCleanup>();
63
+ private resources?: ISonosResources;
16
64
  private selectedUid?: string;
65
+ private closed = false;
17
66
  private closePromise?: Promise<void>;
67
+ private ownedClosePromise?: Promise<void>;
68
+ private retryPromise?: Promise<void>;
18
69
 
19
- constructor(config: ISonosConfig, private readonly abortSignal?: AbortSignal) {
70
+ /** The explicit context remains required even when constructor home is constrained. */
71
+ constructor(config: ISonosConfig, context: INativeIntegrationSetupContext, options: ISonosOwnerOptions = {}, ownership?: ISonosClientOwnership) {
20
72
  this.config = normalizeSonosConfig(config);
21
- const baseUrl = `http://${this.config.host}:${this.config.port}`;
22
- this.soap = new plugins.ecobridgeDeviceManager.UpnpSoapClient(baseUrl, {
73
+ const selected = normalizeSonosOwnerOptions(options);
74
+ this.context = new NativeHomeAdmission('sonos', selected.homeId === undefined ? {} : { homeId: selected.homeId }).admit(context, this.config.homeId);
75
+ this.controlAllowed = selected.allowControl === true;
76
+ if (ownership && !(ownership.owner instanceof NativeIntegrationOwner)) throw new Error('Sonos requires an actual native integration owner.');
77
+ this.standalone = ownership === undefined;
78
+ this.owner = ownership?.owner ?? new NativeIntegrationOwner('sonos', { homeId: this.context.homeId });
79
+ // This admission is independent of config, discovery, and the shared owner's prior binding.
80
+ this.owner.admission.admit(this.context, this.config.homeId);
81
+ if (ownership) {
82
+ ownership.lease.assertOpen();
83
+ if (ownership.lease.context.homeId !== this.context.homeId || ownership.lease.context.configStore !== this.context.configStore) throw new Error('Sonos client ownership does not match its admitted context.');
84
+ this.setupLease = ownership.lease;
85
+ this.resourceLease = Promise.resolve(ownership.lease);
86
+ } else {
87
+ let cleanupStarted = false;
88
+ this.resourceLease = this.owner.runSetup(this.context, this.config.homeId, async (lease) => {
89
+ lease.own(() => { const retry = cleanupStarted; cleanupStarted = true; return this.closeOwnedResources(retry); });
90
+ lease.promoteToRuntime();
91
+ return lease;
92
+ });
93
+ // The owning ledger retains failures; construction has no unhandled promise rejection.
94
+ void this.resourceLease.catch(() => undefined);
95
+ }
96
+ }
97
+
98
+ public getSnapshot(signal?: AbortSignal): Promise<ISonosSnapshot> { return this.withOperation((session) => session.getSnapshot(), signal); }
99
+ public getSpeakerInfo(signal?: AbortSignal): Promise<ISonosSpeakerInfo> { return this.withOperation((session) => session.getSpeakerInfo(), signal); }
100
+ public getTransportInfo(signal?: AbortSignal): Promise<ISonosTransportInfo> { return this.withOperation((session) => session.getTransportInfo(), signal); }
101
+ public getPositionInfo(signal?: AbortSignal): Promise<ISonosPositionInfo> { return this.withOperation((session) => session.getPositionInfo(), signal); }
102
+ public getVolume(signal?: AbortSignal): Promise<number> { return this.withOperation((session) => session.getVolume(), signal); }
103
+ public getMute(signal?: AbortSignal): Promise<boolean> { return this.withOperation((session) => session.getMute(), signal); }
104
+ public play(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.play(), signal); }
105
+ public pause(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.pause(), signal); }
106
+ public stop(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.stop(), signal); }
107
+ public next(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.next(), signal); }
108
+ public previous(signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.previous(), signal); }
109
+ public setVolume(value: number, signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.setVolume(value), signal); }
110
+ public setMute(value: boolean, signal?: AbortSignal): Promise<ISonosControlResult> { return this.withOperation((session) => session.setMute(value), signal); }
111
+ public playUri(uri: string, options: ISonosMediaOptions = {}, signal?: AbortSignal): Promise<ISonosControlResult> {
112
+ const selected = media(uri, options);
113
+ return this.withOperation((session) => session.playUri(selected.uri, selected.options), signal);
114
+ }
115
+
116
+ /** Registers the entire caller continuation, not only individual HTTP exchanges. */
117
+ public withOperation<TResult>(work: (session: ISonosOperationSession) => Promise<TResult>, signal?: AbortSignal): Promise<TResult> {
118
+ try {
119
+ this.assertOpen();
120
+ if (typeof work !== 'function' || (signal !== undefined && !(signal instanceof AbortSignal))) throw new Error('Sonos operation/cancellation is invalid.');
121
+ signal?.throwIfAborted();
122
+ const deadline = new AbortController();
123
+ const combined = AbortSignal.any([this.lifetime.signal, deadline.signal, ...(this.context.abortSignal ? [this.context.abortSignal] : []), ...(signal ? [signal] : [])]);
124
+ let timer: ReturnType<typeof setTimeout> | undefined;
125
+ const operation = this.owner.runOperation({ ...this.context, abortSignal: combined }, this.config.homeId, async (lease) => {
126
+ lease.own(async () => { clearTimeout(timer); });
127
+ const resources = await this.resourceLease;
128
+ resources.assertOpen();
129
+ return this.runAdmitted(lease, resources, work);
130
+ });
131
+ // Capacity and home are reserved before allocation; deadline includes queued callback time.
132
+ timer = setTimeout(() => deadline.abort(new Error('Sonos operation deadline expired.')), this.config.timeoutMs);
133
+ void operation.then(() => clearTimeout(timer), () => clearTimeout(timer));
134
+ return operation;
135
+ } catch (error) { return Promise.reject(error); }
136
+ }
137
+
138
+ /** Integration setup already owns this exact lease; no second setup/operation pool is created. */
139
+ public withSetupLease<TResult>(lease: INativeSetupLease, work: (session: ISonosOperationSession) => Promise<TResult>): Promise<TResult> {
140
+ this.assertOpen();
141
+ if (lease !== this.setupLease) throw new Error('Sonos setup requires its exact owning lease.');
142
+ lease.assertOpen();
143
+ if (lease.context.homeId !== this.context.homeId || lease.context.configStore !== this.context.configStore) throw new Error('Sonos setup lease conflicts with client admission.');
144
+ return this.runAdmitted(lease, lease, work);
145
+ }
146
+
147
+ public destroy(): Promise<void> {
148
+ if (!this.closePromise) {
149
+ this.closed = true;
150
+ let close: Promise<void>;
151
+ this.closePromise = Promise.resolve().then(() => close);
152
+ close = this.standalone ? this.owner.close() : this.closeOwnedResources(false);
153
+ this.lifetime.abort(new Error('Sonos client is closed.'));
154
+ }
155
+ return this.closePromise;
156
+ }
157
+
158
+ public retryCleanup(): Promise<void> {
159
+ if (!this.closed) return Promise.reject(new Error('Sonos cleanup retry requires a closed client.'));
160
+ if (!this.retryPromise) {
161
+ const retry = Promise.resolve().then(() => this.standalone ? this.owner.retryCleanup() : this.closeOwnedResources(true));
162
+ this.retryPromise = retry;
163
+ const settled = (): void => { this.retryPromise = undefined; };
164
+ void retry.then(settled, settled);
165
+ }
166
+ return this.retryPromise;
167
+ }
168
+
169
+ private closeOwnedResources(retry: boolean): Promise<void> {
170
+ if (retry) return this.drain(true);
171
+ if (!this.ownedClosePromise) {
172
+ this.closed = true;
173
+ this.ownedClosePromise = Promise.resolve().then(() => this.drain(false));
174
+ this.lifetime.abort(new Error('Sonos client is closed.'));
175
+ }
176
+ return this.ownedClosePromise;
177
+ }
178
+
179
+ private async drain(retry: boolean): Promise<void> {
180
+ const closing = [...this.cleanup].map((entry) => {
181
+ if (retry && entry.status === 'failed') entry.promise = undefined;
182
+ if (!entry.promise) {
183
+ entry.status = 'pending';
184
+ entry.promise = Promise.resolve().then(() => entry.close()).then(() => { entry.status = 'closed'; }, (error: unknown) => { entry.status = 'failed'; throw error; });
185
+ }
186
+ return entry.promise;
187
+ });
188
+ const [results] = await Promise.all([Promise.allSettled(closing), Promise.allSettled([...this.operations])]);
189
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
190
+ if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos client cleanup failed; handles remain retained.');
191
+ }
192
+
193
+ private allocate(lease: INativeSetupLease): ISonosResources {
194
+ this.assertOpen();
195
+ lease.assertOpen();
196
+ if (this.resources) return this.resources;
197
+ const soap = new plugins.ecobridgeDeviceManager.UpnpSoapClient(`http://${this.config.host}:${this.config.port}`, {
23
198
  timeoutMs: this.config.timeoutMs, maxResponseBytes: this.config.maxResponseBytes,
24
- abortSignal: this.signal(),
199
+ abortSignal: AbortSignal.any([this.lifetime.signal, lease.signal]),
25
200
  });
26
- this.playback = plugins.ecobridgeDeviceManager.UpnpPlaybackController.forSonos(this.config.host, this.config.port, this.soap);
27
- this.volume = plugins.ecobridgeDeviceManager.UpnpVolumeController.forSonos(this.config.host, this.config.port, this.soap);
28
- }
29
-
30
- public getSnapshot(abortSignal?: AbortSignal): Promise<ISonosSnapshot> {
31
- return this.request(async (signal) => {
32
- const speakerInfo = await this.getSpeakerInfo(signal);
33
- const [group, transportInfo, positionInfo, volume, muted] = await settledValues([
34
- this.getGroup(speakerInfo.uid, signal), this.getTransportInfo(signal), this.getPositionInfo(signal),
35
- this.volume.getVolume(signal), this.volume.getMute(signal),
36
- ] as const);
37
- return { speakerInfo, group, transportInfo, positionInfo, volume, muted, observedAt: new Date().toISOString() };
38
- }, abortSignal);
39
- }
40
-
41
- public getSpeakerInfo(abortSignal?: AbortSignal): Promise<ISonosSpeakerInfo> {
42
- return this.request(async (signal) => {
43
- const xml = await this.soap.getXml('/xml/device_description.xml', signal);
44
- const root = readSonosXmlElement(this.soap.parseXml(xml).root, 'device description');
45
- if (root['@_xmlns'] !== 'urn:schemas-upnp-org:device-1-0') throw new Error('Sonos device description has an invalid namespace.');
46
- const device = readSonosXmlElement(root.device, 'renderer');
47
- if (readSonosText(device, 'deviceType') !== 'urn:schemas-upnp-org:device:ZonePlayer:1' || !['sonos', 'sonos, inc.'].includes(readSonosText(device, 'manufacturer').toLowerCase())) {
48
- throw new Error('Configured endpoint is not a Sonos ZonePlayer.');
49
- }
50
- const uid = normalizeSonosUid(readSonosText(device, 'UDN').replace(/^uuid:/i, ''));
51
- const expectedUid = this.config.expectedUid ?? this.selectedUid;
52
- if (expectedUid && uid !== expectedUid) throw new Error('Sonos renderer identity changed or does not match configuration.');
53
- this.selectedUid ??= uid;
54
- const zoneXml = await this.soap.soapAction('/DeviceProperties/Control', 'urn:schemas-upnp-org:service:DeviceProperties:1', 'GetZoneAttributes', {}, signal);
55
- const zoneName = this.soap.extractValue(zoneXml, 'CurrentZoneName');
56
- if (!zoneName.trim() || zoneName.length > 256) throw new Error('Sonos zone name is invalid.');
57
- return {
58
- uid, zoneName, manufacturer: 'Sonos', modelName: readSonosOptionalText(device, 'modelName'),
59
- modelNumber: readSonosOptionalText(device, 'modelNumber'), serialNumber: readSonosOptionalText(device, 'serialNum'),
60
- softwareVersion: readSonosOptionalText(device, 'softwareVersion'),
201
+ this.cleanup.add({ close: () => soap.close(), status: 'idle' });
202
+ const playback = plugins.ecobridgeDeviceManager.UpnpPlaybackController.forSonos(this.config.host, this.config.port, soap);
203
+ this.cleanup.add({ close: () => playback.close(), status: 'idle' });
204
+ const volume = plugins.ecobridgeDeviceManager.UpnpVolumeController.forSonos(this.config.host, this.config.port, soap);
205
+ this.cleanup.add({ close: () => volume.close(), status: 'idle' });
206
+ this.resources = { soap, playback, volume };
207
+ return this.resources;
208
+ }
209
+
210
+ private runAdmitted<TResult>(lease: INativeOperationLease, resourceLease: INativeSetupLease, work: (session: ISonosOperationSession) => Promise<TResult>): Promise<TResult> {
211
+ const deadline = new AbortController();
212
+ const signal = AbortSignal.any([lease.signal, resourceLease.signal, this.lifetime.signal, deadline.signal]);
213
+ const timer = setTimeout(() => deadline.abort(new Error('Sonos operation deadline expired.')), this.config.timeoutMs);
214
+ let active = true;
215
+ let operation: Promise<TResult>;
216
+ operation = Promise.resolve().then(async () => {
217
+ this.assertOpen(); lease.assertOpen(); signal.throwIfAborted();
218
+ const resources = this.allocate(resourceLease);
219
+ const calls = new Set<Promise<unknown>>();
220
+ let controlStarted = false;
221
+ const invoke = <TValue>(callback: () => Promise<TValue>): Promise<TValue> => {
222
+ if (!active) throw new Error('Sonos operation session is closed.');
223
+ this.assertOpen(); lease.assertOpen(); signal.throwIfAborted();
224
+ let call: Promise<TValue>;
225
+ call = Promise.resolve().then(callback).finally(() => calls.delete(call));
226
+ calls.add(call);
227
+ void call.then(() => undefined, () => undefined);
228
+ return call;
229
+ };
230
+ const control = (callback: () => Promise<ISonosControlResult>): Promise<ISonosControlResult> => {
231
+ if (controlStarted) throw new Error('Sonos admits one control sequence per operation.');
232
+ controlStarted = true;
233
+ return invoke(callback);
61
234
  };
62
- }, abortSignal);
235
+ const session: ISonosOperationSession = Object.freeze({
236
+ getSnapshot: () => invoke(() => this.readSnapshot(resources, signal)),
237
+ getSpeakerInfo: () => invoke(async () => (await this.readDescription(resources, signal)).speakerInfo),
238
+ getTransportInfo: () => invoke(() => this.readTransport(resources, signal)),
239
+ getPositionInfo: () => invoke(() => this.readPosition(resources, signal)),
240
+ getVolume: () => invoke(async () => { await this.readDescription(resources, signal); return resources.volume.getVolume(signal); }),
241
+ getMute: () => invoke(async () => { await this.readDescription(resources, signal); return resources.volume.getMute(signal); }),
242
+ play: () => control(() => this.transportControl(resources, 'Play', signal)),
243
+ pause: () => control(() => this.transportControl(resources, 'Pause', signal)),
244
+ stop: () => control(() => this.transportControl(resources, 'Stop', signal)),
245
+ next: () => control(() => this.transportControl(resources, 'Next', signal)),
246
+ previous: () => control(() => this.transportControl(resources, 'Previous', signal)),
247
+ setVolume: (value: number) => control(() => this.renderingControl(resources, 'SetVolume', value, signal)),
248
+ setMute: (value: boolean) => control(() => this.renderingControl(resources, 'SetMute', value, signal)),
249
+ playUri: (uri: string, options: ISonosMediaOptions = {}) => { const selected = media(uri, options); return control(() => this.mediaControl(resources, selected, signal)); },
250
+ });
251
+ let result: TResult;
252
+ try { result = await work(session); }
253
+ catch (error) { active = false; await Promise.allSettled([...calls]); throw error; }
254
+ active = false;
255
+ const results = await Promise.allSettled([...calls]);
256
+ const failures = results.filter((entry): entry is PromiseRejectedResult => entry.status === 'rejected');
257
+ if (failures.length) throw new AggregateError(failures.map((entry) => entry.reason), 'Sonos operation continuation failed.');
258
+ return result;
259
+ }).finally(() => { active = false; clearTimeout(timer); this.operations.delete(operation); });
260
+ this.operations.add(operation);
261
+ return operation;
63
262
  }
64
263
 
65
- public getTransportInfo(abortSignal?: AbortSignal): Promise<ISonosTransportInfo> {
66
- return this.request(async (signal) => {
67
- const transport = await this.soap.getTransportInfo(transportPath, signal);
68
- return { currentTransportState: transport.state, currentTransportStatus: transport.status, currentSpeed: transport.speed };
69
- }, abortSignal);
264
+ private assertOpen(): void {
265
+ if (this.closed) throw new Error('Sonos client is closed.');
266
+ this.lifetime.signal.throwIfAborted();
267
+ this.context.abortSignal?.throwIfAborted();
70
268
  }
71
269
 
72
- public getPositionInfo(abortSignal?: AbortSignal): Promise<ISonosPositionInfo> {
73
- return this.request(async (signal) => {
74
- const position = await this.soap.getPositionInfo(transportPath, signal);
75
- const metadata = position.trackMetadata && position.trackMetadata !== 'NOT_IMPLEMENTED'
76
- ? this.soap.extractOptionalValues(position.trackMetadata, ['title', 'creator', 'artist', 'album', 'albumArtURI']) : {};
77
- return {
78
- track: position.track, trackDuration: position.trackDuration, trackMetaData: position.trackMetadata,
79
- trackUri: position.trackUri, relativeTime: position.relativeTime, title: metadata.title,
80
- artist: metadata.creator ?? metadata.artist, album: metadata.album, albumArtUri: metadata.albumArtURI,
81
- };
82
- }, abortSignal);
270
+ private async readDescription(resources: ISonosResources, signal: AbortSignal): Promise<ISonosDescription> {
271
+ const root = readSonosXmlElement(resources.soap.parseXml(await resources.soap.getXml('/xml/device_description.xml', signal)).root, 'device description');
272
+ if (root['@_xmlns'] !== 'urn:schemas-upnp-org:device-1-0') throw new Error('Sonos device description has an invalid namespace.');
273
+ const device = readSonosXmlElement(root.device, 'renderer');
274
+ if (readSonosText(device, 'deviceType') !== 'urn:schemas-upnp-org:device:ZonePlayer:1' || !['sonos', 'sonos, inc.'].includes(readSonosText(device, 'manufacturer').toLowerCase())) throw new Error('Configured endpoint is not a Sonos ZonePlayer.');
275
+ const uid = normalizeSonosUid(readSonosText(device, 'UDN').replace(/^uuid:/i, ''));
276
+ if ((this.config.expectedUid ?? this.selectedUid) && uid !== (this.config.expectedUid ?? this.selectedUid)) throw new Error('Sonos renderer identity changed or does not match configuration.');
277
+ this.selectedUid ??= uid;
278
+ const xml = await resources.soap.soapAction('/DeviceProperties/Control', 'urn:schemas-upnp-org:service:DeviceProperties:1', 'GetZoneAttributes', {}, signal);
279
+ const zoneName = resources.soap.extractValue(xml, 'CurrentZoneName');
280
+ if (!zoneName.trim() || zoneName.length > 256) throw new Error('Sonos zone name is invalid.');
281
+ return { device, speakerInfo: { uid, zoneName, manufacturer: 'Sonos', modelName: readSonosOptionalText(device, 'modelName'), modelNumber: readSonosOptionalText(device, 'modelNumber'), serialNumber: readSonosOptionalText(device, 'serialNum'), softwareVersion: readSonosOptionalText(device, 'softwareVersion') } };
83
282
  }
84
283
 
85
- public getVolume(signal?: AbortSignal): Promise<number> { return this.request((signal) => this.volume.getVolume(signal), signal); }
86
- public getMute(signal?: AbortSignal): Promise<boolean> { return this.request((signal) => this.volume.getMute(signal), signal); }
87
- public play(signal?: AbortSignal): Promise<void> { return this.transport((signal) => this.playback.play(undefined, signal), signal); }
88
- public pause(signal?: AbortSignal): Promise<void> { return this.transport((signal) => this.playback.pause(signal), signal); }
89
- public stop(signal?: AbortSignal): Promise<void> { return this.transport((signal) => this.playback.stop(signal), signal); }
90
- public next(signal?: AbortSignal): Promise<void> { return this.transport((signal) => this.playback.next(signal), signal); }
91
- public previous(signal?: AbortSignal): Promise<void> { return this.transport((signal) => this.playback.previous(signal), signal); }
284
+ private async readSnapshot(resources: ISonosResources, signal: AbortSignal): Promise<ISonosSnapshot> {
285
+ const description = await this.readDescription(resources, signal);
286
+ const [group, transportInfo, positionInfo, volume, muted, transport, rendering] = await settledValues([
287
+ this.readGroup(resources, description.speakerInfo.uid, signal), this.readTransport(resources, signal), this.readPosition(resources, signal),
288
+ resources.volume.getVolume(signal), resources.volume.getMute(signal), this.transportCapabilities(resources, description.device, signal), this.renderingCapabilities(resources, description.device, signal),
289
+ ] as const);
290
+ signal.throwIfAborted();
291
+ return { homeId: this.context.homeId, controlAllowed: this.controlAllowed, speakerInfo: description.speakerInfo, group, transportInfo, positionInfo, volume, muted,
292
+ capabilities: { ...transport, ...rendering }, observedAt: new Date().toISOString() };
293
+ }
92
294
 
93
- public setVolume(value: number, signal?: AbortSignal): Promise<void> {
94
- if (!Number.isInteger(value) || value < 0 || value > 100) throw new Error('Sonos volume must be an integer from 0 to 100.');
95
- return this.request(async (signal) => { await this.getSpeakerInfo(signal); await this.volume.setVolume(value, signal); }, signal);
295
+ private async readGroup(resources: ISonosResources, uid: string, signal: AbortSignal): Promise<ISonosGroup> {
296
+ const xml = await resources.soap.soapAction('/ZoneGroupTopology/Control', 'urn:schemas-upnp-org:service:ZoneGroupTopology:1', 'GetZoneGroupState', {}, signal);
297
+ return parseSonosGroup(resources.soap.parseXml(resources.soap.extractValue(xml, 'ZoneGroupState')), uid);
298
+ }
299
+ private async readTransport(resources: ISonosResources, signal: AbortSignal): Promise<ISonosTransportInfo> {
300
+ const value = await resources.soap.getTransportInfo(transportPath, signal);
301
+ return { currentTransportState: value.state, currentTransportStatus: value.status, currentSpeed: value.speed };
302
+ }
303
+ private async readPosition(resources: ISonosResources, signal: AbortSignal): Promise<ISonosPositionInfo> {
304
+ const value = await resources.soap.getPositionInfo(transportPath, signal);
305
+ const metadata = value.trackMetadata && value.trackMetadata !== 'NOT_IMPLEMENTED' ? resources.soap.extractOptionalValues(value.trackMetadata, ['title', 'creator', 'artist', 'album', 'albumArtURI']) : {};
306
+ return { track: value.track, trackDuration: value.trackDuration, trackMetaData: value.trackMetadata, trackUri: value.trackUri, relativeTime: value.relativeTime,
307
+ title: metadata.title, artist: metadata.creator ?? metadata.artist, album: metadata.album, albumArtUri: metadata.albumArtURI };
96
308
  }
97
309
 
98
- public setMute(value: boolean, signal?: AbortSignal): Promise<void> {
99
- if (typeof value !== 'boolean') throw new Error('Sonos mute must be boolean.');
100
- return this.request(async (signal) => { await this.getSpeakerInfo(signal); await this.volume.setMute(value, signal); }, signal);
310
+ private async serviceActions(resources: ISonosResources, device: Record<string, unknown>, serviceType: string, controlPath: string, signal: AbortSignal): Promise<string[]> {
311
+ const services: Record<string, unknown>[] = [];
312
+ const visit = (value: Record<string, unknown>, depth: number): void => {
313
+ if (depth > 8) throw new Error('Sonos device description nesting is excessive.');
314
+ if (value.serviceList !== undefined) for (const service of list(readSonosXmlElement(value.serviceList, 'service list').service)) {
315
+ services.push(readSonosXmlElement(service, 'service'));
316
+ if (services.length > 64) throw new Error('Sonos description contains too many services.');
317
+ }
318
+ if (value.deviceList !== undefined) for (const child of list(readSonosXmlElement(value.deviceList, 'device list').device)) visit(readSonosXmlElement(child, 'device'), depth + 1);
319
+ };
320
+ visit(device, 0);
321
+ const matches = services.filter((service) => readSonosText(service, 'serviceType') === serviceType);
322
+ if (matches.length !== 1) throw new Error('Sonos selected renderer must declare exactly one required service.');
323
+ const selected = matches[0];
324
+ const origin = new URL(`http://${this.config.host}:${this.config.port}`).origin;
325
+ const control = new URL(readSonosText(selected, 'controlURL'), origin);
326
+ const scpd = new URL(readSonosText(selected, 'SCPDURL'), origin);
327
+ if (control.origin !== origin || control.pathname !== controlPath || control.username || control.password || control.search || control.hash
328
+ || scpd.origin !== origin || scpd.username || scpd.password || scpd.search || scpd.hash || !scpd.pathname.startsWith('/xml/')) throw new Error('Sonos service description must remain on the exact selected renderer.');
329
+ const root = readSonosXmlElement(resources.soap.parseXml(await resources.soap.getXml(scpd.pathname, signal)).scpd, 'service description');
330
+ if (root['@_xmlns'] !== 'urn:schemas-upnp-org:service-1-0') throw new Error('Sonos service description namespace is invalid.');
331
+ const actions = list(readSonosXmlElement(root.actionList, 'action list').action).map((action) => readSonosText(readSonosXmlElement(action, 'action'), 'name'));
332
+ if (!actions.length || actions.length > 256 || actions.some((action) => !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(action)) || new Set(actions).size !== actions.length) throw new Error('Sonos service actions are invalid or duplicated.');
333
+ return actions;
101
334
  }
102
335
 
103
- public playUri(uri: string, options: ISonosMediaOptions = {}, signal?: AbortSignal): Promise<void> {
104
- if (!isSonosDataRecord(options) || Object.keys(options).some((key) => !['title', 'mimeType'].includes(key))) throw new Error('Sonos media options contain unsupported fields.');
105
- const { title, mimeType } = options;
106
- if (typeof uri !== 'string' || uri.length > 4096 || /[\x00-\x20]/.test(uri)) throw new Error('Sonos media URI is invalid.');
107
- const url = new URL(uri);
108
- if (!['http:', 'https:', 'x-sonos-http:', 'x-rincon-mp3radio:'].includes(url.protocol) || !url.hostname
109
- || uri.slice(url.protocol.length, url.protocol.length + 2) !== '//' || url.username || url.password) throw new Error('Sonos media URI must use an uncredentialed supported stream URL.');
110
- if (title !== undefined && (typeof title !== 'string' || !title.trim() || title.length > 256)) throw new Error('Sonos media title is invalid.');
111
- if (mimeType !== undefined && (typeof mimeType !== 'string' || !/^audio\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i.test(mimeType))) throw new Error('Sonos MIME type must identify a specific audio format.');
112
- if (title !== undefined && mimeType === undefined) throw new Error('Sonos titled media requires an explicit audio MIME type.');
113
- const metadata = title === undefined ? '' : this.soap.generateDidlMetadata(title, uri, mimeType);
114
- return this.transport(async (signal) => {
115
- await this.soap.setAVTransportURI(transportPath, uri, metadata, signal);
116
- await this.playback.play(undefined, signal);
117
- }, signal);
336
+ private async transportCapabilities(resources: ISonosResources, device: Record<string, unknown>, signal: AbortSignal): Promise<Pick<ISonosCapabilities, 'transportActions' | 'declaredTransportActions'>> {
337
+ const declaredTransportActions = await this.serviceActions(resources, device, transportService, transportPath, signal);
338
+ if (!declaredTransportActions.includes('GetCurrentTransportActions')) throw new Error('Sonos renderer does not expose current transport action authority.');
339
+ const xml = await resources.soap.soapAction(transportPath, transportService, 'GetCurrentTransportActions', { InstanceID: 0 }, signal);
340
+ const value = resources.soap.extractValue(xml, 'Actions');
341
+ const transportActions = value === '' ? [] : value.split(',').map((action) => action.trim());
342
+ if (transportActions.length > 128 || transportActions.some((action) => !/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(action)) || new Set(transportActions).size !== transportActions.length) throw new Error('Sonos current transport actions are malformed.');
343
+ return { transportActions, declaredTransportActions };
118
344
  }
119
345
 
120
- public destroy(): Promise<void> {
121
- if (!this.closePromise) {
122
- this.lifetime.abort(new Error('Sonos client is closed.'));
123
- this.closePromise = (async () => {
124
- const results = await Promise.allSettled([this.playback.close(), this.volume.close(), this.soap.close()]);
125
- await Promise.allSettled([...this.operations]);
126
- const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
127
- if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos client cleanup failed.');
128
- })();
346
+ private async renderingCapabilities(resources: ISonosResources, device: Record<string, unknown>, signal: AbortSignal): Promise<Pick<ISonosCapabilities, 'volumeWritable' | 'muteWritable'>> {
347
+ const actions = await this.serviceActions(resources, device, renderingService, renderingPath, signal);
348
+ let fixed = false;
349
+ if (actions.includes('GetOutputFixed')) {
350
+ const xml = await resources.soap.soapAction(renderingPath, renderingService, 'GetOutputFixed', { InstanceID: 0 }, signal);
351
+ const value = resources.soap.extractValue(xml, 'CurrentFixed');
352
+ if (!['0', '1', 'false', 'true'].includes(value)) throw new Error('Sonos fixed-output response is invalid.');
353
+ fixed = value === '1' || value === 'true';
129
354
  }
130
- return this.closePromise;
355
+ return { volumeWritable: actions.includes('SetVolume') && !fixed, muteWritable: actions.includes('SetMute') };
356
+ }
357
+
358
+ private async transportAuthority(resources: ISonosResources, action: string, signal: AbortSignal): Promise<void> {
359
+ const description = await this.readDescription(resources, signal);
360
+ const [group, capabilities] = await settledValues([this.readGroup(resources, description.speakerInfo.uid, signal), this.transportCapabilities(resources, description.device, signal)] as const);
361
+ if (group.coordinatorUid !== description.speakerInfo.uid) throw new Error('Sonos playback requires selecting the group coordinator.');
362
+ if (!capabilities.declaredTransportActions.includes(action) || (action !== 'SetAVTransportURI' && !capabilities.transportActions.includes(action))) throw new Error(`Sonos renderer does not currently authorize ${action}.`);
363
+ signal.throwIfAborted();
131
364
  }
132
365
 
133
- private getGroup(uid: string, signal: AbortSignal): Promise<ISonosGroup> {
134
- return this.request(async (signal) => {
135
- const response = await this.soap.soapAction('/ZoneGroupTopology/Control', 'urn:schemas-upnp-org:service:ZoneGroupTopology:1', 'GetZoneGroupState', {}, signal);
136
- return parseSonosGroup(this.soap.parseXml(this.soap.extractValue(response, 'ZoneGroupState')), uid);
137
- }, signal);
366
+ private async transportControl(resources: ISonosResources, action: 'Play' | 'Pause' | 'Stop' | 'Next' | 'Previous', signal: AbortSignal): Promise<ISonosControlResult> {
367
+ const target = action === 'Play' ? 'PLAYING' : action === 'Pause' ? 'PAUSED_PLAYBACK' : action === 'Stop' ? 'STOPPED' : undefined;
368
+ return this.control(resources, signal, async (acknowledge, attempted) => {
369
+ await this.transportAuthority(resources, action, signal); attempted();
370
+ if (action === 'Play') await resources.playback.play(undefined, signal);
371
+ else if (action === 'Pause') await resources.playback.pause(signal);
372
+ else if (action === 'Stop') await resources.playback.stop(signal);
373
+ else if (action === 'Next') await resources.playback.next(signal);
374
+ else await resources.playback.previous(signal);
375
+ acknowledge(action);
376
+ }, target, (snapshot) => snapshot.transportInfo.currentTransportState);
138
377
  }
139
378
 
140
- private transport(operation: (signal: AbortSignal) => Promise<void>, signal?: AbortSignal): Promise<void> {
141
- return this.request(async (signal) => {
142
- const speaker = await this.getSpeakerInfo(signal);
143
- const group = await this.getGroup(speaker.uid, signal);
144
- if (group.coordinatorUid !== speaker.uid) throw new Error('Sonos playback requires selecting the group coordinator.');
145
- signal.throwIfAborted();
146
- await operation(signal);
147
- }, signal);
379
+ private renderingControl(resources: ISonosResources, action: 'SetVolume' | 'SetMute', value: number | boolean, signal: AbortSignal): Promise<ISonosControlResult> {
380
+ if ((action === 'SetVolume' && (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 100))
381
+ || (action === 'SetMute' && typeof value !== 'boolean')) throw new Error('Sonos rendering value must be an integer percent from 0 to 100 or boolean mute.');
382
+ return this.control(resources, signal, async (acknowledge, attempted) => {
383
+ const description = await this.readDescription(resources, signal);
384
+ const capabilities = await this.renderingCapabilities(resources, description.device, signal);
385
+ if (action === 'SetVolume' ? !capabilities.volumeWritable : !capabilities.muteWritable) throw new Error('Sonos rendering control is not currently writable.');
386
+ signal.throwIfAborted(); attempted();
387
+ if (action === 'SetVolume') await resources.volume.setVolume(value as number, signal);
388
+ else await resources.volume.setMute(value as boolean, signal);
389
+ acknowledge(action);
390
+ }, value, (snapshot) => action === 'SetVolume' ? snapshot.volume : snapshot.muted);
148
391
  }
149
392
 
150
- private signal(signal?: AbortSignal): AbortSignal {
151
- return AbortSignal.any([this.lifetime.signal, ...(this.abortSignal ? [this.abortSignal] : []), ...(signal ? [signal] : [])]);
393
+ private mediaControl(resources: ISonosResources, selected: { uri: string; options: ISonosMediaOptions }, signal: AbortSignal): Promise<ISonosControlResult> {
394
+ return this.control(resources, signal, async (acknowledge, attempted) => {
395
+ await this.transportAuthority(resources, 'SetAVTransportURI', signal);
396
+ const metadata = selected.options.title === undefined ? '' : resources.soap.generateDidlMetadata(selected.options.title, selected.uri, selected.options.mimeType);
397
+ attempted(); await resources.soap.setAVTransportURI(transportPath, selected.uri, metadata, signal); acknowledge('SetAVTransportURI');
398
+ // URI admission changes transport state; authority must be observed again before the distinct Play effect.
399
+ await this.transportAuthority(resources, 'Play', signal);
400
+ attempted(); await resources.playback.play(undefined, signal); acknowledge('Play');
401
+ }, undefined, () => undefined);
152
402
  }
153
403
 
154
- private request<TResult>(operation: (signal: AbortSignal) => Promise<TResult>, signal?: AbortSignal): Promise<TResult> {
155
- const combined = this.signal(signal);
156
- combined.throwIfAborted();
157
- const result = operation(combined);
158
- this.operations.add(result);
159
- void result.then(() => this.operations.delete(result), () => this.operations.delete(result));
160
- return result;
404
+ private async control(resources: ISonosResources, signal: AbortSignal, dispatch: (acknowledge: (action: string) => void, attempted: () => void) => Promise<void>, requestedValue: string | number | boolean | undefined, observation: (snapshot: ISonosSnapshot) => string | number | boolean | undefined): Promise<ISonosControlResult> {
405
+ const acknowledgedActions: string[] = [];
406
+ let attempted = false;
407
+ try {
408
+ if (!this.controlAllowed) throw new Error('Sonos control requires explicit runtime owner permission.');
409
+ await dispatch((action) => acknowledgedActions.push(action), () => { attempted = true; });
410
+ const snapshot = await this.readSnapshot(resources, signal);
411
+ const observedValue = observation(snapshot);
412
+ return Object.freeze({ acknowledgedActions: Object.freeze([...acknowledgedActions]), snapshot, requestedValue, observedValue,
413
+ outcome: requestedValue === undefined ? 'observed-state' : requestedValue === observedValue ? 'observed-target' : 'observed-different', physicalEffectConfirmed: false });
414
+ } catch (error) {
415
+ const outcome = !attempted ? 'not-sent' : error instanceof plugins.ecobridgeDeviceManager.UpnpSoapFault && !acknowledgedActions.length ? 'rejected' : 'indeterminate';
416
+ throw new SonosCommandError(error instanceof Error ? error.message : 'Sonos control failed.', outcome, acknowledgedActions, error);
417
+ }
161
418
  }
162
419
  }
163
420
 
421
+ function media(uri: string, options: ISonosMediaOptions): { uri: string; options: ISonosMediaOptions } {
422
+ if (!isSonosDataRecord(options) || Object.keys(options).some((key) => !['title', 'mimeType'].includes(key))) throw new Error('Sonos media options contain unsupported fields.');
423
+ const { title, mimeType } = options;
424
+ if (typeof uri !== 'string' || uri.length > 4096 || /[\x00-\x20]/.test(uri)) throw new Error('Sonos media URI is invalid.');
425
+ const url = new URL(uri);
426
+ if (!['http:', 'https:', 'x-sonos-http:', 'x-rincon-mp3radio:'].includes(url.protocol) || !url.hostname || uri.slice(url.protocol.length, url.protocol.length + 2) !== '//' || url.username || url.password) throw new Error('Sonos media URI must use an uncredentialed supported stream URL.');
427
+ if (title !== undefined && (typeof title !== 'string' || !title.trim() || title.length > 256)) throw new Error('Sonos media title is invalid.');
428
+ if (mimeType !== undefined && (typeof mimeType !== 'string' || !/^audio\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i.test(mimeType))) throw new Error('Sonos MIME type must identify a specific audio format.');
429
+ if (title !== undefined && mimeType === undefined) throw new Error('Sonos titled media requires an explicit audio MIME type.');
430
+ return { uri, options: Object.freeze({ title, mimeType }) };
431
+ }
432
+ function list(value: unknown): unknown[] { return value === undefined ? [] : Array.isArray(value) ? value : [value]; }
164
433
  async function settledValues<TValues extends readonly unknown[]>(values: { [TKey in keyof TValues]: Promise<TValues[TKey]> }): Promise<TValues> {
165
434
  const results = await Promise.allSettled(values);
166
- const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected');
167
- if (failure) throw failure.reason;
435
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
436
+ if (failures.length) throw new AggregateError(failures.map((result) => result.reason), 'Sonos observation failed.');
168
437
  return results.map((result) => (result as PromiseFulfilledResult<unknown>).value) as unknown as TValues;
169
438
  }