@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.
- package/changelog.md +49 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/core/classes.configstore.d.ts +2 -0
- package/dist_ts/core/classes.configstore.js +84 -27
- package/dist_ts/core/classes.nativehomeadmission.d.ts +19 -0
- package/dist_ts/core/classes.nativehomeadmission.js +84 -0
- package/dist_ts/core/classes.nativeintegrationowner.d.ts +79 -0
- package/dist_ts/core/classes.nativeintegrationowner.js +269 -0
- package/dist_ts/core/index.d.ts +2 -0
- package/dist_ts/core/index.js +3 -1
- package/dist_ts/core/types.d.ts +14 -0
- package/dist_ts/integrations/braviatv/braviatv.classes.client.d.ts +23 -72
- package/dist_ts/integrations/braviatv/braviatv.classes.client.js +265 -625
- package/dist_ts/integrations/braviatv/braviatv.classes.configflow.d.ts +7 -7
- package/dist_ts/integrations/braviatv/braviatv.classes.configflow.js +51 -68
- package/dist_ts/integrations/braviatv/braviatv.classes.integration.d.ts +8 -11
- package/dist_ts/integrations/braviatv/braviatv.classes.integration.js +186 -135
- package/dist_ts/integrations/braviatv/braviatv.discovery.js +12 -11
- package/dist_ts/integrations/braviatv/braviatv.mapper.d.ts +6 -14
- package/dist_ts/integrations/braviatv/braviatv.mapper.js +37 -131
- package/dist_ts/integrations/braviatv/braviatv.types.d.ts +55 -133
- package/dist_ts/integrations/braviatv/braviatv.validation.d.ts +7 -0
- package/dist_ts/integrations/braviatv/braviatv.validation.js +72 -0
- package/dist_ts/integrations/broadlink/broadlink.actions.d.ts +2 -0
- package/dist_ts/integrations/broadlink/broadlink.actions.js +36 -0
- package/dist_ts/integrations/broadlink/broadlink.classes.client.d.ts +21 -19
- package/dist_ts/integrations/broadlink/broadlink.classes.client.js +185 -124
- package/dist_ts/integrations/broadlink/broadlink.classes.configflow.d.ts +5 -4
- package/dist_ts/integrations/broadlink/broadlink.classes.configflow.js +64 -83
- package/dist_ts/integrations/broadlink/broadlink.classes.integration.d.ts +16 -19
- package/dist_ts/integrations/broadlink/broadlink.classes.integration.js +114 -59
- package/dist_ts/integrations/broadlink/broadlink.mapper.d.ts +3 -2
- package/dist_ts/integrations/broadlink/broadlink.mapper.js +2 -1
- package/dist_ts/integrations/broadlink/broadlink.selected.mapper.d.ts +10 -0
- package/dist_ts/integrations/broadlink/broadlink.selected.mapper.js +41 -0
- package/dist_ts/integrations/broadlink/broadlink.types.d.ts +43 -7
- package/dist_ts/integrations/broadlink/broadlink.validation.d.ts +7 -0
- package/dist_ts/integrations/broadlink/broadlink.validation.js +63 -0
- package/dist_ts/integrations/daikin/daikin.classes.client.d.ts +10 -52
- package/dist_ts/integrations/daikin/daikin.classes.client.js +55 -614
- package/dist_ts/integrations/daikin/daikin.classes.configflow.d.ts +8 -12
- package/dist_ts/integrations/daikin/daikin.classes.configflow.js +77 -138
- package/dist_ts/integrations/daikin/daikin.classes.integration.d.ts +7 -24
- package/dist_ts/integrations/daikin/daikin.classes.integration.js +175 -67
- package/dist_ts/integrations/daikin/daikin.discovery.d.ts +11 -24
- package/dist_ts/integrations/daikin/daikin.discovery.js +98 -305
- package/dist_ts/integrations/daikin/daikin.mapper.d.ts +6 -7
- package/dist_ts/integrations/daikin/daikin.mapper.js +95 -155
- package/dist_ts/integrations/daikin/daikin.types.d.ts +24 -44
- package/dist_ts/integrations/daikin/daikin.types.js +1 -1
- package/dist_ts/integrations/daikin/daikin.validation.d.ts +5 -0
- package/dist_ts/integrations/daikin/daikin.validation.js +53 -0
- package/dist_ts/integrations/denonavr/denonavr.classes.client.d.ts +31 -40
- package/dist_ts/integrations/denonavr/denonavr.classes.client.js +149 -404
- package/dist_ts/integrations/denonavr/denonavr.classes.configflow.d.ts +7 -2
- package/dist_ts/integrations/denonavr/denonavr.classes.configflow.js +44 -36
- package/dist_ts/integrations/denonavr/denonavr.classes.integration.d.ts +6 -11
- package/dist_ts/integrations/denonavr/denonavr.classes.integration.js +192 -113
- package/dist_ts/integrations/denonavr/denonavr.discovery.d.ts +10 -18
- package/dist_ts/integrations/denonavr/denonavr.discovery.js +71 -193
- package/dist_ts/integrations/denonavr/denonavr.mapper.d.ts +5 -13
- package/dist_ts/integrations/denonavr/denonavr.mapper.js +35 -159
- package/dist_ts/integrations/denonavr/denonavr.types.d.ts +55 -90
- package/dist_ts/integrations/denonavr/denonavr.validation.d.ts +10 -0
- package/dist_ts/integrations/denonavr/denonavr.validation.js +86 -0
- package/dist_ts/integrations/kodi/kodi.classes.client.d.ts +11 -45
- package/dist_ts/integrations/kodi/kodi.classes.client.js +84 -310
- package/dist_ts/integrations/kodi/kodi.classes.configflow.d.ts +9 -8
- package/dist_ts/integrations/kodi/kodi.classes.configflow.js +53 -48
- package/dist_ts/integrations/kodi/kodi.classes.integration.d.ts +11 -11
- package/dist_ts/integrations/kodi/kodi.classes.integration.js +114 -171
- package/dist_ts/integrations/kodi/kodi.discovery.js +53 -29
- package/dist_ts/integrations/kodi/kodi.mapper.d.ts +7 -2
- package/dist_ts/integrations/kodi/kodi.mapper.js +48 -10
- package/dist_ts/integrations/kodi/kodi.types.d.ts +32 -28
- package/dist_ts/integrations/kodi/kodi.validation.d.ts +9 -0
- package/dist_ts/integrations/kodi/kodi.validation.js +62 -0
- package/dist_ts/integrations/modbus/modbus.classes.client.d.ts +15 -61
- package/dist_ts/integrations/modbus/modbus.classes.client.js +117 -776
- package/dist_ts/integrations/modbus/modbus.classes.configflow.d.ts +9 -5
- package/dist_ts/integrations/modbus/modbus.classes.configflow.js +70 -38
- package/dist_ts/integrations/modbus/modbus.classes.integration.d.ts +6 -17
- package/dist_ts/integrations/modbus/modbus.classes.integration.js +133 -57
- package/dist_ts/integrations/modbus/modbus.discovery.d.ts +5 -8
- package/dist_ts/integrations/modbus/modbus.discovery.js +38 -93
- package/dist_ts/integrations/modbus/modbus.mapper.d.ts +8 -32
- package/dist_ts/integrations/modbus/modbus.mapper.js +71 -431
- package/dist_ts/integrations/modbus/modbus.types.d.ts +82 -332
- package/dist_ts/integrations/modbus/modbus.validation.d.ts +10 -0
- package/dist_ts/integrations/modbus/modbus.validation.js +128 -0
- package/dist_ts/integrations/nanoleaf/nanoleaf.classes.client.d.ts +12 -34
- package/dist_ts/integrations/nanoleaf/nanoleaf.classes.client.js +143 -231
- package/dist_ts/integrations/nanoleaf/nanoleaf.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/nanoleaf/nanoleaf.classes.configflow.js +105 -18
- package/dist_ts/integrations/nanoleaf/nanoleaf.classes.integration.d.ts +8 -8
- package/dist_ts/integrations/nanoleaf/nanoleaf.classes.integration.js +141 -155
- package/dist_ts/integrations/nanoleaf/nanoleaf.credentials.d.ts +9 -0
- package/dist_ts/integrations/nanoleaf/nanoleaf.credentials.js +35 -0
- package/dist_ts/integrations/nanoleaf/nanoleaf.mapper.d.ts +6 -18
- package/dist_ts/integrations/nanoleaf/nanoleaf.mapper.js +45 -220
- package/dist_ts/integrations/nanoleaf/nanoleaf.types.d.ts +33 -147
- package/dist_ts/integrations/nanoleaf/nanoleaf.validation.d.ts +6 -0
- package/dist_ts/integrations/nanoleaf/nanoleaf.validation.js +82 -0
- package/dist_ts/integrations/onkyo/onkyo.classes.client.d.ts +15 -20
- package/dist_ts/integrations/onkyo/onkyo.classes.client.js +161 -404
- package/dist_ts/integrations/onkyo/onkyo.classes.configflow.d.ts +9 -4
- package/dist_ts/integrations/onkyo/onkyo.classes.configflow.js +51 -6
- package/dist_ts/integrations/onkyo/onkyo.classes.integration.d.ts +22 -6
- package/dist_ts/integrations/onkyo/onkyo.classes.integration.js +144 -11
- package/dist_ts/integrations/onkyo/onkyo.discovery.d.ts +6 -1
- package/dist_ts/integrations/onkyo/onkyo.discovery.js +40 -4
- package/dist_ts/integrations/onkyo/onkyo.mapper.d.ts +7 -8
- package/dist_ts/integrations/onkyo/onkyo.mapper.js +45 -12
- package/dist_ts/integrations/onkyo/onkyo.types.d.ts +58 -8
- package/dist_ts/integrations/onkyo/onkyo.types.js +13 -102
- package/dist_ts/integrations/onkyo/onkyo.validation.d.ts +16 -0
- package/dist_ts/integrations/onkyo/onkyo.validation.js +77 -0
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.client.d.ts +25 -25
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.client.js +116 -360
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.d.ts +11 -3
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.js +76 -6
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.integration.d.ts +26 -4
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.classes.integration.js +142 -9
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.discovery.d.ts +2 -1
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.discovery.js +31 -4
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.mapper.d.ts +9 -9
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.mapper.js +31 -149
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.types.d.ts +51 -11
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.types.js +22 -87
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.validation.d.ts +6 -0
- package/dist_ts/integrations/panasonic_viera/panasonic_viera.validation.js +56 -0
- package/dist_ts/integrations/pioneer/pioneer.classes.client.d.ts +31 -16
- package/dist_ts/integrations/pioneer/pioneer.classes.client.js +167 -409
- package/dist_ts/integrations/pioneer/pioneer.classes.configflow.d.ts +10 -3
- package/dist_ts/integrations/pioneer/pioneer.classes.configflow.js +66 -6
- package/dist_ts/integrations/pioneer/pioneer.classes.integration.d.ts +13 -5
- package/dist_ts/integrations/pioneer/pioneer.classes.integration.js +239 -11
- package/dist_ts/integrations/pioneer/pioneer.discovery.d.ts +14 -1
- package/dist_ts/integrations/pioneer/pioneer.discovery.js +51 -4
- package/dist_ts/integrations/pioneer/pioneer.mapper.d.ts +14 -8
- package/dist_ts/integrations/pioneer/pioneer.mapper.js +54 -12
- package/dist_ts/integrations/pioneer/pioneer.types.d.ts +79 -12
- package/dist_ts/integrations/pioneer/pioneer.types.js +12 -76
- package/dist_ts/integrations/pioneer/pioneer.validation.d.ts +10 -0
- package/dist_ts/integrations/pioneer/pioneer.validation.js +85 -0
- package/dist_ts/integrations/soundtouch/soundtouch.classes.client.d.ts +13 -36
- package/dist_ts/integrations/soundtouch/soundtouch.classes.client.js +179 -502
- package/dist_ts/integrations/soundtouch/soundtouch.classes.configflow.d.ts +6 -1
- package/dist_ts/integrations/soundtouch/soundtouch.classes.configflow.js +50 -40
- package/dist_ts/integrations/soundtouch/soundtouch.classes.integration.d.ts +14 -15
- package/dist_ts/integrations/soundtouch/soundtouch.classes.integration.js +108 -128
- package/dist_ts/integrations/soundtouch/soundtouch.discovery.d.ts +2 -20
- package/dist_ts/integrations/soundtouch/soundtouch.discovery.js +23 -113
- package/dist_ts/integrations/soundtouch/soundtouch.mapper.d.ts +3 -12
- package/dist_ts/integrations/soundtouch/soundtouch.mapper.js +35 -189
- package/dist_ts/integrations/soundtouch/soundtouch.types.d.ts +57 -142
- package/dist_ts/integrations/soundtouch/soundtouch.types.js +18 -3
- package/dist_ts/integrations/soundtouch/soundtouch.validation.d.ts +22 -0
- package/dist_ts/integrations/soundtouch/soundtouch.validation.js +65 -0
- package/dist_ts/integrations/wiz/index.d.ts +1 -0
- package/dist_ts/integrations/wiz/index.js +2 -1
- package/dist_ts/integrations/wiz/wiz.classes.client.d.ts +14 -28
- package/dist_ts/integrations/wiz/wiz.classes.client.js +93 -229
- package/dist_ts/integrations/wiz/wiz.classes.configflow.d.ts +6 -4
- package/dist_ts/integrations/wiz/wiz.classes.configflow.js +38 -67
- package/dist_ts/integrations/wiz/wiz.classes.integration.d.ts +10 -21
- package/dist_ts/integrations/wiz/wiz.classes.integration.js +204 -44
- package/dist_ts/integrations/wiz/wiz.discovery.d.ts +10 -31
- package/dist_ts/integrations/wiz/wiz.discovery.js +48 -249
- package/dist_ts/integrations/wiz/wiz.mapper.d.ts +7 -51
- package/dist_ts/integrations/wiz/wiz.mapper.js +82 -668
- package/dist_ts/integrations/wiz/wiz.types.d.ts +34 -208
- package/dist_ts/integrations/wiz/wiz.validation.d.ts +8 -0
- package/dist_ts/integrations/wiz/wiz.validation.js +72 -0
- package/dist_ts/integrations/yamaha/index.d.ts +1 -0
- package/dist_ts/integrations/yamaha/index.js +2 -1
- package/dist_ts/integrations/yamaha/yamaha.classes.client.d.ts +30 -4
- package/dist_ts/integrations/yamaha/yamaha.classes.client.js +162 -6
- package/dist_ts/integrations/yamaha/yamaha.classes.configflow.d.ts +10 -3
- package/dist_ts/integrations/yamaha/yamaha.classes.configflow.js +106 -6
- package/dist_ts/integrations/yamaha/yamaha.classes.integration.d.ts +22 -4
- package/dist_ts/integrations/yamaha/yamaha.classes.integration.js +243 -8
- package/dist_ts/integrations/yamaha/yamaha.discovery.d.ts +13 -1
- package/dist_ts/integrations/yamaha/yamaha.discovery.js +57 -4
- package/dist_ts/integrations/yamaha/yamaha.mapper.d.ts +16 -8
- package/dist_ts/integrations/yamaha/yamaha.mapper.js +58 -12
- package/dist_ts/integrations/yamaha/yamaha.types.d.ts +73 -6
- package/dist_ts/integrations/yamaha/yamaha.types.js +8 -94
- package/dist_ts/integrations/yamaha/yamaha.validation.d.ts +12 -0
- package/dist_ts/integrations/yamaha/yamaha.validation.js +89 -0
- package/package.json +3 -3
- package/readme.md +514 -2
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/core/classes.configstore.ts +84 -27
- package/ts/core/classes.nativehomeadmission.ts +96 -0
- package/ts/core/classes.nativeintegrationowner.ts +297 -0
- package/ts/core/index.ts +2 -0
- package/ts/core/types.ts +16 -0
- package/ts/integrations/braviatv/braviatv.classes.client.ts +245 -709
- package/ts/integrations/braviatv/braviatv.classes.configflow.ts +47 -76
- package/ts/integrations/braviatv/braviatv.classes.integration.ts +167 -147
- package/ts/integrations/braviatv/braviatv.discovery.ts +11 -10
- package/ts/integrations/braviatv/braviatv.mapper.ts +39 -142
- package/ts/integrations/braviatv/braviatv.types.ts +53 -173
- package/ts/integrations/braviatv/braviatv.validation.ts +68 -0
- package/ts/integrations/broadlink/broadlink.actions.ts +31 -0
- package/ts/integrations/broadlink/broadlink.classes.client.ts +170 -144
- package/ts/integrations/broadlink/broadlink.classes.configflow.ts +59 -89
- package/ts/integrations/broadlink/broadlink.classes.integration.ts +110 -64
- package/ts/integrations/broadlink/broadlink.mapper.ts +5 -4
- package/ts/integrations/broadlink/broadlink.selected.mapper.ts +46 -0
- package/ts/integrations/broadlink/broadlink.types.ts +48 -8
- package/ts/integrations/broadlink/broadlink.validation.ts +66 -0
- package/ts/integrations/daikin/daikin.classes.client.ts +52 -661
- package/ts/integrations/daikin/daikin.classes.configflow.ts +72 -152
- package/ts/integrations/daikin/daikin.classes.integration.ts +166 -71
- package/ts/integrations/daikin/daikin.discovery.ts +94 -346
- package/ts/integrations/daikin/daikin.mapper.ts +83 -166
- package/ts/integrations/daikin/daikin.types.ts +27 -48
- package/ts/integrations/daikin/daikin.validation.ts +51 -0
- package/ts/integrations/daikin/readme.md +83 -0
- package/ts/integrations/denonavr/denonavr.classes.client.ts +132 -421
- package/ts/integrations/denonavr/denonavr.classes.configflow.ts +44 -37
- package/ts/integrations/denonavr/denonavr.classes.integration.ts +171 -117
- package/ts/integrations/denonavr/denonavr.discovery.ts +67 -220
- package/ts/integrations/denonavr/denonavr.mapper.ts +36 -182
- package/ts/integrations/denonavr/denonavr.types.ts +48 -111
- package/ts/integrations/denonavr/denonavr.validation.ts +87 -0
- package/ts/integrations/kodi/kodi.classes.client.ts +72 -352
- package/ts/integrations/kodi/kodi.classes.configflow.ts +52 -55
- package/ts/integrations/kodi/kodi.classes.integration.ts +97 -178
- package/ts/integrations/kodi/kodi.discovery.ts +49 -28
- package/ts/integrations/kodi/kodi.mapper.ts +52 -10
- package/ts/integrations/kodi/kodi.types.ts +33 -58
- package/ts/integrations/kodi/kodi.validation.ts +60 -0
- package/ts/integrations/modbus/modbus.classes.client.ts +100 -842
- package/ts/integrations/modbus/modbus.classes.configflow.ts +70 -41
- package/ts/integrations/modbus/modbus.classes.integration.ts +115 -60
- package/ts/integrations/modbus/modbus.discovery.ts +38 -106
- package/ts/integrations/modbus/modbus.mapper.ts +64 -467
- package/ts/integrations/modbus/modbus.types.ts +64 -381
- package/ts/integrations/modbus/modbus.validation.ts +119 -0
- package/ts/integrations/nanoleaf/nanoleaf.classes.client.ts +115 -249
- package/ts/integrations/nanoleaf/nanoleaf.classes.configflow.ts +90 -19
- package/ts/integrations/nanoleaf/nanoleaf.classes.integration.ts +130 -181
- package/ts/integrations/nanoleaf/nanoleaf.credentials.ts +39 -0
- package/ts/integrations/nanoleaf/nanoleaf.mapper.ts +47 -260
- package/ts/integrations/nanoleaf/nanoleaf.types.ts +29 -147
- package/ts/integrations/nanoleaf/nanoleaf.validation.ts +77 -0
- package/ts/integrations/onkyo/onkyo.classes.client.ts +148 -457
- package/ts/integrations/onkyo/onkyo.classes.configflow.ts +52 -6
- package/ts/integrations/onkyo/onkyo.classes.integration.ts +121 -12
- package/ts/integrations/onkyo/onkyo.discovery.ts +41 -3
- package/ts/integrations/onkyo/onkyo.mapper.ts +46 -19
- package/ts/integrations/onkyo/onkyo.types.ts +55 -110
- package/ts/integrations/onkyo/onkyo.validation.ts +85 -0
- package/ts/integrations/onkyo/readme.md +126 -0
- package/ts/integrations/panasonic_viera/panasonic_viera.classes.client.ts +101 -398
- package/ts/integrations/panasonic_viera/panasonic_viera.classes.configflow.ts +69 -5
- package/ts/integrations/panasonic_viera/panasonic_viera.classes.integration.ts +141 -9
- package/ts/integrations/panasonic_viera/panasonic_viera.discovery.ts +32 -3
- package/ts/integrations/panasonic_viera/panasonic_viera.mapper.ts +34 -161
- package/ts/integrations/panasonic_viera/panasonic_viera.types.ts +52 -96
- package/ts/integrations/panasonic_viera/panasonic_viera.validation.ts +56 -0
- package/ts/integrations/pioneer/pioneer.classes.client.ts +132 -428
- package/ts/integrations/pioneer/pioneer.classes.configflow.ts +67 -5
- package/ts/integrations/pioneer/pioneer.classes.integration.ts +225 -11
- package/ts/integrations/pioneer/pioneer.discovery.ts +45 -3
- package/ts/integrations/pioneer/pioneer.mapper.ts +57 -13
- package/ts/integrations/pioneer/pioneer.types.ts +87 -85
- package/ts/integrations/pioneer/pioneer.validation.ts +84 -0
- package/ts/integrations/soundtouch/soundtouch.classes.client.ts +152 -571
- package/ts/integrations/soundtouch/soundtouch.classes.configflow.ts +47 -42
- package/ts/integrations/soundtouch/soundtouch.classes.integration.ts +95 -150
- package/ts/integrations/soundtouch/soundtouch.discovery.ts +21 -133
- package/ts/integrations/soundtouch/soundtouch.mapper.ts +36 -207
- package/ts/integrations/soundtouch/soundtouch.types.ts +50 -195
- package/ts/integrations/soundtouch/soundtouch.validation.ts +60 -0
- package/ts/integrations/wiz/index.ts +1 -0
- package/ts/integrations/wiz/wiz.classes.client.ts +84 -254
- package/ts/integrations/wiz/wiz.classes.configflow.ts +39 -70
- package/ts/integrations/wiz/wiz.classes.integration.ts +189 -45
- package/ts/integrations/wiz/wiz.discovery.ts +48 -291
- package/ts/integrations/wiz/wiz.mapper.ts +81 -754
- package/ts/integrations/wiz/wiz.types.ts +24 -224
- package/ts/integrations/wiz/wiz.validation.ts +73 -0
- package/ts/integrations/yamaha/index.ts +1 -0
- package/ts/integrations/yamaha/yamaha.classes.client.ts +150 -6
- package/ts/integrations/yamaha/yamaha.classes.configflow.ts +100 -5
- package/ts/integrations/yamaha/yamaha.classes.integration.ts +236 -8
- package/ts/integrations/yamaha/yamaha.discovery.ts +52 -3
- package/ts/integrations/yamaha/yamaha.mapper.ts +65 -13
- package/ts/integrations/yamaha/yamaha.types.ts +77 -97
- package/ts/integrations/yamaha/yamaha.validation.ts +86 -0
|
@@ -1,581 +1,162 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
config:
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
240
|
-
|
|
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
|
|
104
|
+
return this.#retryPromise;
|
|
448
105
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
565
|
-
};
|
|
566
|
-
|
|
567
|
-
const unescapeXml = (valueArg: string): string => {
|
|
568
|
-
return valueArg.replace(/'/g, "'").replace(/"/g, '"').replace(/>/g, '>').replace(/</g, '<').replace(/&/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;
|