@smarthome.exchange/integrations 1.0.0 → 2.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 (294) hide show
  1. package/changelog.md +49 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts/core/classes.configstore.d.ts +2 -0
  4. package/dist_ts/core/classes.configstore.js +84 -27
  5. package/dist_ts/core/classes.nativehomeadmission.d.ts +19 -0
  6. package/dist_ts/core/classes.nativehomeadmission.js +84 -0
  7. package/dist_ts/core/classes.nativeintegrationowner.d.ts +79 -0
  8. package/dist_ts/core/classes.nativeintegrationowner.js +269 -0
  9. package/dist_ts/core/index.d.ts +2 -0
  10. package/dist_ts/core/index.js +3 -1
  11. package/dist_ts/core/types.d.ts +14 -0
  12. package/dist_ts/integrations/braviatv/braviatv.classes.client.d.ts +23 -72
  13. package/dist_ts/integrations/braviatv/braviatv.classes.client.js +265 -625
  14. package/dist_ts/integrations/braviatv/braviatv.classes.configflow.d.ts +7 -7
  15. package/dist_ts/integrations/braviatv/braviatv.classes.configflow.js +51 -68
  16. package/dist_ts/integrations/braviatv/braviatv.classes.integration.d.ts +8 -11
  17. package/dist_ts/integrations/braviatv/braviatv.classes.integration.js +186 -135
  18. package/dist_ts/integrations/braviatv/braviatv.discovery.js +12 -11
  19. package/dist_ts/integrations/braviatv/braviatv.mapper.d.ts +6 -14
  20. package/dist_ts/integrations/braviatv/braviatv.mapper.js +37 -131
  21. package/dist_ts/integrations/braviatv/braviatv.types.d.ts +55 -133
  22. package/dist_ts/integrations/braviatv/braviatv.validation.d.ts +7 -0
  23. package/dist_ts/integrations/braviatv/braviatv.validation.js +72 -0
  24. package/dist_ts/integrations/broadlink/broadlink.actions.d.ts +2 -0
  25. package/dist_ts/integrations/broadlink/broadlink.actions.js +36 -0
  26. package/dist_ts/integrations/broadlink/broadlink.classes.client.d.ts +21 -19
  27. package/dist_ts/integrations/broadlink/broadlink.classes.client.js +185 -124
  28. package/dist_ts/integrations/broadlink/broadlink.classes.configflow.d.ts +5 -4
  29. package/dist_ts/integrations/broadlink/broadlink.classes.configflow.js +64 -83
  30. package/dist_ts/integrations/broadlink/broadlink.classes.integration.d.ts +16 -19
  31. package/dist_ts/integrations/broadlink/broadlink.classes.integration.js +114 -59
  32. package/dist_ts/integrations/broadlink/broadlink.mapper.d.ts +3 -2
  33. package/dist_ts/integrations/broadlink/broadlink.mapper.js +2 -1
  34. package/dist_ts/integrations/broadlink/broadlink.selected.mapper.d.ts +10 -0
  35. package/dist_ts/integrations/broadlink/broadlink.selected.mapper.js +41 -0
  36. package/dist_ts/integrations/broadlink/broadlink.types.d.ts +43 -7
  37. package/dist_ts/integrations/broadlink/broadlink.validation.d.ts +7 -0
  38. package/dist_ts/integrations/broadlink/broadlink.validation.js +63 -0
  39. package/dist_ts/integrations/daikin/daikin.classes.client.d.ts +10 -52
  40. package/dist_ts/integrations/daikin/daikin.classes.client.js +55 -614
  41. package/dist_ts/integrations/daikin/daikin.classes.configflow.d.ts +8 -12
  42. package/dist_ts/integrations/daikin/daikin.classes.configflow.js +77 -138
  43. package/dist_ts/integrations/daikin/daikin.classes.integration.d.ts +7 -24
  44. package/dist_ts/integrations/daikin/daikin.classes.integration.js +175 -67
  45. package/dist_ts/integrations/daikin/daikin.discovery.d.ts +11 -24
  46. package/dist_ts/integrations/daikin/daikin.discovery.js +98 -305
  47. package/dist_ts/integrations/daikin/daikin.mapper.d.ts +6 -7
  48. package/dist_ts/integrations/daikin/daikin.mapper.js +95 -155
  49. package/dist_ts/integrations/daikin/daikin.types.d.ts +24 -44
  50. package/dist_ts/integrations/daikin/daikin.types.js +1 -1
  51. package/dist_ts/integrations/daikin/daikin.validation.d.ts +5 -0
  52. package/dist_ts/integrations/daikin/daikin.validation.js +53 -0
  53. package/dist_ts/integrations/denonavr/denonavr.classes.client.d.ts +31 -40
  54. package/dist_ts/integrations/denonavr/denonavr.classes.client.js +149 -404
  55. package/dist_ts/integrations/denonavr/denonavr.classes.configflow.d.ts +7 -2
  56. package/dist_ts/integrations/denonavr/denonavr.classes.configflow.js +44 -36
  57. package/dist_ts/integrations/denonavr/denonavr.classes.integration.d.ts +6 -11
  58. package/dist_ts/integrations/denonavr/denonavr.classes.integration.js +192 -113
  59. package/dist_ts/integrations/denonavr/denonavr.discovery.d.ts +10 -18
  60. package/dist_ts/integrations/denonavr/denonavr.discovery.js +71 -193
  61. package/dist_ts/integrations/denonavr/denonavr.mapper.d.ts +5 -13
  62. package/dist_ts/integrations/denonavr/denonavr.mapper.js +35 -159
  63. package/dist_ts/integrations/denonavr/denonavr.types.d.ts +55 -90
  64. package/dist_ts/integrations/denonavr/denonavr.validation.d.ts +10 -0
  65. package/dist_ts/integrations/denonavr/denonavr.validation.js +86 -0
  66. package/dist_ts/integrations/kodi/kodi.classes.client.d.ts +11 -45
  67. package/dist_ts/integrations/kodi/kodi.classes.client.js +84 -310
  68. package/dist_ts/integrations/kodi/kodi.classes.configflow.d.ts +9 -8
  69. package/dist_ts/integrations/kodi/kodi.classes.configflow.js +53 -48
  70. package/dist_ts/integrations/kodi/kodi.classes.integration.d.ts +11 -11
  71. package/dist_ts/integrations/kodi/kodi.classes.integration.js +114 -171
  72. package/dist_ts/integrations/kodi/kodi.discovery.js +53 -29
  73. package/dist_ts/integrations/kodi/kodi.mapper.d.ts +7 -2
  74. package/dist_ts/integrations/kodi/kodi.mapper.js +48 -10
  75. package/dist_ts/integrations/kodi/kodi.types.d.ts +32 -28
  76. package/dist_ts/integrations/kodi/kodi.validation.d.ts +9 -0
  77. package/dist_ts/integrations/kodi/kodi.validation.js +62 -0
  78. package/dist_ts/integrations/modbus/modbus.classes.client.d.ts +15 -61
  79. package/dist_ts/integrations/modbus/modbus.classes.client.js +117 -776
  80. package/dist_ts/integrations/modbus/modbus.classes.configflow.d.ts +9 -5
  81. package/dist_ts/integrations/modbus/modbus.classes.configflow.js +70 -38
  82. package/dist_ts/integrations/modbus/modbus.classes.integration.d.ts +6 -17
  83. package/dist_ts/integrations/modbus/modbus.classes.integration.js +133 -57
  84. package/dist_ts/integrations/modbus/modbus.discovery.d.ts +5 -8
  85. package/dist_ts/integrations/modbus/modbus.discovery.js +38 -93
  86. package/dist_ts/integrations/modbus/modbus.mapper.d.ts +8 -32
  87. package/dist_ts/integrations/modbus/modbus.mapper.js +71 -431
  88. package/dist_ts/integrations/modbus/modbus.types.d.ts +82 -332
  89. package/dist_ts/integrations/modbus/modbus.validation.d.ts +10 -0
  90. package/dist_ts/integrations/modbus/modbus.validation.js +128 -0
  91. package/dist_ts/integrations/nanoleaf/nanoleaf.classes.client.d.ts +12 -34
  92. package/dist_ts/integrations/nanoleaf/nanoleaf.classes.client.js +143 -231
  93. package/dist_ts/integrations/nanoleaf/nanoleaf.classes.configflow.d.ts +6 -1
  94. package/dist_ts/integrations/nanoleaf/nanoleaf.classes.configflow.js +105 -18
  95. package/dist_ts/integrations/nanoleaf/nanoleaf.classes.integration.d.ts +8 -8
  96. package/dist_ts/integrations/nanoleaf/nanoleaf.classes.integration.js +141 -155
  97. package/dist_ts/integrations/nanoleaf/nanoleaf.credentials.d.ts +9 -0
  98. package/dist_ts/integrations/nanoleaf/nanoleaf.credentials.js +35 -0
  99. package/dist_ts/integrations/nanoleaf/nanoleaf.mapper.d.ts +6 -18
  100. package/dist_ts/integrations/nanoleaf/nanoleaf.mapper.js +45 -220
  101. package/dist_ts/integrations/nanoleaf/nanoleaf.types.d.ts +33 -147
  102. package/dist_ts/integrations/nanoleaf/nanoleaf.validation.d.ts +6 -0
  103. package/dist_ts/integrations/nanoleaf/nanoleaf.validation.js +82 -0
  104. package/dist_ts/integrations/onkyo/onkyo.classes.client.d.ts +15 -20
  105. package/dist_ts/integrations/onkyo/onkyo.classes.client.js +161 -404
  106. package/dist_ts/integrations/onkyo/onkyo.classes.configflow.d.ts +9 -4
  107. package/dist_ts/integrations/onkyo/onkyo.classes.configflow.js +51 -6
  108. package/dist_ts/integrations/onkyo/onkyo.classes.integration.d.ts +22 -6
  109. package/dist_ts/integrations/onkyo/onkyo.classes.integration.js +144 -11
  110. package/dist_ts/integrations/onkyo/onkyo.discovery.d.ts +6 -1
  111. package/dist_ts/integrations/onkyo/onkyo.discovery.js +40 -4
  112. package/dist_ts/integrations/onkyo/onkyo.mapper.d.ts +7 -8
  113. package/dist_ts/integrations/onkyo/onkyo.mapper.js +45 -12
  114. package/dist_ts/integrations/onkyo/onkyo.types.d.ts +58 -8
  115. package/dist_ts/integrations/onkyo/onkyo.types.js +13 -102
  116. package/dist_ts/integrations/onkyo/onkyo.validation.d.ts +16 -0
  117. package/dist_ts/integrations/onkyo/onkyo.validation.js +77 -0
  118. package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.client.d.ts +25 -25
  119. package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.client.js +116 -360
  120. package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.d.ts +11 -3
  121. package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.js +76 -6
  122. package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.integration.d.ts +26 -4
  123. package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.integration.js +142 -9
  124. package/dist_ts/integrations/panasonic_viera/panasonic_viera.discovery.d.ts +2 -1
  125. package/dist_ts/integrations/panasonic_viera/panasonic_viera.discovery.js +31 -4
  126. package/dist_ts/integrations/panasonic_viera/panasonic_viera.mapper.d.ts +9 -9
  127. package/dist_ts/integrations/panasonic_viera/panasonic_viera.mapper.js +31 -149
  128. package/dist_ts/integrations/panasonic_viera/panasonic_viera.types.d.ts +51 -11
  129. package/dist_ts/integrations/panasonic_viera/panasonic_viera.types.js +22 -87
  130. package/dist_ts/integrations/panasonic_viera/panasonic_viera.validation.d.ts +6 -0
  131. package/dist_ts/integrations/panasonic_viera/panasonic_viera.validation.js +56 -0
  132. package/dist_ts/integrations/pioneer/pioneer.classes.client.d.ts +31 -16
  133. package/dist_ts/integrations/pioneer/pioneer.classes.client.js +167 -409
  134. package/dist_ts/integrations/pioneer/pioneer.classes.configflow.d.ts +10 -3
  135. package/dist_ts/integrations/pioneer/pioneer.classes.configflow.js +66 -6
  136. package/dist_ts/integrations/pioneer/pioneer.classes.integration.d.ts +13 -5
  137. package/dist_ts/integrations/pioneer/pioneer.classes.integration.js +239 -11
  138. package/dist_ts/integrations/pioneer/pioneer.discovery.d.ts +14 -1
  139. package/dist_ts/integrations/pioneer/pioneer.discovery.js +51 -4
  140. package/dist_ts/integrations/pioneer/pioneer.mapper.d.ts +14 -8
  141. package/dist_ts/integrations/pioneer/pioneer.mapper.js +54 -12
  142. package/dist_ts/integrations/pioneer/pioneer.types.d.ts +79 -12
  143. package/dist_ts/integrations/pioneer/pioneer.types.js +12 -76
  144. package/dist_ts/integrations/pioneer/pioneer.validation.d.ts +10 -0
  145. package/dist_ts/integrations/pioneer/pioneer.validation.js +85 -0
  146. package/dist_ts/integrations/soundtouch/soundtouch.classes.client.d.ts +13 -36
  147. package/dist_ts/integrations/soundtouch/soundtouch.classes.client.js +179 -502
  148. package/dist_ts/integrations/soundtouch/soundtouch.classes.configflow.d.ts +6 -1
  149. package/dist_ts/integrations/soundtouch/soundtouch.classes.configflow.js +50 -40
  150. package/dist_ts/integrations/soundtouch/soundtouch.classes.integration.d.ts +14 -15
  151. package/dist_ts/integrations/soundtouch/soundtouch.classes.integration.js +108 -128
  152. package/dist_ts/integrations/soundtouch/soundtouch.discovery.d.ts +2 -20
  153. package/dist_ts/integrations/soundtouch/soundtouch.discovery.js +23 -113
  154. package/dist_ts/integrations/soundtouch/soundtouch.mapper.d.ts +3 -12
  155. package/dist_ts/integrations/soundtouch/soundtouch.mapper.js +35 -189
  156. package/dist_ts/integrations/soundtouch/soundtouch.types.d.ts +57 -142
  157. package/dist_ts/integrations/soundtouch/soundtouch.types.js +18 -3
  158. package/dist_ts/integrations/soundtouch/soundtouch.validation.d.ts +22 -0
  159. package/dist_ts/integrations/soundtouch/soundtouch.validation.js +65 -0
  160. package/dist_ts/integrations/wiz/index.d.ts +1 -0
  161. package/dist_ts/integrations/wiz/index.js +2 -1
  162. package/dist_ts/integrations/wiz/wiz.classes.client.d.ts +14 -28
  163. package/dist_ts/integrations/wiz/wiz.classes.client.js +93 -229
  164. package/dist_ts/integrations/wiz/wiz.classes.configflow.d.ts +6 -4
  165. package/dist_ts/integrations/wiz/wiz.classes.configflow.js +38 -67
  166. package/dist_ts/integrations/wiz/wiz.classes.integration.d.ts +10 -21
  167. package/dist_ts/integrations/wiz/wiz.classes.integration.js +204 -44
  168. package/dist_ts/integrations/wiz/wiz.discovery.d.ts +10 -31
  169. package/dist_ts/integrations/wiz/wiz.discovery.js +48 -249
  170. package/dist_ts/integrations/wiz/wiz.mapper.d.ts +7 -51
  171. package/dist_ts/integrations/wiz/wiz.mapper.js +82 -668
  172. package/dist_ts/integrations/wiz/wiz.types.d.ts +34 -208
  173. package/dist_ts/integrations/wiz/wiz.validation.d.ts +8 -0
  174. package/dist_ts/integrations/wiz/wiz.validation.js +72 -0
  175. package/dist_ts/integrations/yamaha/index.d.ts +1 -0
  176. package/dist_ts/integrations/yamaha/index.js +2 -1
  177. package/dist_ts/integrations/yamaha/yamaha.classes.client.d.ts +30 -4
  178. package/dist_ts/integrations/yamaha/yamaha.classes.client.js +162 -6
  179. package/dist_ts/integrations/yamaha/yamaha.classes.configflow.d.ts +10 -3
  180. package/dist_ts/integrations/yamaha/yamaha.classes.configflow.js +106 -6
  181. package/dist_ts/integrations/yamaha/yamaha.classes.integration.d.ts +22 -4
  182. package/dist_ts/integrations/yamaha/yamaha.classes.integration.js +243 -8
  183. package/dist_ts/integrations/yamaha/yamaha.discovery.d.ts +13 -1
  184. package/dist_ts/integrations/yamaha/yamaha.discovery.js +57 -4
  185. package/dist_ts/integrations/yamaha/yamaha.mapper.d.ts +16 -8
  186. package/dist_ts/integrations/yamaha/yamaha.mapper.js +58 -12
  187. package/dist_ts/integrations/yamaha/yamaha.types.d.ts +73 -6
  188. package/dist_ts/integrations/yamaha/yamaha.types.js +8 -94
  189. package/dist_ts/integrations/yamaha/yamaha.validation.d.ts +12 -0
  190. package/dist_ts/integrations/yamaha/yamaha.validation.js +89 -0
  191. package/package.json +3 -3
  192. package/readme.md +514 -2
  193. package/ts/00_commitinfo_data.ts +1 -1
  194. package/ts/core/classes.configstore.ts +84 -27
  195. package/ts/core/classes.nativehomeadmission.ts +96 -0
  196. package/ts/core/classes.nativeintegrationowner.ts +297 -0
  197. package/ts/core/index.ts +2 -0
  198. package/ts/core/types.ts +16 -0
  199. package/ts/integrations/braviatv/braviatv.classes.client.ts +245 -709
  200. package/ts/integrations/braviatv/braviatv.classes.configflow.ts +47 -76
  201. package/ts/integrations/braviatv/braviatv.classes.integration.ts +167 -147
  202. package/ts/integrations/braviatv/braviatv.discovery.ts +11 -10
  203. package/ts/integrations/braviatv/braviatv.mapper.ts +39 -142
  204. package/ts/integrations/braviatv/braviatv.types.ts +53 -173
  205. package/ts/integrations/braviatv/braviatv.validation.ts +68 -0
  206. package/ts/integrations/broadlink/broadlink.actions.ts +31 -0
  207. package/ts/integrations/broadlink/broadlink.classes.client.ts +170 -144
  208. package/ts/integrations/broadlink/broadlink.classes.configflow.ts +59 -89
  209. package/ts/integrations/broadlink/broadlink.classes.integration.ts +110 -64
  210. package/ts/integrations/broadlink/broadlink.mapper.ts +5 -4
  211. package/ts/integrations/broadlink/broadlink.selected.mapper.ts +46 -0
  212. package/ts/integrations/broadlink/broadlink.types.ts +48 -8
  213. package/ts/integrations/broadlink/broadlink.validation.ts +66 -0
  214. package/ts/integrations/daikin/daikin.classes.client.ts +52 -661
  215. package/ts/integrations/daikin/daikin.classes.configflow.ts +72 -152
  216. package/ts/integrations/daikin/daikin.classes.integration.ts +166 -71
  217. package/ts/integrations/daikin/daikin.discovery.ts +94 -346
  218. package/ts/integrations/daikin/daikin.mapper.ts +83 -166
  219. package/ts/integrations/daikin/daikin.types.ts +27 -48
  220. package/ts/integrations/daikin/daikin.validation.ts +51 -0
  221. package/ts/integrations/daikin/readme.md +83 -0
  222. package/ts/integrations/denonavr/denonavr.classes.client.ts +132 -421
  223. package/ts/integrations/denonavr/denonavr.classes.configflow.ts +44 -37
  224. package/ts/integrations/denonavr/denonavr.classes.integration.ts +171 -117
  225. package/ts/integrations/denonavr/denonavr.discovery.ts +67 -220
  226. package/ts/integrations/denonavr/denonavr.mapper.ts +36 -182
  227. package/ts/integrations/denonavr/denonavr.types.ts +48 -111
  228. package/ts/integrations/denonavr/denonavr.validation.ts +87 -0
  229. package/ts/integrations/kodi/kodi.classes.client.ts +72 -352
  230. package/ts/integrations/kodi/kodi.classes.configflow.ts +52 -55
  231. package/ts/integrations/kodi/kodi.classes.integration.ts +97 -178
  232. package/ts/integrations/kodi/kodi.discovery.ts +49 -28
  233. package/ts/integrations/kodi/kodi.mapper.ts +52 -10
  234. package/ts/integrations/kodi/kodi.types.ts +33 -58
  235. package/ts/integrations/kodi/kodi.validation.ts +60 -0
  236. package/ts/integrations/modbus/modbus.classes.client.ts +100 -842
  237. package/ts/integrations/modbus/modbus.classes.configflow.ts +70 -41
  238. package/ts/integrations/modbus/modbus.classes.integration.ts +115 -60
  239. package/ts/integrations/modbus/modbus.discovery.ts +38 -106
  240. package/ts/integrations/modbus/modbus.mapper.ts +64 -467
  241. package/ts/integrations/modbus/modbus.types.ts +64 -381
  242. package/ts/integrations/modbus/modbus.validation.ts +119 -0
  243. package/ts/integrations/nanoleaf/nanoleaf.classes.client.ts +115 -249
  244. package/ts/integrations/nanoleaf/nanoleaf.classes.configflow.ts +90 -19
  245. package/ts/integrations/nanoleaf/nanoleaf.classes.integration.ts +130 -181
  246. package/ts/integrations/nanoleaf/nanoleaf.credentials.ts +39 -0
  247. package/ts/integrations/nanoleaf/nanoleaf.mapper.ts +47 -260
  248. package/ts/integrations/nanoleaf/nanoleaf.types.ts +29 -147
  249. package/ts/integrations/nanoleaf/nanoleaf.validation.ts +77 -0
  250. package/ts/integrations/onkyo/onkyo.classes.client.ts +148 -457
  251. package/ts/integrations/onkyo/onkyo.classes.configflow.ts +52 -6
  252. package/ts/integrations/onkyo/onkyo.classes.integration.ts +121 -12
  253. package/ts/integrations/onkyo/onkyo.discovery.ts +41 -3
  254. package/ts/integrations/onkyo/onkyo.mapper.ts +46 -19
  255. package/ts/integrations/onkyo/onkyo.types.ts +55 -110
  256. package/ts/integrations/onkyo/onkyo.validation.ts +85 -0
  257. package/ts/integrations/onkyo/readme.md +126 -0
  258. package/ts/integrations/panasonic_viera/panasonic_viera.classes.client.ts +101 -398
  259. package/ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.ts +69 -5
  260. package/ts/integrations/panasonic_viera/panasonic_viera.classes.integration.ts +141 -9
  261. package/ts/integrations/panasonic_viera/panasonic_viera.discovery.ts +32 -3
  262. package/ts/integrations/panasonic_viera/panasonic_viera.mapper.ts +34 -161
  263. package/ts/integrations/panasonic_viera/panasonic_viera.types.ts +52 -96
  264. package/ts/integrations/panasonic_viera/panasonic_viera.validation.ts +56 -0
  265. package/ts/integrations/pioneer/pioneer.classes.client.ts +132 -428
  266. package/ts/integrations/pioneer/pioneer.classes.configflow.ts +67 -5
  267. package/ts/integrations/pioneer/pioneer.classes.integration.ts +225 -11
  268. package/ts/integrations/pioneer/pioneer.discovery.ts +45 -3
  269. package/ts/integrations/pioneer/pioneer.mapper.ts +57 -13
  270. package/ts/integrations/pioneer/pioneer.types.ts +87 -85
  271. package/ts/integrations/pioneer/pioneer.validation.ts +84 -0
  272. package/ts/integrations/soundtouch/soundtouch.classes.client.ts +152 -571
  273. package/ts/integrations/soundtouch/soundtouch.classes.configflow.ts +47 -42
  274. package/ts/integrations/soundtouch/soundtouch.classes.integration.ts +95 -150
  275. package/ts/integrations/soundtouch/soundtouch.discovery.ts +21 -133
  276. package/ts/integrations/soundtouch/soundtouch.mapper.ts +36 -207
  277. package/ts/integrations/soundtouch/soundtouch.types.ts +50 -195
  278. package/ts/integrations/soundtouch/soundtouch.validation.ts +60 -0
  279. package/ts/integrations/wiz/index.ts +1 -0
  280. package/ts/integrations/wiz/wiz.classes.client.ts +84 -254
  281. package/ts/integrations/wiz/wiz.classes.configflow.ts +39 -70
  282. package/ts/integrations/wiz/wiz.classes.integration.ts +189 -45
  283. package/ts/integrations/wiz/wiz.discovery.ts +48 -291
  284. package/ts/integrations/wiz/wiz.mapper.ts +81 -754
  285. package/ts/integrations/wiz/wiz.types.ts +24 -224
  286. package/ts/integrations/wiz/wiz.validation.ts +73 -0
  287. package/ts/integrations/yamaha/index.ts +1 -0
  288. package/ts/integrations/yamaha/yamaha.classes.client.ts +150 -6
  289. package/ts/integrations/yamaha/yamaha.classes.configflow.ts +100 -5
  290. package/ts/integrations/yamaha/yamaha.classes.integration.ts +236 -8
  291. package/ts/integrations/yamaha/yamaha.discovery.ts +52 -3
  292. package/ts/integrations/yamaha/yamaha.mapper.ts +65 -13
  293. package/ts/integrations/yamaha/yamaha.types.ts +77 -97
  294. package/ts/integrations/yamaha/yamaha.validation.ts +86 -0
@@ -1,581 +1,162 @@
1
- import type {
2
- ISoundtouchCommandRequest,
3
- ISoundtouchConfig,
4
- ISoundtouchContentItem,
5
- ISoundtouchDeviceInfo,
6
- ISoundtouchPreset,
7
- ISoundtouchRawCommandRequest,
8
- ISoundtouchSnapshot,
9
- ISoundtouchStatus,
10
- ISoundtouchVolume,
11
- ISoundtouchZoneMember,
12
- ISoundtouchZoneStatus,
13
- TSoundtouchCommand,
14
- } from './soundtouch.types.js';
15
- import { soundtouchDefaultDlnaPort, soundtouchDefaultPort, soundtouchDefaultSourceList } from './soundtouch.types.js';
16
-
17
- const keyCommands: Record<Exclude<TSoundtouchCommand, 'turn_on' | 'turn_off' | 'set_volume' | 'mute' | 'select_source' | 'play_media' | 'play_preset'>, string> = {
18
- play: 'PLAY',
19
- pause: 'PAUSE',
20
- play_pause: 'PLAY_PAUSE',
21
- previous_track: 'PREV_TRACK',
22
- next_track: 'NEXT_TRACK',
23
- volume_up: 'VOLUME_UP',
24
- volume_down: 'VOLUME_DOWN',
25
- };
1
+ import * as plugins from '../../plugins.js';
2
+ import { NativeHomeAdmission } from '../../core/classes.nativehomeadmission.js';
3
+ import type { ISoundtouchConfig, ISoundtouchSetupContext, ISoundtouchSnapshot, TSoundtouchClientEvent } from './soundtouch.types.js';
4
+ import { normalizeSoundtouchConfig, soundtouchBoolean, soundtouchInteger } from './soundtouch.validation.js';
5
+
6
+ interface ISoundtouchSubscriber {
7
+ handler: (eventArg: TSoundtouchClientEvent) => void;
8
+ active: boolean;
9
+ failed: boolean;
10
+ failure?: unknown;
11
+ detach: () => void;
12
+ unsubscribe: () => void;
13
+ }
26
14
 
15
+ /** Owns one selected published protocol and a bounded logical operation lifetime. */
27
16
  export class SoundtouchClient {
28
- constructor(private readonly config: ISoundtouchConfig) {}
29
-
30
- public async getSnapshot(): Promise<ISoundtouchSnapshot> {
31
- if (this.config.snapshot) {
32
- return this.normalizeSnapshot(this.cloneValue(this.config.snapshot));
33
- }
34
- if (!this.config.host && !this.config.commandExecutor) {
35
- return this.normalizeSnapshot(this.manualSnapshot(false, 'SoundTouch refresh requires config.host, config.snapshot, or commandExecutor.'));
36
- }
37
-
38
- const infoXml = await this.fetchText('/info');
39
- const deviceInfo = this.parseDeviceInfo(infoXml);
40
- const [status, volume, presets, zone] = await Promise.all([
41
- this.optionalText('/now_playing').then((xmlArg) => xmlArg ? this.parseStatus(xmlArg) : undefined),
42
- this.optionalText('/volume').then((xmlArg) => xmlArg ? this.parseVolume(xmlArg) : undefined),
43
- this.optionalText('/presets').then((xmlArg) => xmlArg ? this.parsePresets(xmlArg) : []),
44
- this.optionalText('/getZone').then((xmlArg) => xmlArg ? this.parseZoneStatus(xmlArg) : undefined),
45
- ]);
46
-
47
- return this.normalizeSnapshot({
48
- config: deviceInfo,
49
- status,
50
- volume,
51
- presets,
52
- zone,
53
- sourceList: this.config.sourceList,
54
- available: true,
55
- lastUpdated: new Date().toISOString(),
17
+ readonly #config: ReturnType<typeof normalizeSoundtouchConfig>;
18
+ readonly #protocol: plugins.ecobridgeDeviceManager.BoseSoundTouchProtocol;
19
+ readonly #lifetime = new AbortController();
20
+ readonly #operations = new Set<Promise<unknown>>();
21
+ readonly #subscribers = new Set<ISoundtouchSubscriber>();
22
+ readonly #parentSignal?: AbortSignal;
23
+ readonly #onParentAbort: () => void;
24
+ #tail: Promise<void> = Promise.resolve();
25
+ #healthy = false;
26
+ #closed = false;
27
+ #closePromise?: Promise<void>;
28
+ #retryPromise?: Promise<void>;
29
+ #protocolClosed = false;
30
+
31
+ public constructor(configArg: ISoundtouchConfig, contextArg: ISoundtouchSetupContext) {
32
+ this.#config = normalizeSoundtouchConfig(configArg);
33
+ const context = new NativeHomeAdmission('soundtouch').admit(contextArg, this.#config.homeId);
34
+ const signalArg = context.abortSignal;
35
+ this.#protocol = new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocol({
36
+ address: this.#config.host, port: this.#config.port, expectedDeviceId: this.#config.deviceId, expectedType: this.#config.type,
37
+ allowPlaintext: true, timeoutMs: this.#config.requestTimeoutMs, maxPendingOperations: this.#config.maxPendingOperations,
38
+ maxResponseBytes: this.#config.maxResponseBytes,
39
+ controlPolicy: { maximumVolume: this.#config.volumeLimit },
56
40
  });
57
- }
58
-
59
- public async validateConnection(): Promise<ISoundtouchSnapshot> {
60
- const snapshot = await this.getSnapshot();
61
- if (!snapshot.config.deviceId && !snapshot.config.name) {
62
- throw new Error('SoundTouch device did not provide a device identifier.');
63
- }
64
- return snapshot;
65
- }
66
-
67
- public async execute(requestArg: ISoundtouchCommandRequest): Promise<unknown> {
68
- this.ensureCommandTransport();
69
-
70
- if (requestArg.command === 'turn_on') {
71
- return this.setPower(true);
72
- }
73
- if (requestArg.command === 'turn_off') {
74
- return this.setPower(false);
75
- }
76
- if (requestArg.command in keyCommands) {
77
- return this.sendKey(keyCommands[requestArg.command as keyof typeof keyCommands], requestArg.command);
78
- }
79
- if (requestArg.command === 'set_volume') {
80
- return this.setVolume(requestArg);
81
- }
82
- if (requestArg.command === 'mute') {
83
- return this.setMute(requestArg.muted);
84
- }
85
- if (requestArg.command === 'select_source') {
86
- return this.selectSource(requestArg.source);
87
- }
88
- if (requestArg.command === 'play_preset') {
89
- return this.playPreset(requestArg);
90
- }
91
- if (requestArg.command === 'play_media') {
92
- return this.playMedia(requestArg);
93
- }
94
-
95
- throw new Error(`Unsupported SoundTouch command: ${requestArg.command}`);
96
- }
97
-
98
- public async destroy(): Promise<void> {}
99
-
100
- private async setPower(poweredArg: boolean): Promise<unknown> {
101
- const snapshot = await this.getSnapshot().catch(() => undefined);
102
- const isStandby = snapshot?.status?.source === 'STANDBY';
103
- if (poweredArg && snapshot?.status && !isStandby) {
104
- return { skipped: true, reason: 'SoundTouch device is already on.' };
105
- }
106
- if (!poweredArg && isStandby) {
107
- return { skipped: true, reason: 'SoundTouch device is already off.' };
108
- }
109
- return this.sendKey('POWER', poweredArg ? 'turn_on' : 'turn_off');
110
- }
111
-
112
- private async setVolume(requestArg: ISoundtouchCommandRequest): Promise<string> {
113
- const volume = this.volumePercent(requestArg);
114
- return this.requestText('POST', '/volume', 'set_volume', `<volume>${volume}</volume>`, { 'content-type': 'text/xml; charset="utf-8"' });
115
- }
116
-
117
- private async setMute(mutedArg: boolean | undefined): Promise<unknown> {
118
- if (typeof mutedArg === 'boolean') {
119
- const snapshot = await this.getSnapshot().catch(() => undefined);
120
- if (typeof snapshot?.volume?.muted === 'boolean' && snapshot.volume.muted === mutedArg) {
121
- return { skipped: true, reason: `SoundTouch mute is already ${mutedArg ? 'on' : 'off'}.` };
122
- }
123
- }
124
- return this.sendKey('MUTE', 'mute');
125
- }
126
-
127
- private async selectSource(sourceArg: string | undefined): Promise<string> {
128
- const source = sourceArg?.toUpperCase();
129
- if (source !== 'AUX' && source !== 'BLUETOOTH') {
130
- throw new Error('SoundTouch select_source supports AUX or BLUETOOTH.');
131
- }
132
- return this.sendSelectXml(this.contentItemXml({ source, sourceAccount: source === 'AUX' ? 'AUX' : undefined }), 'select_source');
133
- }
134
-
135
- private async playPreset(requestArg: ISoundtouchCommandRequest): Promise<string> {
136
- const preset = requestArg.contentItem ? { presetId: requestArg.presetId || '', ...requestArg.contentItem } : await this.findPreset(requestArg);
137
- const body = preset.sourceXml || this.contentItemXml(preset);
138
- return this.sendSelectXml(body, 'play_preset');
139
- }
140
-
141
- private async playMedia(requestArg: ISoundtouchCommandRequest): Promise<unknown> {
142
- const mediaId = requestArg.url || requestArg.mediaId;
143
- if (mediaId?.toLowerCase().startsWith('http://')) {
144
- return this.playUrl(mediaId);
145
- }
146
- if (mediaId?.toLowerCase().startsWith('https://')) {
147
- throw new Error('SoundTouch play_media supports only http:// URLs for DLNA playback.');
148
- }
149
- return this.playPreset({ ...requestArg, presetId: requestArg.presetId || mediaId });
150
- }
151
-
152
- private async playUrl(urlArg: string): Promise<string> {
153
- const body = `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body><u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1"><InstanceID>0</InstanceID><CurrentURIMetaData></CurrentURIMetaData><CurrentURI>${escapeXml(urlArg)}</CurrentURI></u:SetAVTransportURI></s:Body></s:Envelope>`;
154
- return this.requestText('POST', '/AVTransport/Control', 'play_media', body, {
155
- 'user-agent': 'smarthome.exchange',
156
- accept: '*/*',
157
- 'content-type': 'text/xml; charset="utf-8"',
158
- host: this.config.host ? `${this.config.host}:${this.dlnaPort()}` : `:${this.dlnaPort()}`,
159
- soapaction: 'urn:schemas-upnp-org:service:AVTransport:1#SetAVTransportURI',
160
- }, this.dlnaPort());
161
- }
162
-
163
- private async sendKey(keyArg: string, commandArg: TSoundtouchCommand): Promise<string[]> {
164
- const body = (stateArg: 'press' | 'release') => `<key state="${stateArg}" sender="Gabbo">${keyArg}</key>`;
165
- return [
166
- await this.requestText('POST', '/key', commandArg, body('press'), { 'content-type': 'text/xml; charset="utf-8"' }),
167
- await this.requestText('POST', '/key', commandArg, body('release'), { 'content-type': 'text/xml; charset="utf-8"' }),
168
- ];
169
- }
170
-
171
- private async sendSelectXml(bodyArg: string, commandArg: TSoundtouchCommand): Promise<string> {
172
- return this.requestText('POST', '/select', commandArg, bodyArg, { 'content-type': 'text/xml; charset="utf-8"' });
173
- }
174
-
175
- private async findPreset(requestArg: ISoundtouchCommandRequest): Promise<ISoundtouchPreset> {
176
- const snapshot = await this.getSnapshot();
177
- const preset = (snapshot.presets || []).find((presetArg) => {
178
- return Boolean(
179
- requestArg.presetId && String(presetArg.presetId) === String(requestArg.presetId)
180
- || requestArg.presetId && presetArg.name === requestArg.presetId
181
- || requestArg.presetName && presetArg.name === requestArg.presetName
182
- );
183
- });
184
- if (!preset) {
185
- throw new Error('SoundTouch play_preset requires a known preset id or name.');
186
- }
187
- return preset;
188
- }
189
-
190
- private contentItemXml(itemArg: ISoundtouchContentItem): string {
191
- if (!itemArg.source) {
192
- throw new Error('SoundTouch ContentItem requires source.');
193
- }
194
- const attributes: Record<string, string | undefined> = {
195
- source: itemArg.source,
196
- type: itemArg.type,
197
- location: itemArg.location,
198
- sourceAccount: itemArg.sourceAccount,
199
- isPresetable: typeof itemArg.isPresetable === 'boolean' ? String(itemArg.isPresetable) : undefined,
41
+ this.#parentSignal = signalArg;
42
+ this.#onParentAbort = () => {
43
+ // The owner receives the retained terminal close promise, including cleanup failures.
44
+ void this.destroy().catch(() => {});
200
45
  };
201
- const attrs = Object.entries(attributes)
202
- .filter((entryArg): entryArg is [string, string] => typeof entryArg[1] === 'string' && entryArg[1] !== '')
203
- .map(([keyArg, valueArg]) => `${keyArg}="${escapeXml(valueArg)}"`)
204
- .join(' ');
205
- const itemName = itemArg.name ? `<itemName>${escapeXml(itemArg.name)}</itemName>` : '';
206
- return `<ContentItem ${attrs}>${itemName}</ContentItem>`;
207
- }
208
-
209
- private async fetchText(pathArg: string): Promise<string> {
210
- return this.requestText('GET', pathArg, 'refresh');
211
- }
212
-
213
- private async optionalText(pathArg: string): Promise<string | undefined> {
214
- try {
215
- return await this.fetchText(pathArg);
216
- } catch {
217
- return undefined;
218
- }
219
- }
220
-
221
- private async requestText(methodArg: 'GET' | 'POST', pathArg: string, commandArg: TSoundtouchCommand | 'refresh', bodyArg?: string, headersArg?: Record<string, string>, portArg = this.port()): Promise<string> {
222
- const rawRequest = this.rawRequest(methodArg, pathArg, commandArg, bodyArg, headersArg, portArg);
223
- if (this.config.commandExecutor) {
224
- return this.executorResultToText(await this.config.commandExecutor.execute(rawRequest));
225
- }
226
- if (!rawRequest.host) {
227
- throw new Error('SoundTouch command transport requires config.host or commandExecutor.');
228
- }
229
-
230
- const controller = this.config.timeoutMs ? new AbortController() : undefined;
231
- const timeout = controller ? globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs) : undefined;
232
- try {
233
- const response = await globalThis.fetch(rawRequest.url, {
234
- method: rawRequest.method,
235
- headers: rawRequest.headers,
236
- body: rawRequest.body,
237
- signal: controller?.signal,
46
+ signalArg?.addEventListener('abort', this.#onParentAbort, { once: true });
47
+ if (signalArg?.aborted) this.#onParentAbort();
48
+ }
49
+ public isHealthy(): boolean { return this.#healthy && !this.#closed && !this.#protocol.isClosed; }
50
+ public getSnapshot(signalArg?: AbortSignal): Promise<ISoundtouchSnapshot> { return this.#run((signal) => this.#inspect(signal), signalArg); }
51
+ public setVolume(valueArg: number, signalArg?: AbortSignal): Promise<plugins.ecobridgeDeviceManager.IBoseSoundTouchControlResult> {
52
+ const volume = soundtouchInteger(valueArg, 0, this.#config.volumeLimit, 'absolute volume');
53
+ return this.#run((signal) => this.#control({ volume }, signal), signalArg);
54
+ }
55
+ public setMuted(valueArg: boolean, signalArg?: AbortSignal): Promise<plugins.ecobridgeDeviceManager.IBoseSoundTouchControlResult> {
56
+ const muted = soundtouchBoolean(valueArg);
57
+ return this.#run((signal) => this.#control({ muted }, signal), signalArg);
58
+ }
59
+ public writeFeature(idArg: string, valueArg: plugins.shxInterfaces.data.TDeviceStateValue, signalArg?: AbortSignal): Promise<plugins.ecobridgeDeviceManager.IBoseSoundTouchControlResult> {
60
+ if (idArg === 'volume') return this.setVolume(soundtouchInteger(valueArg, 0, this.#config.volumeLimit, 'absolute volume'), signalArg);
61
+ if (idArg === 'mute') return this.setMuted(soundtouchBoolean(valueArg), signalArg);
62
+ throw new TypeError('SoundTouch feature is not writable.');
63
+ }
64
+ public subscribe(handlerArg: (eventArg: TSoundtouchClientEvent) => void, signalArg?: AbortSignal): () => void {
65
+ if (this.#closed || this.#protocol.isClosed) throw new Error('SoundTouch client is closed.');
66
+ if (typeof handlerArg !== 'function') throw new TypeError('SoundTouch subscriber must be a function.');
67
+ if (signalArg !== undefined && !(signalArg instanceof AbortSignal)) throw new TypeError('SoundTouch subscription signal must be an AbortSignal.');
68
+ signalArg?.throwIfAborted();
69
+ if (this.#subscribers.size >= 128) throw new Error('SoundTouch subscription capacity is full.');
70
+ const aborted = (): void => { subscriber.detach(); if (!subscriber.failed) this.#subscribers.delete(subscriber); };
71
+ const subscriber: ISoundtouchSubscriber = { handler: handlerArg, active: true, failed: false,
72
+ detach: () => { subscriber.active = false; signalArg?.removeEventListener('abort', aborted); },
73
+ unsubscribe: () => {
74
+ subscriber.detach();
75
+ if (subscriber.failed) throw subscriber.failure;
76
+ this.#subscribers.delete(subscriber);
77
+ } };
78
+ this.#subscribers.add(subscriber); signalArg?.addEventListener('abort', aborted, { once: true });
79
+ if (signalArg?.aborted) aborted();
80
+ return subscriber.unsubscribe;
81
+ }
82
+ public destroy(): Promise<void> {
83
+ if (this.#closePromise) return this.#closePromise;
84
+ this.#closed = true; this.#healthy = false;
85
+ this.#closePromise = Promise.resolve().then(() => this.#drain());
86
+ this.#parentSignal?.removeEventListener('abort', this.#onParentAbort);
87
+ for (const subscriber of this.#subscribers) subscriber.detach();
88
+ this.#lifetime.abort(new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError('SoundTouch client is destroyed.', 'closed'));
89
+ return this.#closePromise;
90
+ }
91
+ /** Explicit cleanup retry only; never repeats an HTTP control or observation. */
92
+ public retryCleanup(): Promise<void> {
93
+ if (!this.#closed) return Promise.reject(new Error('SoundTouch cleanup retry requires terminal close.'));
94
+ if (!this.#retryPromise) {
95
+ this.#retryPromise = Promise.resolve().then(async () => {
96
+ await Promise.allSettled([this.#closePromise]);
97
+ // A caller explicitly acknowledges retained subscriber failures by requesting cleanup retry.
98
+ for (const subscriber of this.#subscribers) { subscriber.detach(); this.#subscribers.delete(subscriber); }
99
+ await this.#drain();
238
100
  });
239
- const text = await response.text();
240
- if (!response.ok) {
241
- throw new Error(`SoundTouch request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
242
- }
243
- return text;
244
- } finally {
245
- if (timeout) {
246
- globalThis.clearTimeout(timeout);
247
- }
248
- }
249
- }
250
-
251
- private rawRequest(methodArg: 'GET' | 'POST', pathArg: string, commandArg: TSoundtouchCommand | 'refresh', bodyArg: string | undefined, headersArg: Record<string, string> | undefined, portArg: number): ISoundtouchRawCommandRequest {
252
- const path = pathArg.startsWith('/') ? pathArg : `/${pathArg}`;
253
- return {
254
- command: commandArg,
255
- method: methodArg,
256
- path,
257
- url: `${this.baseUrl(portArg)}${path}`,
258
- host: this.config.host,
259
- port: portArg,
260
- body: bodyArg,
261
- headers: headersArg,
262
- };
263
- }
264
-
265
- private parseDeviceInfo(xmlArg: string): ISoundtouchDeviceInfo {
266
- const infoElement = readFirstElement(xmlArg, 'info') || xmlArg;
267
- const infoAttrs = elementAttributes(infoElement, 'info');
268
- return {
269
- deviceId: infoAttrs.deviceID || this.config.deviceId,
270
- name: readTagValue(infoElement, 'name') || this.config.name,
271
- type: readTagValue(infoElement, 'type') || this.config.model,
272
- accountUuid: readTagValue(infoElement, 'margeAccountUUID'),
273
- moduleType: readTagValue(infoElement, 'moduleType'),
274
- variant: readTagValue(infoElement, 'variant'),
275
- variantMode: readTagValue(infoElement, 'variantMode'),
276
- countryCode: readTagValue(infoElement, 'countryCode'),
277
- regionCode: readTagValue(infoElement, 'regionCode'),
278
- host: this.config.host,
279
- port: this.port(),
280
- networks: readAllElements(infoElement, 'networkInfo').map((networkArg) => ({
281
- type: elementAttributes(networkArg, 'networkInfo').type,
282
- macAddress: readTagValue(networkArg, 'macAddress'),
283
- ipAddress: readTagValue(networkArg, 'ipAddress'),
284
- })),
285
- components: readAllElements(infoElement, 'component').map((componentArg) => ({
286
- category: readTagValue(componentArg, 'componentCategory'),
287
- softwareVersion: readTagValue(componentArg, 'softwareVersion'),
288
- serialNumber: readTagValue(componentArg, 'serialNumber'),
289
- })),
290
- };
291
- }
292
-
293
- private parseStatus(xmlArg: string): ISoundtouchStatus {
294
- const statusElement = readFirstElement(xmlArg, 'nowPlaying') || xmlArg;
295
- const statusAttrs = elementAttributes(statusElement, 'nowPlaying');
296
- const contentElement = readFirstElement(statusElement, 'ContentItem');
297
- const artElement = readFirstElement(statusElement, 'art');
298
- const artAttrs = artElement ? elementAttributes(artElement, 'art') : {};
299
- const timeElement = readFirstElement(statusElement, 'time');
300
- const timeAttrs = timeElement ? elementAttributes(timeElement, 'time') : {};
301
- return {
302
- source: statusAttrs.source,
303
- contentItem: contentElement ? this.parseContentItem(contentElement) : undefined,
304
- track: readTagValue(statusElement, 'track'),
305
- artist: readTagValue(statusElement, 'artist'),
306
- album: readTagValue(statusElement, 'album'),
307
- image: artAttrs.artImageStatus === 'IMAGE_PRESENT' ? readTagValue(statusElement, 'art') : undefined,
308
- duration: numberValue(timeAttrs.total),
309
- position: numberValue(timeElement ? innerText(timeElement, 'time') : undefined),
310
- playStatus: readTagValue(statusElement, 'playStatus'),
311
- shuffleSetting: readTagValue(statusElement, 'shuffleSetting'),
312
- repeatSetting: readTagValue(statusElement, 'repeatSetting'),
313
- streamType: readTagValue(statusElement, 'streamType'),
314
- trackId: readTagValue(statusElement, 'trackID'),
315
- stationName: readTagValue(statusElement, 'stationName'),
316
- description: readTagValue(statusElement, 'description'),
317
- stationLocation: readTagValue(statusElement, 'stationLocation'),
318
- };
319
- }
320
-
321
- private parseContentItem(xmlArg: string): ISoundtouchContentItem {
322
- const attrs = elementAttributes(xmlArg, 'ContentItem');
323
- return {
324
- name: readTagValue(xmlArg, 'itemName'),
325
- source: attrs.source,
326
- type: attrs.type,
327
- location: attrs.location,
328
- sourceAccount: attrs.sourceAccount,
329
- isPresetable: booleanValue(attrs.isPresetable),
330
- };
331
- }
332
-
333
- private parseVolume(xmlArg: string): ISoundtouchVolume {
334
- return {
335
- actual: numberValue(readTagValue(xmlArg, 'actualvolume')),
336
- target: numberValue(readTagValue(xmlArg, 'targetvolume')),
337
- muted: booleanValue(readTagValue(xmlArg, 'muteenabled')),
338
- };
339
- }
340
-
341
- private parsePresets(xmlArg: string): ISoundtouchPreset[] {
342
- return readAllElements(xmlArg, 'preset').map((presetArg) => {
343
- const attrs = elementAttributes(presetArg, 'preset');
344
- const contentElement = readFirstElement(presetArg, 'ContentItem');
345
- const content = contentElement ? this.parseContentItem(contentElement) : {};
346
- return {
347
- ...content,
348
- presetId: attrs.id || readTagValue(presetArg, 'id') || '',
349
- sourceXml: contentElement,
350
- };
351
- }).filter((presetArg) => presetArg.presetId);
352
- }
353
-
354
- private parseZoneStatus(xmlArg: string): ISoundtouchZoneStatus | undefined {
355
- const zoneElement = readFirstElement(xmlArg, 'zone') || xmlArg;
356
- const members = readAllElements(zoneElement, 'member');
357
- if (!members.length) {
358
- return undefined;
359
- }
360
- const attrs = elementAttributes(zoneElement, 'zone');
361
- return {
362
- masterId: attrs.master,
363
- masterIp: attrs.senderIPAddress,
364
- isMaster: attrs.master && this.config.deviceId ? attrs.master === this.config.deviceId : attrs.senderIPAddress === undefined,
365
- slaves: members.map((memberArg): ISoundtouchZoneMember => {
366
- const memberAttrs = elementAttributes(memberArg, 'member');
367
- return {
368
- deviceId: stripTags(innerXml(memberArg, 'member')).trim() || undefined,
369
- ipAddress: memberAttrs.ipaddress,
370
- role: memberAttrs.role,
371
- };
372
- }),
373
- };
374
- }
375
-
376
- private normalizeSnapshot(snapshotArg: ISoundtouchSnapshot): ISoundtouchSnapshot {
377
- const config = {
378
- ...snapshotArg.config,
379
- deviceId: snapshotArg.config.deviceId || this.config.deviceId || this.config.macAddress || this.config.host,
380
- name: snapshotArg.config.name || this.config.name || this.config.host || 'Bose SoundTouch',
381
- type: snapshotArg.config.type || this.config.model || 'SoundTouch',
382
- host: snapshotArg.config.host || this.config.host,
383
- port: snapshotArg.config.port || this.config.port || (this.config.host ? soundtouchDefaultPort : undefined),
384
- networks: snapshotArg.config.networks?.length ? snapshotArg.config.networks : this.config.macAddress || this.config.host ? [{ macAddress: this.config.macAddress, ipAddress: this.config.host }] : [],
385
- };
386
- const zone = snapshotArg.zone ? {
387
- ...snapshotArg.zone,
388
- isMaster: snapshotArg.zone.masterId && config.deviceId ? snapshotArg.zone.masterId === config.deviceId : snapshotArg.zone.isMaster,
389
- slaves: snapshotArg.zone.slaves || [],
390
- } : undefined;
391
- return {
392
- ...snapshotArg,
393
- config,
394
- presets: snapshotArg.presets || [],
395
- zone,
396
- sourceList: snapshotArg.sourceList?.length ? snapshotArg.sourceList : [...soundtouchDefaultSourceList],
397
- available: snapshotArg.available ?? Boolean(this.config.host || this.config.commandExecutor),
398
- lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(),
399
- };
400
- }
401
-
402
- private manualSnapshot(availableArg: boolean, errorArg?: string): ISoundtouchSnapshot {
403
- return {
404
- config: {
405
- deviceId: this.config.deviceId || this.config.macAddress || this.config.host,
406
- name: this.config.name || this.config.host || 'Bose SoundTouch',
407
- type: this.config.model || 'SoundTouch',
408
- host: this.config.host,
409
- port: this.config.port || (this.config.host ? soundtouchDefaultPort : undefined),
410
- networks: this.config.macAddress || this.config.host ? [{ macAddress: this.config.macAddress, ipAddress: this.config.host }] : [],
411
- },
412
- presets: [],
413
- sourceList: this.config.sourceList || [...soundtouchDefaultSourceList],
414
- available: availableArg,
415
- lastUpdated: new Date().toISOString(),
416
- error: errorArg,
417
- };
418
- }
419
-
420
- private ensureCommandTransport(): void {
421
- if (!this.config.host && !this.config.commandExecutor) {
422
- throw new Error('SoundTouch command transport requires config.host or commandExecutor. Static snapshots are read-only.');
423
- }
424
- }
425
-
426
- private async executorResultToText(resultArg: unknown): Promise<string> {
427
- if (typeof resultArg === 'string') {
428
- return resultArg;
429
- }
430
- if (isFetchResponse(resultArg)) {
431
- return resultArg.text();
432
- }
433
- if (isRecord(resultArg) && typeof resultArg.body === 'string') {
434
- return resultArg.body;
435
- }
436
- if (isRecord(resultArg) && typeof resultArg.text === 'string') {
437
- return resultArg.text;
438
- }
439
- return resultArg === undefined ? '' : JSON.stringify(resultArg);
440
- }
441
-
442
- private volumePercent(requestArg: ISoundtouchCommandRequest): number {
443
- const value = requestArg.volume ?? (typeof requestArg.volumeLevel === 'number' ? requestArg.volumeLevel <= 1 ? requestArg.volumeLevel * 100 : requestArg.volumeLevel : undefined);
444
- if (typeof value !== 'number' || !Number.isFinite(value)) {
445
- throw new Error('SoundTouch volume command requires volumeLevel or volume.');
101
+ const settled = (): void => { this.#retryPromise = undefined; };
102
+ void this.#retryPromise.then(settled, settled);
446
103
  }
447
- return Math.max(0, Math.min(100, Math.round(value)));
104
+ return this.#retryPromise;
448
105
  }
449
-
450
- private port(): number {
451
- return this.config.port || soundtouchDefaultPort;
452
- }
453
-
454
- private dlnaPort(): number {
455
- return this.config.dlnaPort || soundtouchDefaultDlnaPort;
456
- }
457
-
458
- private baseUrl(portArg: number): string {
459
- const rawHost = this.config.host || 'localhost';
460
- if (/^https?:\/\//i.test(rawHost)) {
461
- const url = new URL(rawHost);
462
- if (!url.port) {
463
- url.port = String(portArg);
464
- }
465
- return url.origin;
466
- }
467
- const host = rawHost.replace(/\/.*$/, '');
468
- const hostPort = /^([^:]+):(\d+)$/.exec(host);
469
- if (hostPort) {
470
- return `http://${hostPort[1]}:${hostPort[2]}`;
106
+ async #drain(): Promise<void> {
107
+ const close = Promise.resolve().then(async () => {
108
+ if (!this.#protocolClosed) { await this.#protocol.close(); this.#protocolClosed = true; }
109
+ });
110
+ const [closed] = await Promise.allSettled([close, ...this.#operations]);
111
+ const errors: unknown[] = closed.status === 'rejected' ? [closed.reason as unknown] : [];
112
+ for (const subscriber of this.#subscribers) {
113
+ if (subscriber.failed) errors.push(subscriber.failure);
114
+ else this.#subscribers.delete(subscriber);
115
+ }
116
+ if (errors.length) throw new AggregateError(errors, 'SoundTouch cleanup or subscriber failed; explicit cleanup retry is required.');
117
+ }
118
+ async #inspect(signalArg: AbortSignal): Promise<ISoundtouchSnapshot> {
119
+ const state = await this.#protocol.readState({ signal: signalArg });
120
+ const snapshot = this.#snapshot(state); this.#emit({ type: 'snapshot', snapshot }); return snapshot;
121
+ }
122
+ #snapshot(stateArg: plugins.ecobridgeDeviceManager.IBoseSoundTouchState): ISoundtouchSnapshot {
123
+ return Object.freeze({ ...stateArg, homeId: this.#config.homeId, ...(this.#config.name === undefined ? {} : { displayName: this.#config.name }),
124
+ volumeLimit: this.#config.volumeLimit, online: true as const });
125
+ }
126
+ async #control(controlArg: plugins.ecobridgeDeviceManager.IBoseSoundTouchControl, signalArg: AbortSignal): Promise<plugins.ecobridgeDeviceManager.IBoseSoundTouchControlResult> {
127
+ const observation = await this.#protocol.setControl(controlArg, { signal: signalArg });
128
+ this.#emit({ type: 'control', observation });
129
+ this.#emit({ type: 'snapshot', snapshot: this.#snapshot(observation.observed) });
130
+ return observation;
131
+ }
132
+ #run<TResult>(workArg: (signalArg: AbortSignal) => Promise<TResult>, signalArg?: AbortSignal): Promise<TResult> {
133
+ if (this.#closed || this.#protocol.isClosed) return Promise.reject(new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError('SoundTouch client is terminally closed.', 'closed'));
134
+ if (signalArg !== undefined && !(signalArg instanceof AbortSignal)) return Promise.reject(new TypeError('SoundTouch operation signal must be an AbortSignal.'));
135
+ if (signalArg?.aborted) return Promise.reject(new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError('SoundTouch operation was cancelled before admission.', 'cancelled'));
136
+ if (this.#operations.size >= this.#config.maxPendingOperations) return Promise.reject(new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError('SoundTouch client operation capacity is full.', 'capacity'));
137
+ const deadline = new AbortController();
138
+ const signal = AbortSignal.any([this.#lifetime.signal, deadline.signal, ...(signalArg ? [signalArg] : [])]);
139
+ const timer = setTimeout(() => deadline.abort(new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError('SoundTouch consumer deadline expired.', 'timeout')), this.#config.requestTimeoutMs);
140
+ timer.unref();
141
+ const operation = this.#tail.then(() => { signal.throwIfAborted(); return workArg(signal); }).then((resultArg) => {
142
+ this.#healthy = !this.#closed && !signal.aborted && !this.#protocol.isClosed; return resultArg;
143
+ }, (errorArg: unknown) => {
144
+ this.#healthy = false;
145
+ const error = deadline.signal.aborted && errorArg instanceof plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError
146
+ ? new plugins.ecobridgeDeviceManager.BoseSoundTouchProtocolError('SoundTouch consumer deadline expired.', 'timeout', errorArg.effectOutcome) : errorArg;
147
+ if (!this.#closed && error instanceof Error) this.#emit({ type: 'error', error });
148
+ throw error;
149
+ }).finally(() => { clearTimeout(timer); this.#operations.delete(operation); });
150
+ this.#operations.add(operation);
151
+ this.#tail = operation.then(() => {}, () => {});
152
+ return operation;
153
+ }
154
+ #emit(eventArg: TSoundtouchClientEvent): void {
155
+ if (this.#closed) return;
156
+ for (const subscriber of [...this.#subscribers]) {
157
+ if (!subscriber.active || !this.#subscribers.has(subscriber)) continue;
158
+ try { subscriber.handler(structuredClone(eventArg)); }
159
+ catch (errorArg) { subscriber.failed = true; subscriber.failure = errorArg; subscriber.detach(); }
471
160
  }
472
- return `http://${formatHost(host)}:${portArg}`;
473
- }
474
-
475
- private cloneValue<TValue>(valueArg: TValue): TValue {
476
- return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
477
161
  }
478
162
  }
479
-
480
- const readFirstElement = (xmlArg: string, tagArg: string): string | undefined => {
481
- const escapedTag = escapeRegExp(tagArg);
482
- const match = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>`, 'i').exec(xmlArg)
483
- || new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>`, 'i').exec(xmlArg);
484
- return match?.[0];
485
- };
486
-
487
- const readAllElements = (xmlArg: string, tagArg: string): string[] => {
488
- const escapedTag = escapeRegExp(tagArg);
489
- const regex = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>[\\s\\S]*?<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>|<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>`, 'gi');
490
- const values: string[] = [];
491
- let match: RegExpExecArray | null;
492
- while ((match = regex.exec(xmlArg))) {
493
- values.push(match[0]);
494
- }
495
- return values;
496
- };
497
-
498
- const readTagValue = (xmlArg: string, tagArg: string): string | undefined => {
499
- const element = readFirstElement(xmlArg, tagArg);
500
- if (!element) {
501
- return undefined;
502
- }
503
- return stripTags(innerXml(element, tagArg)).trim() || undefined;
504
- };
505
-
506
- const innerText = (elementArg: string, tagArg: string): string | undefined => {
507
- return stripTags(innerXml(elementArg, tagArg)).trim() || undefined;
508
- };
509
-
510
- const innerXml = (elementArg: string, tagArg: string): string => {
511
- const escapedTag = escapeRegExp(tagArg);
512
- return elementArg
513
- .replace(new RegExp(`^<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*>`, 'i'), '')
514
- .replace(new RegExp(`<\\/(?:[A-Za-z0-9_]+:)?${escapedTag}>$`, 'i'), '')
515
- .replace(new RegExp(`^<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b[^>]*/>$`, 'i'), '');
516
- };
517
-
518
- const elementAttributes = (elementArg: string, tagArg: string): Record<string, string> => {
519
- const escapedTag = escapeRegExp(tagArg);
520
- const match = new RegExp(`<(?:[A-Za-z0-9_]+:)?${escapedTag}\\b([^>]*)>`, 'i').exec(elementArg);
521
- return parseAttributes(match?.[1] || '');
522
- };
523
-
524
- const parseAttributes = (valueArg: string): Record<string, string> => {
525
- const attrs: Record<string, string> = {};
526
- const regex = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
527
- let match: RegExpExecArray | null;
528
- while ((match = regex.exec(valueArg))) {
529
- attrs[match[1]] = unescapeXml(match[2] ?? match[3] ?? '');
530
- }
531
- return attrs;
532
- };
533
-
534
- const stripTags = (valueArg: string): string => unescapeXml(valueArg.replace(/<[^>]+>/g, ''));
535
-
536
- const numberValue = (valueArg: unknown): number | undefined => {
537
- if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
538
- return valueArg;
539
- }
540
- if (typeof valueArg === 'string' && valueArg.trim()) {
541
- const parsed = Number(valueArg);
542
- return Number.isFinite(parsed) ? parsed : undefined;
543
- }
544
- return undefined;
545
- };
546
-
547
- const booleanValue = (valueArg: unknown): boolean | undefined => {
548
- if (typeof valueArg === 'boolean') {
549
- return valueArg;
550
- }
551
- if (typeof valueArg === 'string') {
552
- const normalized = valueArg.toLowerCase();
553
- if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
554
- return true;
555
- }
556
- if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
557
- return false;
558
- }
559
- }
560
- return undefined;
561
- };
562
-
563
- const escapeXml = (valueArg: string): string => {
564
- return valueArg.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
565
- };
566
-
567
- const unescapeXml = (valueArg: string): string => {
568
- return valueArg.replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
569
- };
570
-
571
- const escapeRegExp = (valueArg: string): string => valueArg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
572
-
573
- const isRecord = (valueArg: unknown): valueArg is Record<string, unknown> => {
574
- return Boolean(valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg));
575
- };
576
-
577
- const isFetchResponse = (valueArg: unknown): valueArg is Response => {
578
- return Boolean(valueArg && typeof valueArg === 'object' && typeof (valueArg as Response).text === 'function');
579
- };
580
-
581
- const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;